feat: 생일 축하 다이얼로그 추가 (PC/모바일)

생일 당일 접속 시 폭죽과 함께 멤버 사진, HAPPY OOO DAY 제목,
날짜를 보여주는 축하 다이얼로그 표시. 데뷔 다이얼로그와 동일하게
localStorage로 하루 1회만 표시.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-03-20 13:39:55 +09:00
parent 8effebf681
commit 9d18449d3a
4 changed files with 153 additions and 2 deletions

View 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;

View file

@ -9,3 +9,4 @@ export { default as LightboxIndicator } from './LightboxIndicator';
export { default as AnimatedNumber } from './AnimatedNumber';
export { default as Fromis9Logo } from './Fromis9Logo';
export { default as DebutCelebrationDialog } from './DebutCelebrationDialog';
export { default as BirthdayCelebrationDialog } from './BirthdayCelebrationDialog';

View file

@ -17,7 +17,7 @@ import {
BirthdayCard as MobileBirthdayCard,
DebutCard as MobileDebutCard,
} from '@/components/mobile';
import { DebutCelebrationDialog } from '@/components/common';
import { DebutCelebrationDialog, BirthdayCelebrationDialog } from '@/components/common';
import { fireBirthdayConfetti, fireDebutConfetti } from '@/utils';
/**
@ -55,6 +55,8 @@ function MobileSchedule() {
const [showSuggestionsScreen, setShowSuggestionsScreen] = useState(false);
const [showDebutDialog, setShowDebutDialog] = useState(false);
const [debutDialogInfo, setDebutDialogInfo] = useState({ isDebut: false, anniversaryYear: 0 });
const [showBirthdayDialog, setShowBirthdayDialog] = useState(false);
const [birthdayInfo, setBirthdayInfo] = useState({ title: '', memberImage: '', date: '' });
// /
const enterSearchMode = () => {
@ -194,8 +196,19 @@ function MobileSchedule() {
});
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(() => {
fireBirthdayConfetti();
setBirthdayInfo({
title: birthdaySchedule?.title || '',
memberImage: birthdaySchedule?.member_image || '',
date: birthdaySchedule?.date || '',
});
setShowBirthdayDialog(true);
localStorage.setItem(confettiKey, 'true');
}, 500);
return () => clearTimeout(timer);
@ -813,6 +826,14 @@ function MobileSchedule() {
isDebut={debutDialogInfo.isDebut}
anniversaryYear={debutDialogInfo.anniversaryYear}
/>
{/* 생일 축하 다이얼로그 */}
<BirthdayCelebrationDialog
isOpen={showBirthdayDialog}
onClose={() => setShowBirthdayDialog(false)}
title={birthdayInfo.title}
memberImage={birthdayInfo.memberImage}
date={birthdayInfo.date}
/>
</>
);
}

View file

@ -13,7 +13,7 @@ import {
BirthdayCard,
DebutCard,
} from '@/components/pc/public';
import { DebutCelebrationDialog } from '@/components/common';
import { DebutCelebrationDialog, BirthdayCelebrationDialog } from '@/components/common';
import { fireBirthdayConfetti, fireDebutConfetti } from '@/utils';
import { getSchedules, searchSchedules } from '@/api';
import { useScheduleStore } from '@/stores';
@ -57,6 +57,8 @@ function PCSchedule() {
const [showCategoryTooltip, setShowCategoryTooltip] = useState(false);
const [showDebutDialog, setShowDebutDialog] = useState(false);
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 month = currentDate.getMonth();
@ -131,8 +133,15 @@ function PCSchedule() {
if (localStorage.getItem(confettiKey)) return;
const hasBirthdayToday = schedules.some((s) => s.is_birthday && s.date === today);
if (hasBirthdayToday) {
const birthdaySchedule = schedules.find((s) => s.is_birthday && s.date === today);
const timer = setTimeout(() => {
fireBirthdayConfetti();
setBirthdayInfo({
title: birthdaySchedule.title || '',
memberImage: birthdaySchedule.member_image || '',
date: birthdaySchedule.date,
});
setShowBirthdayDialog(true);
localStorage.setItem(confettiKey, 'true');
}, 500);
return () => clearTimeout(timer);
@ -618,6 +627,13 @@ function PCSchedule() {
isDebut={debutDialogInfo.isDebut}
anniversaryYear={debutDialogInfo.anniversaryYear}
/>
<BirthdayCelebrationDialog
isOpen={showBirthdayDialog}
onClose={() => setShowBirthdayDialog(false)}
title={birthdayInfo.title}
memberImage={birthdayInfo.memberImage}
date={birthdayInfo.date}
/>
</div>
);
}