feat: 데뷔/주년 기념일 카드 및 축하 다이얼로그 추가

- DebutCard 컴포넌트 추가 (PC/모바일)
- DebutCelebrationDialog 축하 다이얼로그 추가
- Fromis9Logo SVG 컴포넌트 추가
- 기념일 카테고리 추가 (ID: 9)
- 데뷔일(2018.01.24) 및 주년 일정 자동 생성
- 폭죽 효과 추가 (fireDebutConfetti)
- 카테고리 정보 DB에서 동적 조회하도록 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-24 15:04:29 +09:00
parent a7bc2e9800
commit 5b9d93b37f
14 changed files with 550 additions and 21 deletions

View file

@ -3,6 +3,14 @@ export const CATEGORY_IDS = {
YOUTUBE: 2,
X: 3,
BIRTHDAY: 8,
DEBUT: 9,
};
// 데뷔일 (fromis_9: 2018년 1월 24일)
export const DEBUT_DATE = {
year: 2018,
month: 1,
day: 24,
};
// 필수 환경변수 검증

View file

@ -2,7 +2,7 @@
* 스케줄 서비스
* 스케줄 관련 비즈니스 로직
*/
import config, { CATEGORY_IDS } from '../config/index.js';
import config, { CATEGORY_IDS, DEBUT_DATE } from '../config/index.js';
import { getOrSet, cacheKeys, TTL } from '../utils/cache.js';
// ==================== 공통 포맷팅 함수 ====================
@ -308,6 +308,16 @@ export async function getMonthlySchedules(db, year, month) {
// 일정 포맷팅
const schedules = formatSchedules(rawSchedules, memberMap);
// 특수 카테고리 조회 (생일, 기념일)
const [specialCategories] = await db.query(
'SELECT id, name, color FROM schedule_categories WHERE id IN (?, ?)',
[CATEGORY_IDS.BIRTHDAY, CATEGORY_IDS.DEBUT]
);
const categoryMap = {};
for (const cat of specialCategories) {
categoryMap[cat.id] = { id: cat.id, name: cat.name, color: cat.color };
}
// 생일 조회 및 추가
const [birthdays] = await db.query(`
SELECT m.id, m.name, m.name_en, m.birth_date,
@ -328,11 +338,7 @@ export async function getMonthlySchedules(db, year, month) {
title: `HAPPY ${member.name_en} DAY`,
date: birthdayDate.toISOString().split('T')[0],
time: null,
category: {
id: CATEGORY_IDS.BIRTHDAY,
name: '생일',
color: '#f472b6',
},
category: categoryMap[CATEGORY_IDS.BIRTHDAY],
source: null,
members: [member.name],
is_birthday: true,
@ -340,6 +346,43 @@ export async function getMonthlySchedules(db, year, month) {
});
}
// 데뷔/주년 추가 (1월인 경우)
if (month === DEBUT_DATE.month) {
const debutYear = DEBUT_DATE.year;
const anniversaryYear = year - debutYear;
if (year >= debutYear) {
const debutDate = new Date(year, DEBUT_DATE.month - 1, DEBUT_DATE.day);
if (year === debutYear) {
// 데뷔 당일
schedules.push({
id: 'debut',
title: '프로미스나인 데뷔',
date: debutDate.toISOString().split('T')[0],
time: null,
category: categoryMap[CATEGORY_IDS.DEBUT],
source: null,
members: ['프로미스나인'],
is_debut: true,
});
} else {
// N주년
schedules.push({
id: `anniversary-${anniversaryYear}`,
title: `프로미스나인 데뷔 ${anniversaryYear}주년`,
date: debutDate.toISOString().split('T')[0],
time: null,
category: categoryMap[CATEGORY_IDS.DEBUT],
source: null,
members: ['프로미스나인'],
is_anniversary: true,
anniversary_year: anniversaryYear,
});
}
}
}
// 날짜순 정렬
schedules.sort((a, b) => a.date.localeCompare(b.date));

View file

@ -86,7 +86,7 @@ fromis_9/
│ │ │ ├── index.js
│ │ │ ├── cn.js # className 병합
│ │ │ ├── color.js # 색상 상수/유틸
│ │ │ ├── confetti.js # 생일 축하 효과
│ │ │ ├── confetti.js # 축하 효과 (생일, 데뷔/주년)
│ │ │ ├── date.js # 날짜 포맷
│ │ │ ├── format.js # 문자열 포맷
│ │ │ ├── schedule.js # 일정 관련 유틸
@ -107,7 +107,9 @@ fromis_9/
│ │ │ │ ├── MobileLightbox.jsx
│ │ │ │ ├── LightboxIndicator.jsx
│ │ │ │ ├── AnimatedNumber.jsx
│ │ │ │ └── ScrollToTop.jsx
│ │ │ │ ├── ScrollToTop.jsx
│ │ │ │ ├── Fromis9Logo.jsx # 프로미스나인 로고 SVG
│ │ │ │ └── DebutCelebrationDialog.jsx # 데뷔/주년 축하 다이얼로그
│ │ │ │
│ │ │ ├── pc/
│ │ │ │ ├── public/ # PC 공개 컴포넌트
@ -119,6 +121,7 @@ fromis_9/
│ │ │ │ │ ├── Calendar.jsx
│ │ │ │ │ ├── ScheduleCard.jsx
│ │ │ │ │ ├── BirthdayCard.jsx
│ │ │ │ │ ├── DebutCard.jsx # 데뷔/주년 카드
│ │ │ │ │ └── CategoryFilter.jsx
│ │ │ │ │
│ │ │ │ └── admin/ # PC 관리자 컴포넌트
@ -159,7 +162,8 @@ fromis_9/
│ │ │ ├── ScheduleCard.jsx
│ │ │ ├── ScheduleListCard.jsx
│ │ │ ├── ScheduleSearchCard.jsx
│ │ │ └── BirthdayCard.jsx
│ │ │ ├── BirthdayCard.jsx
│ │ │ └── DebutCard.jsx # 데뷔/주년 카드
│ │ │
│ │ ├── pages/
│ │ │ ├── pc/

View file

@ -0,0 +1,107 @@
import { memo, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X } from 'lucide-react';
import Fromis9Logo from './Fromis9Logo';
/**
* 데뷔/주년 축하 다이얼로그
* @param {boolean} isOpen - 다이얼로그 표시 여부
* @param {function} onClose - 닫기 핸들러
* @param {boolean} isDebut - 데뷔일 여부 (false면 주년)
* @param {number} anniversaryYear - 주년 (isDebut이 false일 )
*/
const DebutCelebrationDialog = memo(function DebutCelebrationDialog({
isOpen,
onClose,
isDebut = false,
anniversaryYear = 0,
}) {
// ESC
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, onClose]);
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-[#7a99c8] via-[#98b0d8] to-[#b8c8e8] 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-4 left-8 text-yellow-200 text-2xl animate-pulse"></div>
<div className="absolute top-12 right-16 text-yellow-200/80 text-xl animate-pulse delay-100"></div>
<div className="absolute bottom-20 left-12 text-yellow-200/60 text-3xl animate-pulse delay-200"></div>
<div className="absolute bottom-12 right-8 text-yellow-200 text-xl animate-pulse delay-150"></div>
<div className="absolute top-1/3 left-4 text-white/40 text-lg animate-pulse delay-300"></div>
<div className="absolute top-1/2 right-4 text-white/30 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">
{isDebut ? (
<Fromis9Logo size={56} fill="white" className="drop-shadow-lg" />
) : (
<div className="text-center" style={{ textShadow: '0 2px 4px rgba(0,0,0,0.2)' }}>
<div className="text-white font-black text-4xl leading-none">{anniversaryYear}</div>
<div className="text-white/80 text-xs font-bold tracking-wider">YEARS</div>
</div>
)}
</div>
{/* 텍스트 */}
<h2
className="text-white font-bold text-2xl mb-2"
style={{ textShadow: '0 2px 4px rgba(0,0,0,0.2)' }}
>
{isDebut ? '프로미스나인 데뷔' : `프로미스나인 데뷔 ${anniversaryYear}주년`}
</h2>
<p
className="text-white/80 text-base"
style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}
>
2018. 01. 24
</p>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
});
export default DebutCelebrationDialog;

View file

@ -0,0 +1,22 @@
/**
* fromis_9 로고 컴포넌트
* @param {string} className - 추가 클래스
* @param {string} fill - 채우기 색상 (기본: currentColor)
* @param {number} size - 크기 (기본: 24)
*/
function Fromis9Logo({ className = '', fill = 'currentColor', size = 24 }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1000 1000"
width={size}
height={size}
className={className}
fill={fill}
>
<path d="m330.8,82.15c48.81-14.33,102.1-13.34,150.34,2.79,49.01,16.79,93.21,49.01,122.21,92.14,36.07-5.57,73.13-9.03,109.3-1.96,80.03,13.24,152.43,65.03,191.02,136.34-29.07,16.59-59.76,30.16-89.46,45.59-23.99-32.72-54.75-61.02-91.31-79.07-24.55-12.04-52.56-14.87-79.5-11.88,9.39,49.94,5.71,102.69-14.63,149.58-16.99,40.02-45.23,74.62-79.47,101.17,75.29.56,150.57-.23,225.83.4.63,35.84.5,71.67.17,107.51-98.02.1-196,.23-294.02-.07-1.36,44.63-2.19,90.55-18.61,132.72-14.6,37.33-32.38,73.99-58.3,104.88-19.24,22.86-37.56,46.95-61.12,65.63-19.74-29.73-35.6-61.98-53.85-92.71,11.51-8.89,24.52-15.89,34.74-26.45,37.03-36.8,54.62-88.76,60.65-139.66,3.09-34.47,2.26-69.18,4.31-103.72-52.89-3.25-104.98-22.73-145.53-57.14-54.12-44.63-87.53-113.41-88.49-183.62-1.26-45.36,10.25-90.95,33.21-130.1,30.96-53.72,83.12-94.7,142.51-112.38m-33.08,116.33c-34.31,29.46-55.45,74.13-55.01,119.48-.5,33.65,10.62,67.16,30.43,94.27,27.27,37.66,71.87,62.88,118.49,64.9,1.66-34.51.23-69.61,9.79-103.19,16.62-65.93,59.49-124.3,116.43-161.19l3.55.83c-20.97-22.83-47.91-40.45-78.01-48.38-50.07-14.3-106.71-1.16-145.66,33.28m192.78,252.57c47.68-32.65,75.85-91.25,69.71-148.95-45.56,34.44-73.46,91.65-69.71,148.95Z" />
</svg>
);
}
export default Fromis9Logo;

View file

@ -7,3 +7,5 @@ export { default as Lightbox } from './Lightbox';
export { default as MobileLightbox } from './MobileLightbox';
export { default as LightboxIndicator } from './LightboxIndicator';
export { default as AnimatedNumber } from './AnimatedNumber';
export { default as Fromis9Logo } from './Fromis9Logo';
export { default as DebutCelebrationDialog } from './DebutCelebrationDialog';

View file

@ -0,0 +1,99 @@
import { memo } from 'react';
import { motion } from 'framer-motion';
import { Clover } from 'lucide-react';
import { dayjs } from '@/utils';
import { Fromis9Logo } from '@/components/common';
/**
* Mobile용 데뷔/주년 카드 컴포넌트
* @param {Object} schedule - 일정 데이터
* @param {boolean} showYear - 년도 표시 여부
* @param {number} delay - 애니메이션 딜레이 ()
*/
const DebutCard = memo(function DebutCard({ schedule, showYear = false, delay = 0 }) {
const scheduleDate = dayjs(schedule.date);
const formatted = {
year: scheduleDate.year(),
month: scheduleDate.month() + 1,
day: scheduleDate.date(),
};
const isDebut = schedule.is_debut;
const anniversaryYear = schedule.anniversary_year;
const CardContent = (
<div className="relative overflow-hidden bg-gradient-to-br from-[#7a99c8] via-[#98b0d8] to-[#b8c8e8] rounded-xl shadow-md">
{/* 배경 별 장식 */}
<div className="absolute inset-0 overflow-hidden">
{/* 반짝이는 별들 */}
<div className="absolute top-2 right-4 text-white/60 text-xs animate-pulse"></div>
<div className="absolute top-4 right-12 text-white/40 text-[10px] animate-pulse delay-100"></div>
<div className="absolute bottom-3 right-6 text-white/50 text-sm animate-pulse delay-200"></div>
<div className="absolute top-1/2 right-1/4 text-white/30 text-xs animate-pulse delay-300"></div>
<div className="absolute bottom-4 left-4 text-white/40 text-[10px] animate-pulse delay-150"></div>
{/* 원형 장식 */}
<div className="absolute -top-6 -left-6 w-20 h-20 bg-white/10 rounded-full" />
<div className="absolute -bottom-8 -right-8 w-24 h-24 bg-white/10 rounded-full" />
</div>
<div className="relative flex items-center p-4 gap-3">
{/* 아이콘 영역 */}
<div className="flex-shrink-0">
<div className="w-14 h-14 rounded-full bg-white/30 backdrop-blur-sm flex items-center justify-center shadow-inner">
{isDebut ? (
<div className="text-center" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}>
<div className="text-white font-black text-[11px] tracking-wider">DEBUT</div>
</div>
) : (
<div className="text-center" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}>
<div className="text-white font-black text-xl leading-none">{anniversaryYear}</div>
<div className="text-white/80 text-[8px] font-bold">YEARS</div>
</div>
)}
</div>
</div>
{/* 내용 */}
<div className="flex-1 text-white min-w-0">
<h3
className="font-bold text-base tracking-wide truncate flex items-center gap-1.5"
style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}
>
{isDebut ? (
<Fromis9Logo size={18} fill="white" className="flex-shrink-0 drop-shadow" />
) : (
<Clover size={18} className="flex-shrink-0 drop-shadow" strokeWidth={2.5} />
)}
{schedule.title}
</h3>
</div>
{/* 날짜 뱃지 */}
{showYear && (
<div className="flex-shrink-0 bg-white/25 backdrop-blur-sm rounded-lg px-3 py-1.5 text-center">
<div className="text-white/80 text-[10px] font-medium">{formatted.year}</div>
<div className="text-white/80 text-[10px] font-medium">{formatted.month}</div>
<div className="text-white text-xl font-bold">{formatted.day}</div>
</div>
)}
</div>
</div>
);
// delay motion
if (delay > 0) {
return (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay, type: 'spring', stiffness: 300, damping: 30 }}
>
{CardContent}
</motion.div>
);
}
return CardContent;
});
export default DebutCard;

View file

@ -3,3 +3,4 @@ export { default as ScheduleCard } from './ScheduleCard';
export { default as ScheduleListCard } from './ScheduleListCard';
export { default as ScheduleSearchCard } from './ScheduleSearchCard';
export { default as BirthdayCard } from './BirthdayCard';
export { default as DebutCard } from './DebutCard';

View file

@ -0,0 +1,82 @@
import { memo } from 'react';
import { Clover } from 'lucide-react';
import { dayjs } from '@/utils';
import { Fromis9Logo } from '@/components/common';
/**
* PC용 데뷔/주년 카드 컴포넌트
*/
const DebutCard = memo(function DebutCard({ schedule, showYear = false }) {
const scheduleDate = dayjs(schedule.date);
const formatted = {
year: scheduleDate.year(),
month: scheduleDate.month() + 1,
day: scheduleDate.date(),
};
const isDebut = schedule.is_debut;
const anniversaryYear = schedule.anniversary_year;
return (
<div className="relative overflow-hidden bg-gradient-to-br from-[#7a99c8] via-[#98b0d8] to-[#b8c8e8] rounded-2xl shadow-lg">
{/* 배경 별 장식 */}
<div className="absolute inset-0 overflow-hidden">
{/* 반짝이는 별들 */}
<div className="absolute top-3 right-6 text-white/60 text-base animate-pulse"></div>
<div className="absolute top-6 right-16 text-white/40 text-sm animate-pulse delay-100"></div>
<div className="absolute bottom-4 right-10 text-white/50 text-lg animate-pulse delay-200"></div>
<div className="absolute top-1/2 right-1/3 text-white/30 text-sm animate-pulse delay-300"></div>
<div className="absolute bottom-6 left-6 text-white/40 text-sm animate-pulse delay-150"></div>
<div className="absolute top-4 left-1/4 text-white/30 text-xs animate-pulse delay-250"></div>
{/* 원형 장식 */}
<div className="absolute -top-8 -left-8 w-28 h-28 bg-white/10 rounded-full" />
<div className="absolute -bottom-10 -right-10 w-36 h-36 bg-white/10 rounded-full" />
<div className="absolute top-1/2 left-1/2 w-16 h-16 bg-white/5 rounded-full" />
</div>
<div className="relative flex items-center p-4 gap-4">
{/* 아이콘 영역 */}
<div className="flex-shrink-0">
<div className="w-20 h-20 rounded-full bg-white/30 backdrop-blur-sm flex items-center justify-center shadow-inner border-2 border-white/20">
{isDebut ? (
<div className="text-center" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}>
<div className="text-white font-black text-base tracking-wider">DEBUT</div>
</div>
) : (
<div className="text-center" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}>
<div className="text-white font-black text-3xl leading-none">{anniversaryYear}</div>
<div className="text-white/80 text-[10px] font-bold tracking-wider">YEARS</div>
</div>
)}
</div>
</div>
{/* 내용 */}
<div className="flex-1 text-white flex flex-col justify-center">
<h3
className="font-bold text-2xl tracking-wide flex items-center gap-2"
style={{ textShadow: '0 1px 2px rgba(0,0,0,0.2)' }}
>
{isDebut ? (
<Fromis9Logo size={28} fill="white" className="flex-shrink-0 drop-shadow" />
) : (
<Clover size={28} className="flex-shrink-0 drop-shadow" strokeWidth={2.5} />
)}
{schedule.title}
</h3>
</div>
{/* 날짜 뱃지 */}
<div className="flex-shrink-0 bg-white/25 backdrop-blur-sm rounded-xl px-4 py-2 text-center">
{showYear && (
<div className="text-white/80 text-xs font-medium">{formatted.year}</div>
)}
<div className="text-white/80 text-xs font-medium">{formatted.month}</div>
<div className="text-white text-2xl font-bold">{formatted.day}</div>
</div>
</div>
</div>
);
});
export default DebutCard;

View file

@ -1,4 +1,5 @@
export { default as Calendar } from './Calendar';
export { default as ScheduleCard } from './ScheduleCard';
export { default as BirthdayCard } from './BirthdayCard';
export { default as DebutCard } from './DebutCard';
export { default as CategoryFilter } from './CategoryFilter';

View file

@ -15,8 +15,10 @@ import {
ScheduleListCard as MobileScheduleListCard,
ScheduleSearchCard as MobileScheduleSearchCard,
BirthdayCard as MobileBirthdayCard,
DebutCard as MobileDebutCard,
} from '@/components/mobile';
import { fireBirthdayConfetti } from '@/utils';
import { DebutCelebrationDialog } from '@/components/common';
import { fireBirthdayConfetti, fireDebutConfetti } from '@/utils';
/**
* 모바일 일정 페이지
@ -51,6 +53,8 @@ function MobileSchedule() {
const [suggestions, setSuggestions] = useState([]);
const [lastSearchTerm, setLastSearchTerm] = useState('');
const [showSuggestionsScreen, setShowSuggestionsScreen] = useState(false);
const [showDebutDialog, setShowDebutDialog] = useState(false);
const [debutDialogInfo, setDebutDialogInfo] = useState({ isDebut: false, anniversaryYear: 0 });
// /
const enterSearchMode = () => {
@ -198,6 +202,35 @@ function MobileSchedule() {
}
}, [schedules, loading]);
// /
useEffect(() => {
if (loading || schedules.length === 0) return;
const today = getTodayKST();
const confettiKey = `debut-confetti-${today}`;
if (localStorage.getItem(confettiKey)) return;
const debutSchedule = schedules.find((s) => {
if (!s.is_debut && !s.is_anniversary) return false;
const scheduleDate = s.date ? s.date.split('T')[0] : '';
return scheduleDate === today;
});
if (debutSchedule) {
const timer = setTimeout(() => {
fireDebutConfetti();
setDebutDialogInfo({
isDebut: debutSchedule.is_debut,
anniversaryYear: debutSchedule.anniversary_year || 0,
});
setShowDebutDialog(true);
localStorage.setItem(confettiKey, 'true');
}, 500);
return () => clearTimeout(timer);
}
}, [schedules, loading]);
// 2017 1
const canGoPrevMonth = !(selectedDate.getFullYear() === MIN_YEAR && selectedDate.getMonth() === 0);
@ -308,8 +341,12 @@ function MobileSchedule() {
.sort((a, b) => {
const aIsBirthday = a.is_birthday || String(a.id).startsWith('birthday-');
const bIsBirthday = b.is_birthday || String(b.id).startsWith('birthday-');
if (aIsBirthday && !bIsBirthday) return -1;
if (!aIsBirthday && bIsBirthday) return 1;
const aIsDebut = a.is_debut || a.is_anniversary;
const bIsDebut = b.is_debut || b.is_anniversary;
const aIsSpecial = aIsBirthday || aIsDebut;
const bIsSpecial = bIsBirthday || bIsDebut;
if (aIsSpecial && !bIsSpecial) return -1;
if (!aIsSpecial && bIsSpecial) return 1;
return 0;
});
}, [schedules, selectedDate]);
@ -743,6 +780,7 @@ function MobileSchedule() {
<div className="space-y-3">
{selectedDateSchedules.map((schedule, index) => {
const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-');
const isDebut = schedule.is_debut || schedule.is_anniversary;
if (isBirthday) {
return (
@ -759,6 +797,16 @@ function MobileSchedule() {
);
}
if (isDebut) {
return (
<MobileDebutCard
key={schedule.id}
schedule={schedule}
delay={index * 0.05}
/>
);
}
return (
<MobileScheduleListCard
key={schedule.id}
@ -772,6 +820,14 @@ function MobileSchedule() {
)}
</div>
</div>
{/* 데뷔/주년 축하 다이얼로그 */}
<DebutCelebrationDialog
isOpen={showDebutDialog}
onClose={() => setShowDebutDialog(false)}
isDebut={debutDialogInfo.isDebut}
anniversaryYear={debutDialogInfo.anniversaryYear}
/>
</>
);
}

View file

@ -11,8 +11,10 @@ import {
CategoryFilter,
ScheduleCard,
BirthdayCard,
DebutCard,
} from '@/components/pc/public';
import { fireBirthdayConfetti } from '@/utils';
import { DebutCelebrationDialog } from '@/components/common';
import { fireBirthdayConfetti, fireDebutConfetti } from '@/utils';
import { getSchedules, searchSchedules } from '@/api';
import { useScheduleStore } from '@/stores';
import { getTodayKST } from '@/utils';
@ -53,6 +55,8 @@ function PCSchedule() {
const [suggestions, setSuggestions] = useState([]);
const [originalSearchQuery, setOriginalSearchQuery] = useState('');
const [showCategoryTooltip, setShowCategoryTooltip] = useState(false);
const [showDebutDialog, setShowDebutDialog] = useState(false);
const [debutDialogInfo, setDebutDialogInfo] = useState({ isDebut: false, anniversaryYear: 0 });
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
@ -135,6 +139,27 @@ function PCSchedule() {
}
}, [schedules, loading]);
// /
useEffect(() => {
if (loading || schedules.length === 0) return;
const today = getTodayKST();
const confettiKey = `debut-confetti-${today}`;
if (localStorage.getItem(confettiKey)) return;
const debutSchedule = schedules.find((s) => (s.is_debut || s.is_anniversary) && s.date === today);
if (debutSchedule) {
const timer = setTimeout(() => {
fireDebutConfetti();
setDebutDialogInfo({
isDebut: debutSchedule.is_debut,
anniversaryYear: debutSchedule.anniversary_year || 0,
});
setShowDebutDialog(true);
localStorage.setItem(confettiKey, 'true');
}, 500);
return () => clearTimeout(timer);
}
}, [schedules, loading]);
//
useEffect(() => {
const handleClickOutside = (event) => {
@ -213,20 +238,24 @@ function PCSchedule() {
const currentYearMonth = `${year}-${String(month + 1).padStart(2, '0')}`;
const filteredSchedules = useMemo(() => {
const sortWithBirthdayFirst = (list) => {
const sortWithSpecialFirst = (list) => {
return [...list].sort((a, b) => {
const aIsBirthday = a.is_birthday || String(a.id).startsWith('birthday-');
const bIsBirthday = b.is_birthday || String(b.id).startsWith('birthday-');
if (aIsBirthday && !bIsBirthday) return -1;
if (!aIsBirthday && bIsBirthday) return 1;
const aIsDebut = a.is_debut || a.is_anniversary;
const bIsDebut = b.is_debut || b.is_anniversary;
const aIsSpecial = aIsBirthday || aIsDebut;
const bIsSpecial = bIsBirthday || bIsDebut;
if (aIsSpecial && !bIsSpecial) return -1;
if (!aIsSpecial && bIsSpecial) return 1;
return 0;
});
};
if (isSearchMode) {
if (!searchTerm) return [];
if (selectedCategories.length === 0) return sortWithBirthdayFirst(searchResults);
return sortWithBirthdayFirst(searchResults.filter((s) => selectedCategories.includes(s.category_id)));
if (selectedCategories.length === 0) return sortWithSpecialFirst(searchResults);
return sortWithSpecialFirst(searchResults.filter((s) => selectedCategories.includes(s.category_id)));
}
const filtered = schedules
@ -238,8 +267,12 @@ function PCSchedule() {
.sort((a, b) => {
const aIsBirthday = a.is_birthday || String(a.id).startsWith('birthday-');
const bIsBirthday = b.is_birthday || String(b.id).startsWith('birthday-');
if (aIsBirthday && !bIsBirthday) return -1;
if (!aIsBirthday && bIsBirthday) return 1;
const aIsDebut = a.is_debut || a.is_anniversary;
const bIsDebut = b.is_debut || b.is_anniversary;
const aIsSpecial = aIsBirthday || aIsDebut;
const bIsSpecial = bIsBirthday || bIsDebut;
if (aIsSpecial && !bIsSpecial) return -1;
if (!aIsSpecial && bIsSpecial) return 1;
if (a.date !== b.date) return a.date.localeCompare(b.date);
return (a.time || '00:00:00').localeCompare(b.time || '00:00:00');
});
@ -535,6 +568,8 @@ function PCSchedule() {
<div className={virtualItem.index < filteredSchedules.length - 1 ? 'pb-4' : ''}>
{schedule.is_birthday ? (
<BirthdayCard schedule={schedule} showYear onClick={() => handleScheduleClick(schedule)} />
) : schedule.is_debut || schedule.is_anniversary ? (
<DebutCard schedule={schedule} showYear />
) : (
<ScheduleCard schedule={schedule} showYear onClick={() => handleScheduleClick(schedule)} />
)}
@ -564,6 +599,8 @@ function PCSchedule() {
>
{schedule.is_birthday ? (
<BirthdayCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
) : schedule.is_debut || schedule.is_anniversary ? (
<DebutCard schedule={schedule} />
) : (
<ScheduleCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
)}
@ -587,6 +624,14 @@ function PCSchedule() {
</div>
</div>
</div>
{/* 데뷔/주년 축하 다이얼로그 */}
<DebutCelebrationDialog
isOpen={showDebutDialog}
onClose={() => setShowDebutDialog(false)}
isDebut={debutDialogInfo.isDebut}
anniversaryYear={debutDialogInfo.anniversaryYear}
/>
</div>
);
}

View file

@ -1,5 +1,64 @@
import confetti from 'canvas-confetti';
/**
* 데뷔/주년 폭죽 애니메이션
* PC/Mobile 공용
*/
export function fireDebutConfetti() {
const duration = 3000;
const animationEnd = Date.now() + duration;
const colors = ['#7a99c8', '#98b0d8', '#b8c8e8', '#ffffff', '#ffd700', '#c0c0c0'];
const randomInRange = (min, max) => Math.random() * (max - min) + min;
const interval = setInterval(() => {
const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
clearInterval(interval);
return;
}
const particleCount = 50 * (timeLeft / duration);
// 왼쪽에서 발사
confetti({
particleCount: Math.floor(particleCount),
startVelocity: 30,
spread: 60,
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
colors,
shapes: ['circle', 'square'],
gravity: 1.2,
scalar: randomInRange(0.8, 1.2),
drift: randomInRange(-0.5, 0.5),
});
// 오른쪽에서 발사
confetti({
particleCount: Math.floor(particleCount),
startVelocity: 30,
spread: 60,
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
colors,
shapes: ['circle', 'square'],
gravity: 1.2,
scalar: randomInRange(0.8, 1.2),
drift: randomInRange(-0.5, 0.5),
});
}, 250);
// 초기 대형 폭죽
confetti({
particleCount: 100,
spread: 100,
origin: { x: 0.5, y: 0.6 },
colors,
shapes: ['circle', 'square'],
startVelocity: 45,
});
}
/**
* 생일 폭죽 애니메이션
* PC/Mobile 공용

View file

@ -48,4 +48,4 @@ export {
} from './schedule';
// 애니메이션 관련
export { fireBirthdayConfetti } from './confetti';
export { fireBirthdayConfetti, fireDebutConfetti } from './confetti';