diff --git a/backend/src/config/index.js b/backend/src/config/index.js
index a6a4b28..b185fea 100644
--- a/backend/src/config/index.js
+++ b/backend/src/config/index.js
@@ -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,
};
// 필수 환경변수 검증
diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js
index 4b70a72..415b798 100644
--- a/backend/src/services/schedule.js
+++ b/backend/src/services/schedule.js
@@ -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));
diff --git a/docs/architecture.md b/docs/architecture.md
index f920afb..6f77936 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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/
diff --git a/frontend/src/components/common/DebutCelebrationDialog.jsx b/frontend/src/components/common/DebutCelebrationDialog.jsx
new file mode 100644
index 0000000..3c267f5
--- /dev/null
+++ b/frontend/src/components/common/DebutCelebrationDialog.jsx
@@ -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 (
+
+ {isOpen && (
+
+ {/* 배경 오버레이 */}
+
+
+ {/* 다이얼로그 */}
+ 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"
+ >
+ {/* 닫기 버튼 */}
+
+
+ {/* 배경 장식 */}
+
+
✦
+
✦
+
✦
+
✦
+
✦
+
✦
+
+
+
+
+ {/* 컨텐츠 */}
+
+ {/* 로고/숫자 */}
+
+ {isDebut ? (
+
+ ) : (
+
+
{anniversaryYear}
+
YEARS
+
+ )}
+
+
+ {/* 텍스트 */}
+
+ {isDebut ? '프로미스나인 데뷔' : `프로미스나인 데뷔 ${anniversaryYear}주년`}
+
+
+ 2018. 01. 24
+
+
+
+
+ )}
+
+ );
+});
+
+export default DebutCelebrationDialog;
diff --git a/frontend/src/components/common/Fromis9Logo.jsx b/frontend/src/components/common/Fromis9Logo.jsx
new file mode 100644
index 0000000..71fe1b0
--- /dev/null
+++ b/frontend/src/components/common/Fromis9Logo.jsx
@@ -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 (
+
+ );
+}
+
+export default Fromis9Logo;
diff --git a/frontend/src/components/common/index.js b/frontend/src/components/common/index.js
index e3db5c6..2344b66 100644
--- a/frontend/src/components/common/index.js
+++ b/frontend/src/components/common/index.js
@@ -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';
diff --git a/frontend/src/components/mobile/schedule/DebutCard.jsx b/frontend/src/components/mobile/schedule/DebutCard.jsx
new file mode 100644
index 0000000..a99421e
--- /dev/null
+++ b/frontend/src/components/mobile/schedule/DebutCard.jsx
@@ -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 = (
+
+ {/* 배경 별 장식 */}
+
+ {/* 반짝이는 별들 */}
+
✦
+
✦
+
✦
+
✦
+
✦
+ {/* 원형 장식 */}
+
+
+
+
+
+ {/* 아이콘 영역 */}
+
+
+ {isDebut ? (
+
+ ) : (
+
+
{anniversaryYear}
+
YEARS
+
+ )}
+
+
+
+ {/* 내용 */}
+
+
+ {isDebut ? (
+
+ ) : (
+
+ )}
+ {schedule.title}
+
+
+
+ {/* 날짜 뱃지 */}
+ {showYear && (
+
+
{formatted.year}
+
{formatted.month}월
+
{formatted.day}
+
+ )}
+
+
+ );
+
+ // delay가 있으면 motion 사용
+ if (delay > 0) {
+ return (
+
+ {CardContent}
+
+ );
+ }
+
+ return CardContent;
+});
+
+export default DebutCard;
diff --git a/frontend/src/components/mobile/schedule/index.js b/frontend/src/components/mobile/schedule/index.js
index 348feab..6cff5ca 100644
--- a/frontend/src/components/mobile/schedule/index.js
+++ b/frontend/src/components/mobile/schedule/index.js
@@ -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';
diff --git a/frontend/src/components/pc/public/schedule/DebutCard.jsx b/frontend/src/components/pc/public/schedule/DebutCard.jsx
new file mode 100644
index 0000000..1d18019
--- /dev/null
+++ b/frontend/src/components/pc/public/schedule/DebutCard.jsx
@@ -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 (
+
+ {/* 배경 별 장식 */}
+
+ {/* 반짝이는 별들 */}
+
✦
+
✦
+
✦
+
✦
+
✦
+
✦
+ {/* 원형 장식 */}
+
+
+
+
+
+
+ {/* 아이콘 영역 */}
+
+
+ {isDebut ? (
+
+ ) : (
+
+
{anniversaryYear}
+
YEARS
+
+ )}
+
+
+
+ {/* 내용 */}
+
+
+ {isDebut ? (
+
+ ) : (
+
+ )}
+ {schedule.title}
+
+
+
+ {/* 날짜 뱃지 */}
+
+ {showYear && (
+
{formatted.year}
+ )}
+
{formatted.month}월
+
{formatted.day}
+
+
+
+ );
+});
+
+export default DebutCard;
diff --git a/frontend/src/components/pc/public/schedule/index.js b/frontend/src/components/pc/public/schedule/index.js
index 196d75a..ccb3c55 100644
--- a/frontend/src/components/pc/public/schedule/index.js
+++ b/frontend/src/components/pc/public/schedule/index.js
@@ -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';
diff --git a/frontend/src/pages/mobile/schedule/Schedule.jsx b/frontend/src/pages/mobile/schedule/Schedule.jsx
index 245253a..b8b7695 100644
--- a/frontend/src/pages/mobile/schedule/Schedule.jsx
+++ b/frontend/src/pages/mobile/schedule/Schedule.jsx
@@ -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() {
{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 (
+
+ );
+ }
+
return (
+
+ {/* 데뷔/주년 축하 다이얼로그 */}
+ setShowDebutDialog(false)}
+ isDebut={debutDialogInfo.isDebut}
+ anniversaryYear={debutDialogInfo.anniversaryYear}
+ />
>
);
}
diff --git a/frontend/src/pages/pc/public/schedule/Schedule.jsx b/frontend/src/pages/pc/public/schedule/Schedule.jsx
index 5ca2e0d..72859bc 100644
--- a/frontend/src/pages/pc/public/schedule/Schedule.jsx
+++ b/frontend/src/pages/pc/public/schedule/Schedule.jsx
@@ -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() {
{schedule.is_birthday ? (
handleScheduleClick(schedule)} />
+ ) : schedule.is_debut || schedule.is_anniversary ? (
+
) : (
handleScheduleClick(schedule)} />
)}
@@ -564,6 +599,8 @@ function PCSchedule() {
>
{schedule.is_birthday ? (
handleScheduleClick(schedule)} />
+ ) : schedule.is_debut || schedule.is_anniversary ? (
+
) : (
handleScheduleClick(schedule)} />
)}
@@ -587,6 +624,14 @@ function PCSchedule() {
+
+ {/* 데뷔/주년 축하 다이얼로그 */}
+ setShowDebutDialog(false)}
+ isDebut={debutDialogInfo.isDebut}
+ anniversaryYear={debutDialogInfo.anniversaryYear}
+ />
);
}
diff --git a/frontend/src/utils/confetti.js b/frontend/src/utils/confetti.js
index 8d04b83..7be889b 100644
--- a/frontend/src/utils/confetti.js
+++ b/frontend/src/utils/confetti.js
@@ -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 공용
diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js
index 42bb58d..e256463 100644
--- a/frontend/src/utils/index.js
+++ b/frontend/src/utils/index.js
@@ -48,4 +48,4 @@ export {
} from './schedule';
// 애니메이션 관련
-export { fireBirthdayConfetti } from './confetti';
+export { fireBirthdayConfetti, fireDebutConfetti } from './confetti';