feat: 생일 축하 다이얼로그 추가 (PC/모바일)
생일 당일 접속 시 폭죽과 함께 멤버 사진, HAPPY OOO DAY 제목, 날짜를 보여주는 축하 다이얼로그 표시. 데뷔 다이얼로그와 동일하게 localStorage로 하루 1회만 표시. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8effebf681
commit
9d18449d3a
4 changed files with 153 additions and 2 deletions
113
frontend/src/components/common/BirthdayCelebrationDialog.jsx
Normal file
113
frontend/src/components/common/BirthdayCelebrationDialog.jsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { memo, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생일 축하 다이얼로그
|
||||||
|
* @param {boolean} isOpen - 다이얼로그 표시 여부
|
||||||
|
* @param {function} onClose - 닫기 핸들러
|
||||||
|
* @param {string} title - 제목 (예: HAPPY Jiwon DAY)
|
||||||
|
* @param {string} memberImage - 멤버 이미지 URL
|
||||||
|
* @param {string} date - 생일 날짜 (YYYY-MM-DD)
|
||||||
|
*/
|
||||||
|
const BirthdayCelebrationDialog = memo(function BirthdayCelebrationDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title = '',
|
||||||
|
memberImage = '',
|
||||||
|
date = '',
|
||||||
|
}) {
|
||||||
|
// ESC 키로 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
const dateObj = date ? new Date(date) : new Date();
|
||||||
|
const month = dateObj.getMonth() + 1;
|
||||||
|
const day = dateObj.getDate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{/* 배경 오버레이 */}
|
||||||
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
||||||
|
|
||||||
|
{/* 다이얼로그 */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8, opacity: 0, y: 20 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.8, opacity: 0, y: 20 }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="relative w-full max-w-md overflow-hidden rounded-3xl bg-gradient-to-br from-pink-400 via-purple-400 to-indigo-400 shadow-2xl"
|
||||||
|
>
|
||||||
|
{/* 닫기 버튼 */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-4 right-4 z-10 p-2 rounded-full bg-white/20 hover:bg-white/30 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} className="text-white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 배경 장식 */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute top-6 left-10 text-2xl animate-pulse">🎉</div>
|
||||||
|
<div className="absolute top-10 right-16 text-xl animate-pulse delay-100">🎂</div>
|
||||||
|
<div className="absolute bottom-20 left-8 text-2xl animate-pulse delay-200">🎁</div>
|
||||||
|
<div className="absolute bottom-10 right-10 text-xl animate-pulse delay-150">🎉</div>
|
||||||
|
<div className="absolute top-1/3 left-4 text-white/30 text-lg animate-pulse delay-300">✦</div>
|
||||||
|
<div className="absolute top-1/2 right-4 text-white/20 text-sm animate-pulse delay-250">✦</div>
|
||||||
|
<div className="absolute -top-16 -left-16 w-48 h-48 bg-white/10 rounded-full" />
|
||||||
|
<div className="absolute -bottom-20 -right-20 w-56 h-56 bg-white/10 rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컨텐츠 */}
|
||||||
|
<div className="relative flex flex-col items-center py-12 px-8 text-center">
|
||||||
|
{/* 멤버 사진 */}
|
||||||
|
<div className="w-28 h-28 rounded-full bg-white/30 backdrop-blur-sm flex items-center justify-center shadow-lg border-4 border-white/30 mb-6 overflow-hidden">
|
||||||
|
{memberImage ? (
|
||||||
|
<img
|
||||||
|
src={memberImage}
|
||||||
|
alt={title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-5xl">🎂</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 텍스트 */}
|
||||||
|
<h2
|
||||||
|
className="text-white font-bold text-2xl mb-2"
|
||||||
|
style={{ textShadow: '0 2px 4px rgba(0,0,0,0.2)' }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="text-white/80 text-base"
|
||||||
|
style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}
|
||||||
|
>
|
||||||
|
🎂 {month}월 {day}일
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default BirthdayCelebrationDialog;
|
||||||
|
|
@ -9,3 +9,4 @@ export { default as LightboxIndicator } from './LightboxIndicator';
|
||||||
export { default as AnimatedNumber } from './AnimatedNumber';
|
export { default as AnimatedNumber } from './AnimatedNumber';
|
||||||
export { default as Fromis9Logo } from './Fromis9Logo';
|
export { default as Fromis9Logo } from './Fromis9Logo';
|
||||||
export { default as DebutCelebrationDialog } from './DebutCelebrationDialog';
|
export { default as DebutCelebrationDialog } from './DebutCelebrationDialog';
|
||||||
|
export { default as BirthdayCelebrationDialog } from './BirthdayCelebrationDialog';
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
BirthdayCard as MobileBirthdayCard,
|
BirthdayCard as MobileBirthdayCard,
|
||||||
DebutCard as MobileDebutCard,
|
DebutCard as MobileDebutCard,
|
||||||
} from '@/components/mobile';
|
} from '@/components/mobile';
|
||||||
import { DebutCelebrationDialog } from '@/components/common';
|
import { DebutCelebrationDialog, BirthdayCelebrationDialog } from '@/components/common';
|
||||||
import { fireBirthdayConfetti, fireDebutConfetti } from '@/utils';
|
import { fireBirthdayConfetti, fireDebutConfetti } from '@/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -55,6 +55,8 @@ function MobileSchedule() {
|
||||||
const [showSuggestionsScreen, setShowSuggestionsScreen] = useState(false);
|
const [showSuggestionsScreen, setShowSuggestionsScreen] = useState(false);
|
||||||
const [showDebutDialog, setShowDebutDialog] = useState(false);
|
const [showDebutDialog, setShowDebutDialog] = useState(false);
|
||||||
const [debutDialogInfo, setDebutDialogInfo] = useState({ isDebut: false, anniversaryYear: 0 });
|
const [debutDialogInfo, setDebutDialogInfo] = useState({ isDebut: false, anniversaryYear: 0 });
|
||||||
|
const [showBirthdayDialog, setShowBirthdayDialog] = useState(false);
|
||||||
|
const [birthdayInfo, setBirthdayInfo] = useState({ title: '', memberImage: '', date: '' });
|
||||||
|
|
||||||
// 검색 모드 진입/종료
|
// 검색 모드 진입/종료
|
||||||
const enterSearchMode = () => {
|
const enterSearchMode = () => {
|
||||||
|
|
@ -194,8 +196,19 @@ function MobileSchedule() {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasBirthdayToday) {
|
if (hasBirthdayToday) {
|
||||||
|
const birthdaySchedule = schedules.find((s) => {
|
||||||
|
if (!s.is_birthday) return false;
|
||||||
|
const scheduleDate = s.date ? s.date.split('T')[0] : '';
|
||||||
|
return scheduleDate === today;
|
||||||
|
});
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
fireBirthdayConfetti();
|
fireBirthdayConfetti();
|
||||||
|
setBirthdayInfo({
|
||||||
|
title: birthdaySchedule?.title || '',
|
||||||
|
memberImage: birthdaySchedule?.member_image || '',
|
||||||
|
date: birthdaySchedule?.date || '',
|
||||||
|
});
|
||||||
|
setShowBirthdayDialog(true);
|
||||||
localStorage.setItem(confettiKey, 'true');
|
localStorage.setItem(confettiKey, 'true');
|
||||||
}, 500);
|
}, 500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
|
|
@ -813,6 +826,14 @@ function MobileSchedule() {
|
||||||
isDebut={debutDialogInfo.isDebut}
|
isDebut={debutDialogInfo.isDebut}
|
||||||
anniversaryYear={debutDialogInfo.anniversaryYear}
|
anniversaryYear={debutDialogInfo.anniversaryYear}
|
||||||
/>
|
/>
|
||||||
|
{/* 생일 축하 다이얼로그 */}
|
||||||
|
<BirthdayCelebrationDialog
|
||||||
|
isOpen={showBirthdayDialog}
|
||||||
|
onClose={() => setShowBirthdayDialog(false)}
|
||||||
|
title={birthdayInfo.title}
|
||||||
|
memberImage={birthdayInfo.memberImage}
|
||||||
|
date={birthdayInfo.date}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
BirthdayCard,
|
BirthdayCard,
|
||||||
DebutCard,
|
DebutCard,
|
||||||
} from '@/components/pc/public';
|
} from '@/components/pc/public';
|
||||||
import { DebutCelebrationDialog } from '@/components/common';
|
import { DebutCelebrationDialog, BirthdayCelebrationDialog } from '@/components/common';
|
||||||
import { fireBirthdayConfetti, fireDebutConfetti } from '@/utils';
|
import { fireBirthdayConfetti, fireDebutConfetti } from '@/utils';
|
||||||
import { getSchedules, searchSchedules } from '@/api';
|
import { getSchedules, searchSchedules } from '@/api';
|
||||||
import { useScheduleStore } from '@/stores';
|
import { useScheduleStore } from '@/stores';
|
||||||
|
|
@ -57,6 +57,8 @@ function PCSchedule() {
|
||||||
const [showCategoryTooltip, setShowCategoryTooltip] = useState(false);
|
const [showCategoryTooltip, setShowCategoryTooltip] = useState(false);
|
||||||
const [showDebutDialog, setShowDebutDialog] = useState(false);
|
const [showDebutDialog, setShowDebutDialog] = useState(false);
|
||||||
const [debutDialogInfo, setDebutDialogInfo] = useState({ isDebut: false, anniversaryYear: 0 });
|
const [debutDialogInfo, setDebutDialogInfo] = useState({ isDebut: false, anniversaryYear: 0 });
|
||||||
|
const [showBirthdayDialog, setShowBirthdayDialog] = useState(false);
|
||||||
|
const [birthdayInfo, setBirthdayInfo] = useState({ title: '', memberImage: '', date: '' });
|
||||||
|
|
||||||
const year = currentDate.getFullYear();
|
const year = currentDate.getFullYear();
|
||||||
const month = currentDate.getMonth();
|
const month = currentDate.getMonth();
|
||||||
|
|
@ -131,8 +133,15 @@ function PCSchedule() {
|
||||||
if (localStorage.getItem(confettiKey)) return;
|
if (localStorage.getItem(confettiKey)) return;
|
||||||
const hasBirthdayToday = schedules.some((s) => s.is_birthday && s.date === today);
|
const hasBirthdayToday = schedules.some((s) => s.is_birthday && s.date === today);
|
||||||
if (hasBirthdayToday) {
|
if (hasBirthdayToday) {
|
||||||
|
const birthdaySchedule = schedules.find((s) => s.is_birthday && s.date === today);
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
fireBirthdayConfetti();
|
fireBirthdayConfetti();
|
||||||
|
setBirthdayInfo({
|
||||||
|
title: birthdaySchedule.title || '',
|
||||||
|
memberImage: birthdaySchedule.member_image || '',
|
||||||
|
date: birthdaySchedule.date,
|
||||||
|
});
|
||||||
|
setShowBirthdayDialog(true);
|
||||||
localStorage.setItem(confettiKey, 'true');
|
localStorage.setItem(confettiKey, 'true');
|
||||||
}, 500);
|
}, 500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
|
|
@ -618,6 +627,13 @@ function PCSchedule() {
|
||||||
isDebut={debutDialogInfo.isDebut}
|
isDebut={debutDialogInfo.isDebut}
|
||||||
anniversaryYear={debutDialogInfo.anniversaryYear}
|
anniversaryYear={debutDialogInfo.anniversaryYear}
|
||||||
/>
|
/>
|
||||||
|
<BirthdayCelebrationDialog
|
||||||
|
isOpen={showBirthdayDialog}
|
||||||
|
onClose={() => setShowBirthdayDialog(false)}
|
||||||
|
title={birthdayInfo.title}
|
||||||
|
memberImage={birthdayInfo.memberImage}
|
||||||
|
date={birthdayInfo.date}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue