diff --git a/app/components/EmotionForecastPopup.tsx b/app/components/EmotionForecastPopup.tsx new file mode 100644 index 0000000..64b6155 --- /dev/null +++ b/app/components/EmotionForecastPopup.tsx @@ -0,0 +1,231 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { motion, AnimatePresence, PanInfo } from 'framer-motion'; +import { useRouter } from 'next/navigation'; +import { CATEGORY_COLORS } from '../types/common'; + +interface EmotionForecast { + timeSlot: string; + emotion: string; + temperature: number; + image: string; + category: string; +} + +interface EmotionForecastPopupProps { + isOpen: boolean; + onClose: () => void; + forecasts: EmotionForecast[]; +} + +const getEmotionImage = (imageUrl: string) => { + // DB에서 받은 이미지 URL을 SVG big 버전으로 변환 + console.log('원본 이미지 URL:', imageUrl); + + if (!imageUrl) { + console.error('이미지 URL이 없습니다'); + return '/icon/기쁜-big.svg'; // 기본 이미지 + } + + let processedUrl = imageUrl; + + // PNG를 SVG로 변경하고 big 버전 사용 + + // small을 big으로 변경 + + console.log('최종 SVG URL:', processedUrl); + return processedUrl; +}; + +const getTimeSlotLabel = (timeSlot: string) => { + const timeLabels: { [key: string]: string } = { + 'morning': '아침', + 'afternoon': '점심', + 'evening': '저녁' + }; + + return timeLabels[timeSlot] || timeSlot; +}; + +const getCategoryColor = (category: string) => { + return CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS] || '#666666'; +}; + +export default function EmotionForecastPopup({ isOpen, onClose, forecasts }: EmotionForecastPopupProps) { + const [currentIndex, setCurrentIndex] = useState(0); + const router = useRouter(); + const [dragDirection, setDragDirection] = useState<'left' | 'right' | null>(null); + const [hasSwiped, setHasSwiped] = useState(false); + + const handleClose = () => { + onClose(); + router.push('/baby'); + }; + + useEffect(() => { + if (isOpen) { + setCurrentIndex(0); + setHasSwiped(false); + console.log('팝업 열림 - 감정 데이터:', forecasts); + } + }, [isOpen, forecasts]); + + // 키보드 이벤트 처리 + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!isOpen || forecasts.length <= 1) return; + + switch (event.key) { + case 'ArrowLeft': + event.preventDefault(); + setCurrentIndex((prev) => (prev + 1) % forecasts.length); + setHasSwiped(true); + break; + case 'ArrowRight': + event.preventDefault(); + setCurrentIndex((prev) => (prev - 1 + forecasts.length) % forecasts.length); + setHasSwiped(true); + break; + } + }; + + if (isOpen) { + document.addEventListener('keydown', handleKeyDown); + } + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, forecasts.length]); + + const nextSlide = () => { + setCurrentIndex((prev) => (prev + 1) % forecasts.length); + }; + + const prevSlide = () => { + setCurrentIndex((prev) => (prev - 1 + forecasts.length) % forecasts.length); + }; + + const handleDragEnd = (event: any, info: PanInfo) => { + const threshold = 50; // 슬라이드 임계값 + + if (info.offset.x > threshold) { + // 오른쪽으로 드래그 - 이전 슬라이드 + setCurrentIndex((prev) => (prev - 1 + forecasts.length) % forecasts.length); + setHasSwiped(true); + } else if (info.offset.x < -threshold) { + // 왼쪽으로 드래그 - 다음 슬라이드 + setCurrentIndex((prev) => (prev + 1) % forecasts.length); + setHasSwiped(true); + } + + setDragDirection(null); + }; + + if (!isOpen || forecasts.length === 0) return null; + + const currentForecast = forecasts[currentIndex]; + + return ( + + + e.stopPropagation()} + > + {/* Slide Container */} + + {/* Forecast Card */} +
+ {/* Top Row - Temperature */} +
+ {/* Temperature - 왼쪽 상단 */} +
+ + {currentForecast.temperature}° + +
+
+ + {/* Content Area */} +
+ {/* Emotion Icon - 가운데 */} +
+ {currentForecast.emotion} { + console.error('이미지 로딩 실패:', currentForecast.image); + console.error('감정 데이터:', currentForecast); + // 이미지 로딩 실패 시 기본 이모지 표시 + e.currentTarget.style.display = 'none'; + const fallbackDiv = document.createElement('div'); + fallbackDiv.className = 'w-full h-full flex items-center justify-center text-4xl'; + fallbackDiv.textContent = '😐'; + e.currentTarget.parentNode?.appendChild(fallbackDiv); + }} + onLoad={() => { + console.log('이미지 로딩 성공:', currentForecast.image); + }} + /> +
+
+ + {/* Bottom Text */} +
+ {/* Forecast Text */} +
+ {getTimeSlotLabel(currentForecast.timeSlot)}의 감정은{' '} + {currentForecast.emotion}으로
+ {currentForecast.temperature}° 예정이에요. +
+
+
+ + {/* Swipe Hint */} + {forecasts.length > 1 && !hasSwiped && ( +
+
← 슬라이드 →
+
또는 화살표 키 사용
+
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/components/EmotionResultPopup.tsx b/app/components/EmotionResultPopup.tsx new file mode 100644 index 0000000..44cbedc --- /dev/null +++ b/app/components/EmotionResultPopup.tsx @@ -0,0 +1,209 @@ +'use client' + +import React, { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; + +interface EmotionData { + step: string; + emotion: { + id: number; + name: string; + type: string; + temp: number; + image: string; + }; + category: string; + memo?: string; +} + +interface EmotionResultPopupProps { + isVisible: boolean; + onClose: () => void; + emotions: EmotionData[]; +} + +const TIME_PERIODS = { + morning: { label: '오전', text: '오전의 감정은' }, + afternoon: { label: '오후', text: '오후의 감정은' }, + evening: { label: '저녁', text: '저녁의 감정은' } +}; + +const getEmotionColor = (type: string) => { + switch (type) { + case 'positive': + return 'bg-red-500'; + case 'negative': + return 'bg-blue-500'; + default: + return 'bg-gray-500'; + } +}; + +const getTemperatureColor = (temp: number) => { + if (temp >= 0) return 'text-red-600'; + return 'text-blue-600'; +}; + +export default function EmotionResultPopup({ isVisible, onClose, emotions }: EmotionResultPopupProps) { + const [currentIndex, setCurrentIndex] = useState(0); + + const nextSlide = () => { + setCurrentIndex((prev) => (prev + 1) % emotions.length); + }; + + const prevSlide = () => { + setCurrentIndex((prev) => (prev - 1 + emotions.length) % emotions.length); + }; + + const currentEmotion = emotions[currentIndex]; + const step = currentEmotion?.step as keyof typeof TIME_PERIODS; + + if (!isVisible || !currentEmotion) return null; + + return ( + + + e.stopPropagation()} + > + {/* 닫기 버튼 */} + + + {/* 슬라이드 컨테이너 */} +
+ + {/* 시간대 표시 */} +
+ + {TIME_PERIODS[step]?.label} + +
+ + {/* 감정 카드 */} +
+
+ {/* 온도 */} +
+ + {currentEmotion.emotion.temp > 0 ? '+' : ''}{currentEmotion.emotion.temp}° + +
+ + {/* 감정 아이콘 */} +
+
+ {currentEmotion.emotion.name} +
+
+
+ + {/* 감정 설명 */} +
+

+ {TIME_PERIODS[step]?.text} {currentEmotion.emotion.name}으로 {currentEmotion.emotion.temp > 0 ? '+' : ''}{currentEmotion.emotion.temp}° 예정이에요. +

+
+
+ + {/* 메모 (있는 경우) */} + {currentEmotion.memo && ( +
+

+ "{currentEmotion.memo}" +

+
+ )} + + {/* 감정 카테고리 */} +
+ + {currentEmotion.category} + +
+
+
+ + {/* 네비게이션 */} + {emotions.length > 1 && ( +
+ + + {/* 인디케이터 */} +
+ {emotions.map((_, index) => ( +
+ + +
+ )} + + {/* 완료 버튼 */} +
+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/components/EmotionSelector.tsx b/app/components/EmotionSelector.tsx index 52367d8..13b57e5 100644 --- a/app/components/EmotionSelector.tsx +++ b/app/components/EmotionSelector.tsx @@ -95,50 +95,58 @@ export default function EmotionSelector({ ); } + // 카테고리 순서 정의 + const categoryOrder = ['긍정', '중립', '부정']; + return (
- {Object.entries(emotionCategories).map(([category, categoryEmotions]) => ( - -
{category}
-
- {categoryEmotions.map((emotion) => { - const isSelected = selectedEmotion?.id === emotion.id; - const categoryColor = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS]; - - return ( - handleEmotionClick(category, emotion)} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - {emotion.name} - - ); - })} -
-
- ))} + {categoryOrder.map((category) => { + const categoryEmotions = emotionCategories[category]; + if (!categoryEmotions) return null; + + return ( + +
{category}
+
+ {categoryEmotions.map((emotion) => { + const isSelected = selectedEmotion?.id === emotion.id; + const categoryColor = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS]; + + return ( + handleEmotionClick(category, emotion)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + {emotion.name} + + ); + })} +
+
+ ); + })}
); } \ No newline at end of file diff --git a/app/components/WeatherForecast.tsx b/app/components/WeatherForecast.tsx new file mode 100644 index 0000000..09389ef --- /dev/null +++ b/app/components/WeatherForecast.tsx @@ -0,0 +1,48 @@ +'use client'; + +import React from 'react'; + +interface WeatherForecastProps { + temperature: number; + emotion: string; + timeSlot: string; + className?: string; +} + +const WeatherForecast: React.FC = ({ + temperature, + emotion, + timeSlot, + className = '' +}) => { + return ( +
+ {/* 온도 표시 - 왼쪽 상단 */} +
+ {temperature}° +
+ + {/* 하단 텍스트 - 왼쪽 하단 */} +
+ {timeSlot}의 감정은 {emotion}으로 {temperature}° 예정이에요. +
+
+ ); +}; + +export default WeatherForecast; \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index faf303d..aaf3591 100644 --- a/app/globals.css +++ b/app/globals.css @@ -27,6 +27,21 @@ font-display: swap; } +@font-face { + font-family: 'Cafe24Syongsyong'; + src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_twelve@1.1/Cafe24Syongsyong.woff') format('woff'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'Cafe24Syongsyong'; + src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_twelve@1.1/Cafe24Syongsyong.woff') format('woff'); + font-weight: normal; + font-style: normal; + font-display: swap; +} + body { background: var(--background); color: var(--foreground); diff --git a/app/insert-after/page.tsx b/app/insert-after/page.tsx index fe1128a..f0b3d1c 100644 --- a/app/insert-after/page.tsx +++ b/app/insert-after/page.tsx @@ -1,12 +1,14 @@ 'use client' import React, { useState, useEffect, Suspense } from "react"; +import { motion } from "framer-motion"; import { useRouter, useSearchParams } from "next/navigation"; import Button from "../components/Button"; import { useChild } from "../contexts/ChildContext"; import { EmotionType, TIME_PERIODS, TimeSlot } from "../types/common"; import { getCurrentDate } from "../utils/dateUtils"; import EmotionSelector from "../components/EmotionSelector"; +import EmotionResultPopup from "../components/EmotionResultPopup"; function InsertAfterPageContent() { const { selectedChild } = useChild(); @@ -18,7 +20,31 @@ function InsertAfterPageContent() { const router = useRouter(); const searchParams = useSearchParams(); - const forecastId = searchParams.get('forecastId'); + const baseForecastId = searchParams.get('forecastId'); + + // 각 시간대별 예보 ID 계산 (아침: baseId-1, 점심: baseId, 저녁: baseId+1) + const getForecastIdForStep = (step: TimeSlot) => { + if (!baseForecastId) return null; + const baseId = parseInt(baseForecastId); + let forecastId: number; + + switch (step) { + case 'morning': + forecastId = baseId; // 아침은 baseId보다 1 작음 + break; + case 'afternoon': + forecastId = baseId + 1; // 점심은 baseId + break; + case 'evening': + forecastId = baseId + 1; // 저녁은 baseId보다 1 큼 + break; + default: + forecastId = baseId; + } + + console.log(`📊 ${step} 단계 예보 ID 계산: baseId=${baseId}, forecastId=${forecastId}`); + return forecastId; + }; useEffect(() => { const step = searchParams.get('step') as TimeSlot; @@ -63,51 +89,59 @@ function InsertAfterPageContent() { console.log(`${currentStep} 감정 저장:`, currentEmotionData); // reason 페이지로 이동 - router.push(`/insert-after/reason?step=${currentStep}&forecastId=${forecastId}&date=${searchParams.get('date')}&timeZone=${searchParams.get('timeZone')}`); + const currentForecastId = getForecastIdForStep(currentStep); + router.push(`/insert-after/reason?step=${currentStep}&forecastId=${currentForecastId}&date=${searchParams.get('date')}&timeZone=${searchParams.get('timeZone')}`); }; const isEmotionSelected = selectedEmotion !== null; + const fadeInOutVariants = { + hidden: { opacity: 0, y: 10 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.5 } }, + exit: { opacity: 0, y: -10, transition: { duration: 0.3 } }, + }; + return (
-
-
- {/* 로고 */} -
-
- HeartForecast -
+
+ +
+ +
+ +
+
{getCurrentDate()} {TIME_PERIODS[currentStep].label}
+
+ {TIME_PERIODS[currentStep].text}{`\n`}느꼈나요?
-
- -
-
-
{getCurrentDate()} {TIME_PERIODS[currentStep].label}
-
- {TIME_PERIODS[currentStep].text}{`\n`}느꼈나요? -
- +
- -
- -
+ + + +
); diff --git a/app/insert-after/reason/page.tsx b/app/insert-after/reason/page.tsx index 4bf9885..4bccc35 100644 --- a/app/insert-after/reason/page.tsx +++ b/app/insert-after/reason/page.tsx @@ -5,6 +5,8 @@ import { motion } from "framer-motion"; import { useRouter, useSearchParams } from "next/navigation"; import Button from "../../components/Button"; import { useChild } from "../../contexts/ChildContext"; +import EmotionForecastPopup from "../../components/EmotionForecastPopup"; +import { getCurrentDate } from "../../utils/dateUtils"; const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; @@ -33,7 +35,8 @@ function ReasonPageContent() { const [reason, setReason] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [showCompletionModal, setShowCompletionModal] = useState(false); + const [showResultPopup, setShowResultPopup] = useState(false); + const [allEmotions, setAllEmotions] = useState([]); const router = useRouter(); const searchParams = useSearchParams(); const forecastId = searchParams.get('forecastId'); @@ -48,7 +51,7 @@ function ReasonPageContent() { if (typeof window !== 'undefined') { const savedEmotions = localStorage.getItem('forecastRecordEmotions'); if (savedEmotions) { - try { + try { const emotions = JSON.parse(savedEmotions); console.log('저장된 감정 데이터:', emotions); @@ -63,11 +66,11 @@ function ReasonPageContent() { console.error('현재 단계의 감정 데이터를 찾을 수 없습니다.'); setError('감정 데이터를 찾을 수 없습니다.'); } - } catch (error) { - console.error('감정 데이터 파싱 오류:', error); + } catch (error) { + console.error('감정 데이터 파싱 오류:', error); setError('감정 데이터를 불러오는데 실패했습니다.'); - } - } else { + } + } else { console.error('저장된 감정 데이터가 없습니다.'); setError('감정 데이터를 찾을 수 없습니다.'); } @@ -75,7 +78,15 @@ function ReasonPageContent() { }, [searchParams]); const handleBack = () => { - window.history.back(); + const steps = ['morning', 'afternoon', 'evening']; + const currentIndex = steps.indexOf(currentStep); + const prevStep = steps[currentIndex - 1]; + + if (prevStep) { + router.push(`/insert-after?step=${prevStep}&forecastId=${searchParams.get('forecastId')}&date=${searchParams.get('date')}&timeZone=${searchParams.get('timeZone')}`); + } else { + window.history.back(); + } }; const handleNext = async () => { @@ -88,29 +99,59 @@ function ReasonPageContent() { setIsLoading(true); setError(null); - const forecastDate = searchParams.get('date') || new Date().toISOString().split('T')[0]; - const originalTimeZone = searchParams.get('timeZone'); - const currentTimeZone = TIME_PERIODS[currentStep].label; - - console.log('디버깅 정보:', { - currentStep, - originalTimeZone, - currentTimeZone, - forecastDate, - forecastId + // 원래 예보 정보 조회 + console.log(`🔍 예보 ID ${forecastId} 정보 조회 시작`); + const forecastResponse = await fetch(`${apiBaseUrl}/api/forecasts/forecast/${forecastId}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include' + }); + + if (!forecastResponse.ok) { + throw new Error('원래 예보 정보를 가져올 수 없습니다.'); + } + + const forecastData = await forecastResponse.json(); + console.log('📅 원래 예보 정보:', { + forecastId: forecastData.id, + date: forecastData.date, + timeZone: forecastData.timeZone, + childId: forecastData.childId }); - - // 현재 단계의 예보 기록 생성 + + // 현재 단계에 맞는 시간대 매핑 + const timeZoneMapping: Record = { + 'morning': '아침', + 'afternoon': '점심', + 'evening': '저녁' + }; + + // 원래 예보의 시간대와 현재 단계가 일치하는지 확인 + const expectedTimeZone = timeZoneMapping[currentStep]; + if (forecastData.timeZone !== expectedTimeZone) { + console.error(`❌ 시간대 불일치: 원래 예보(${forecastData.timeZone}) vs 현재 단계(${expectedTimeZone})`); + throw new Error(`잘못된 예보 ID입니다. ${expectedTimeZone} 시간대의 예보를 사용해주세요.`); + } + + // 예보 기록 생성 const recordData = { - forecastId: Number(forecastId), - childId: selectedChild.id, + forecastId: parseInt(forecastId), emotionTypeId: currentEmotion.emotion.id, - date: forecastDate, - timeZone: originalTimeZone || currentTimeZone, - memo: reason.trim() + memo: reason.trim(), + childId: selectedChild.id, + date: forecastData.date, + timeZone: forecastData.timeZone // 원래 예보의 시간대 사용 }; - console.log(`${currentStep} 예보 기록 생성:`, recordData); + console.log(`📝 ${currentStep} 예보 기록 생성:`, { + forecastId: recordData.forecastId, + emotionTypeId: recordData.emotionTypeId, + emotionName: currentEmotion.emotion.name, + date: recordData.date, + timeZone: recordData.timeZone, + childId: recordData.childId, + memoLength: recordData.memo.length + }); const response = await fetch(`${apiBaseUrl}/api/forecastRecords/forecastRecord`, { method: 'POST', @@ -121,10 +162,17 @@ function ReasonPageContent() { if (!response.ok) { const errorText = await response.text(); - throw new Error(`예보 기록 생성 실패: ${errorText}`); + const errorData = JSON.parse(errorText); + + // 이미 존재하는 예보 기록인 경우 오류 처리 + if (errorData.errorCode === 'FORECAST_RECORD_ALREADY_EXISTS') { + throw new Error('이미 해당 시간대의 예보 기록이 존재합니다.'); + } else { + throw new Error(`예보 기록 생성 실패: ${errorText}`); + } } - console.log(`${currentStep} 예보 기록 생성 완료`); + console.log(`✅ ${currentStep} 예보 기록 생성 완료 - ${new Date().toLocaleString('ko-KR')}`); // 다음 단계로 이동 const steps = ['morning', 'afternoon', 'evening']; @@ -132,31 +180,17 @@ function ReasonPageContent() { const nextStep = steps[currentIndex + 1]; if (nextStep) { - // localStorage에서 모든 forecastId 가져오기 - let allForecastIds = {}; - if (typeof window !== 'undefined') { - const savedForecastIds = localStorage.getItem('allForecastIds'); - if (savedForecastIds) { - allForecastIds = JSON.parse(savedForecastIds); - } - } - - // 다음 단계의 올바른 forecastId 사용 - const nextForecastId = allForecastIds[nextStep as keyof typeof allForecastIds] || forecastId; - const nextTimeZone = TIME_PERIODS[nextStep as keyof typeof TIME_PERIODS].label; - - console.log('다음 단계 이동:', { - currentStep, - nextStep, - currentForecastId: forecastId, - nextForecastId, - nextTimeZone - }); - - router.push(`/insert-after?step=${nextStep}&forecastId=${nextForecastId}&date=${searchParams.get('date')}&timeZone=${nextTimeZone}`); + router.push(`/insert-after?step=${nextStep}&forecastId=${forecastId}&date=${forecastData.date}&timeZone=${TIME_PERIODS[nextStep as keyof typeof TIME_PERIODS].label}`); } else { - // 모든 단계 완료 - setShowCompletionModal(true); + // 모든 단계 완료 - 결과 팝업 표시 + const savedEmotions = localStorage.getItem('forecastRecordEmotions'); + if (savedEmotions) { + const emotions = JSON.parse(savedEmotions); + setAllEmotions(emotions); + setShowResultPopup(true); + } else { + router.push('/home'); + } } } catch (error) { console.error('예보 기록 생성 실패:', error); @@ -186,7 +220,7 @@ function ReasonPageContent() {

감정 데이터를 찾을 수 없습니다.

+
+ +
+ +
+ +
+ + {/* 결과 팝업 */} + { + setShowResultPopup(false); + localStorage.removeItem('forecastRecordEmotions'); // 저장된 감정 데이터 정리 + }} + forecasts={allEmotions.map((emotion: any) => ({ + timeSlot: emotion.step, + emotion: emotion.emotion.name, + temperature: emotion.emotion.temp, + image: emotion.emotion.image, + category: emotion.category + }))} + /> + +
{getCurrentDate()} {TIME_PERIODS[currentStep].label}
+
+ {TIME_PERIODS[currentStep].text}{`\n`}느꼈나요?
-
-
- {searchParams.get('date') || new Date().toISOString().split('T')[0]} {TIME_PERIODS[currentStep].label} -
-
- {TIME_PERIODS[currentStep].text}{`\n`}느꼈나요? -
- - {error && ( -
- {error} -
- )} - -
-
-