refactor: 생일 페이지 라우트를 /schedule/:id 형식으로 변경

- /birthday/:memberName/:year → /schedule/birthday-{year}-{nameEn}
- ScheduleDetail에서 특수 ID(birthday, debut, anniversary) 감지
- Birthday 컴포넌트가 props로 year, nameEn 받도록 변경
- 멤버 API가 영문명으로도 조회 가능하도록 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-25 13:15:04 +09:00
parent e1ee0b47a0
commit f97c925fba
9 changed files with 100 additions and 41 deletions

View file

@ -62,8 +62,9 @@ export async function invalidateMemberCache(redis) {
/** /**
* 이름으로 멤버 조회 (별명 포함) * 이름으로 멤버 조회 (별명 포함)
* 한글명(name) 또는 영문명(name_en) 모두 검색 가능
* @param {object} db - 데이터베이스 연결 * @param {object} db - 데이터베이스 연결
* @param {string} name - 멤버 이름 * @param {string} name - 멤버 이름 (한글 또는 영문)
* @returns {object|null} 멤버 정보 또는 null * @returns {object|null} 멤버 정보 또는 null
*/ */
export async function getMemberByName(db, name) { export async function getMemberByName(db, name) {
@ -75,8 +76,8 @@ export async function getMemberByName(db, name) {
i.thumb_url as image_thumb i.thumb_url as image_thumb
FROM members m FROM members m
LEFT JOIN images i ON m.image_id = i.id LEFT JOIN images i ON m.image_id = i.id
WHERE m.name = ? WHERE m.name = ? OR LOWER(m.name_en) = LOWER(?)
`, [name]); `, [name, name]);
if (members.length === 0) { if (members.length === 0) {
return null; return null;

View file

@ -1,4 +1,4 @@
import { useParams, Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
@ -6,25 +6,23 @@ import { fetchApi } from '@/api';
/** /**
* Mobile 생일 페이지 * Mobile 생일 페이지
* @param {object} props
* @param {string} props.year - 연도
* @param {string} props.nameEn - 멤버 영문 이름 (소문자)
*/ */
function MobileBirthday() { function MobileBirthday({ year, nameEn }) {
const { memberName, year } = useParams(); // ( )
// URL
const decodedMemberName = decodeURIComponent(memberName || '');
//
const { const {
data: member, data: member,
isLoading: memberLoading, isLoading: memberLoading,
error, error,
} = useQuery({ } = useQuery({
queryKey: ['member', decodedMemberName], queryKey: ['member', nameEn],
queryFn: () => fetchApi(`/members/${encodeURIComponent(decodedMemberName)}`), queryFn: () => fetchApi(`/members/${encodeURIComponent(nameEn)}`),
enabled: !!decodedMemberName, enabled: !!nameEn,
}); });
if (!decodedMemberName || error) { if (!nameEn || error) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-6"> <div className="min-h-screen flex items-center justify-center bg-gray-50 px-6">
<div className="text-center"> <div className="text-center">
@ -128,7 +126,7 @@ function MobileBirthday() {
<div className="text-center py-8"> <div className="text-center py-8">
<div className="text-5xl mb-3">🎁</div> <div className="text-5xl mb-3">🎁</div>
<p className="text-gray-500 text-sm"> <p className="text-gray-500 text-sm">
{year} {decodedMemberName} 생일카페 정보가 준비 중입니다 {year} {member?.name} 생일카페 정보가 준비 중입니다
</p> </p>
<p className="text-gray-400 text-xs mt-1">생일카페 정보가 등록되면 이곳에 표시됩니다</p> <p className="text-gray-400 text-xs mt-1">생일카페 정보가 등록되면 이곳에 표시됩니다</p>
</div> </div>

View file

@ -777,11 +777,7 @@ function MobileSchedule() {
key={schedule.id} key={schedule.id}
schedule={schedule} schedule={schedule}
delay={index * 0.05} delay={index * 0.05}
onClick={() => { onClick={() => navigate(`/schedule/${schedule.id}`)}
const scheduleYear = new Date(schedule.date).getFullYear();
const memberName = schedule.member_names;
navigate(`/birthday/${encodeURIComponent(memberName)}/${scheduleYear}`);
}}
/> />
); );
} }

View file

@ -6,6 +6,34 @@ import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight } from 'lucide-rea
import Linkify from 'react-linkify'; import Linkify from 'react-linkify';
import { getSchedule } from '@/api'; import { getSchedule } from '@/api';
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils'; import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
import Birthday from './Birthday';
/**
* 특수 일정 ID 파싱
* @param {string} id - 일정 ID
* @returns {object|null} { type, year, nameEn } 또는 null
*/
function parseSpecialId(id) {
// birthday-{year}-{nameEn}
const birthdayMatch = id.match(/^birthday-(\d{4})-(.+)$/);
if (birthdayMatch) {
return { type: 'birthday', year: birthdayMatch[1], nameEn: birthdayMatch[2] };
}
// debut-{year}
const debutMatch = id.match(/^debut-(\d{4})$/);
if (debutMatch) {
return { type: 'debut', year: debutMatch[1] };
}
// anniversary-{year}
const anniversaryMatch = id.match(/^anniversary-(\d{4})$/);
if (anniversaryMatch) {
return { type: 'anniversary', year: anniversaryMatch[1] };
}
return null;
}
/** /**
* 전체화면 자동 가로 회전 (숏츠가 아닐 때만) * 전체화면 자동 가로 회전 (숏츠가 아닐 때만)
@ -393,6 +421,12 @@ function MobileDefaultSection({ schedule }) {
function MobileScheduleDetail() { function MobileScheduleDetail() {
const { id } = useParams(); const { id } = useParams();
// ID
const specialId = parseSpecialId(id);
if (specialId?.type === 'birthday') {
return <Birthday year={specialId.year} nameEn={specialId.nameEn} />;
}
// //
useEffect(() => { useEffect(() => {
document.documentElement.classList.add('mobile-layout'); document.documentElement.classList.add('mobile-layout');

View file

@ -1,4 +1,4 @@
import { useParams, Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
@ -6,25 +6,23 @@ import { fetchApi } from '@/api';
/** /**
* PC 생일 페이지 * PC 생일 페이지
* @param {object} props
* @param {string} props.year - 연도
* @param {string} props.nameEn - 멤버 영문 이름 (소문자)
*/ */
function PCBirthday() { function PCBirthday({ year, nameEn }) {
const { memberName, year } = useParams(); // ( )
// URL
const decodedMemberName = decodeURIComponent(memberName || '');
//
const { const {
data: member, data: member,
isLoading: memberLoading, isLoading: memberLoading,
error, error,
} = useQuery({ } = useQuery({
queryKey: ['member', decodedMemberName], queryKey: ['member', nameEn],
queryFn: () => fetchApi(`/members/${encodeURIComponent(decodedMemberName)}`), queryFn: () => fetchApi(`/members/${encodeURIComponent(nameEn)}`),
enabled: !!decodedMemberName, enabled: !!nameEn,
}); });
if (!decodedMemberName || error) { if (!nameEn || error) {
return ( return (
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center"> <div className="min-h-[calc(100vh-64px)] flex items-center justify-center">
<div className="text-center"> <div className="text-center">
@ -125,7 +123,7 @@ function PCBirthday() {
<div className="text-center py-12"> <div className="text-center py-12">
<div className="text-6xl mb-4">🎁</div> <div className="text-6xl mb-4">🎁</div>
<p className="text-gray-500 text-lg"> <p className="text-gray-500 text-lg">
{year} {decodedMemberName} 생일카페 정보가 준비 중입니다 {year} {member?.name} 생일카페 정보가 준비 중입니다
</p> </p>
<p className="text-gray-400 text-sm mt-2">생일카페 정보가 등록되면 이곳에 표시됩니다</p> <p className="text-gray-400 text-sm mt-2">생일카페 정보가 등록되면 이곳에 표시됩니다</p>
</div> </div>

View file

@ -262,15 +262,17 @@ function PCSchedule() {
// //
const handleScheduleClick = (schedule) => { const handleScheduleClick = (schedule) => {
if (schedule.is_birthday || String(schedule.id).startsWith('birthday-')) { // , ,
const scheduleYear = new Date(schedule.date).getFullYear(); if (schedule.is_birthday || schedule.is_debut || schedule.is_anniversary) {
navigate(`/birthday/${encodeURIComponent(schedule.member_names)}/${scheduleYear}`); navigate(`/schedule/${schedule.id}`);
return; return;
} }
// (2), X(3), (6)
if ([2, 3, 6].includes(schedule.category_id)) { if ([2, 3, 6].includes(schedule.category_id)) {
navigate(`/schedule/${schedule.id}`); navigate(`/schedule/${schedule.id}`);
return; return;
} }
// URL
if (!schedule.description && schedule.source?.url) { if (!schedule.description && schedule.source?.url) {
window.open(schedule.source.url, '_blank'); window.open(schedule.source.url, '_blank');
} else { } else {

View file

@ -6,6 +6,34 @@ import { getSchedule } from '@/api';
// //
import { YoutubeSection, XSection, DefaultSection, decodeHtmlEntities } from './sections'; import { YoutubeSection, XSection, DefaultSection, decodeHtmlEntities } from './sections';
import Birthday from './Birthday';
/**
* 특수 일정 ID 파싱
* @param {string} id - 일정 ID
* @returns {object|null} { type, year, nameEn } 또는 null
*/
function parseSpecialId(id) {
// birthday-{year}-{nameEn}
const birthdayMatch = id.match(/^birthday-(\d{4})-(.+)$/);
if (birthdayMatch) {
return { type: 'birthday', year: birthdayMatch[1], nameEn: birthdayMatch[2] };
}
// debut-{year}
const debutMatch = id.match(/^debut-(\d{4})$/);
if (debutMatch) {
return { type: 'debut', year: debutMatch[1] };
}
// anniversary-{year}
const anniversaryMatch = id.match(/^anniversary-(\d{4})$/);
if (anniversaryMatch) {
return { type: 'anniversary', year: anniversaryMatch[1] };
}
return null;
}
/** /**
* PC 일정 상세 페이지 * PC 일정 상세 페이지
@ -13,6 +41,12 @@ import { YoutubeSection, XSection, DefaultSection, decodeHtmlEntities } from './
function PCScheduleDetail() { function PCScheduleDetail() {
const { id } = useParams(); const { id } = useParams();
// ID
const specialId = parseSpecialId(id);
if (specialId?.type === 'birthday') {
return <Birthday year={specialId.year} nameEn={specialId.nameEn} />;
}
const { const {
data: schedule, data: schedule,
isLoading, isLoading,

View file

@ -9,7 +9,6 @@ import Members from '@/pages/mobile/members/Members';
import MembersPreview from '@/pages/mobile/members/MembersPreview'; import MembersPreview from '@/pages/mobile/members/MembersPreview';
import Schedule from '@/pages/mobile/schedule/Schedule'; import Schedule from '@/pages/mobile/schedule/Schedule';
import ScheduleDetail from '@/pages/mobile/schedule/ScheduleDetail'; import ScheduleDetail from '@/pages/mobile/schedule/ScheduleDetail';
import Birthday from '@/pages/mobile/schedule/Birthday';
import Album from '@/pages/mobile/album/Album'; import Album from '@/pages/mobile/album/Album';
import AlbumDetail from '@/pages/mobile/album/AlbumDetail'; import AlbumDetail from '@/pages/mobile/album/AlbumDetail';
import TrackDetail from '@/pages/mobile/album/TrackDetail'; import TrackDetail from '@/pages/mobile/album/TrackDetail';
@ -55,7 +54,6 @@ export default function MobileRoutes() {
} }
/> />
<Route path="/schedule/:id" element={<ScheduleDetail />} /> <Route path="/schedule/:id" element={<ScheduleDetail />} />
<Route path="/birthday/:memberName/:year" element={<Birthday />} />
<Route <Route
path="/album" path="/album"
element={ element={

View file

@ -8,7 +8,6 @@ import Home from '@/pages/pc/public/home/Home';
import Members from '@/pages/pc/public/members/Members'; import Members from '@/pages/pc/public/members/Members';
import Schedule from '@/pages/pc/public/schedule/Schedule'; import Schedule from '@/pages/pc/public/schedule/Schedule';
import ScheduleDetail from '@/pages/pc/public/schedule/ScheduleDetail'; import ScheduleDetail from '@/pages/pc/public/schedule/ScheduleDetail';
import Birthday from '@/pages/pc/public/schedule/Birthday';
import Album from '@/pages/pc/public/album/Album'; import Album from '@/pages/pc/public/album/Album';
import AlbumDetail from '@/pages/pc/public/album/AlbumDetail'; import AlbumDetail from '@/pages/pc/public/album/AlbumDetail';
import TrackDetail from '@/pages/pc/public/album/TrackDetail'; import TrackDetail from '@/pages/pc/public/album/TrackDetail';
@ -30,7 +29,6 @@ export default function PublicRoutes() {
<Route path="/members" element={<Members />} /> <Route path="/members" element={<Members />} />
<Route path="/schedule" element={<Schedule />} /> <Route path="/schedule" element={<Schedule />} />
<Route path="/schedule/:id" element={<ScheduleDetail />} /> <Route path="/schedule/:id" element={<ScheduleDetail />} />
<Route path="/birthday/:memberName/:year" element={<Birthday />} />
<Route path="/album" element={<Album />} /> <Route path="/album" element={<Album />} />
<Route path="/album/:name" element={<AlbumDetail />} /> <Route path="/album/:name" element={<AlbumDetail />} />
<Route path="/album/:name/track/:trackTitle" element={<TrackDetail />} /> <Route path="/album/:name/track/:trackTitle" element={<TrackDetail />} />