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 Fromis9Logo } from './Fromis9Logo';
|
||||
export { default as DebutCelebrationDialog } from './DebutCelebrationDialog';
|
||||
export { default as BirthdayCelebrationDialog } from './BirthdayCelebrationDialog';
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue