feat: 생일 페이지 추가 (PC/Mobile)

- 멤버 생일 상세 페이지 구현
- 그라데이션 헤더 카드 (멤버 사진, 생일 정보)
- 생일카페 섹션 (준비 중 플레이스홀더)
- breadcrumb 네비게이션 (HAPPY OOO DAY 그라데이션 텍스트)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-22 10:32:11 +09:00
parent 76e0c2ee72
commit dd0e508117
2 changed files with 307 additions and 0 deletions

View file

@ -0,0 +1,155 @@
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { ChevronLeft } from 'lucide-react';
import { fetchApi } from '@/api/client';
//
const memberEnglishName = {
송하영: 'HAYOUNG',
박지원: 'JIWON',
이채영: 'CHAEYOUNG',
이나경: 'NAKYUNG',
백지헌: 'JIHEON',
장규리: 'GYURI',
이새롬: 'SAEROM',
노지선: 'JISUN',
이서연: 'SEOYEON',
};
/**
* Mobile 생일 페이지
*/
function MobileBirthday() {
const { memberName, year } = useParams();
// URL
const decodedMemberName = decodeURIComponent(memberName || '');
const englishName = memberEnglishName[decodedMemberName];
//
const {
data: member,
isLoading: memberLoading,
error,
} = useQuery({
queryKey: ['member', decodedMemberName],
queryFn: () => fetchApi(`/members/${encodeURIComponent(decodedMemberName)}`),
enabled: !!decodedMemberName,
});
if (!decodedMemberName || error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-6">
<div className="text-center">
<h1 className="text-xl font-bold text-gray-900 mb-2">멤버를 찾을 없습니다</h1>
<Link to="/schedule" className="text-primary">
일정으로 돌아가기
</Link>
</div>
</div>
);
}
if (memberLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
//
const birthDate = member?.birth_date ? new Date(member.birth_date) : null;
const birthdayThisYear = birthDate ? new Date(parseInt(year), birthDate.getMonth(), birthDate.getDate()) : null;
return (
<div className="min-h-screen bg-gradient-to-b from-pink-50 to-purple-50">
{/* 헤더 */}
<div className="sticky top-0 bg-white/80 backdrop-blur-sm border-b border-gray-100 z-10">
<div className="flex items-center h-14 px-4">
<Link to="/schedule" className="p-2 -ml-2 rounded-lg active:bg-gray-100">
<ChevronLeft size={24} />
</Link>
<div className="flex-1 text-center">
<span className="text-sm font-medium bg-gradient-to-r from-pink-500 via-purple-500 to-indigo-500 bg-clip-text text-transparent">
HAPPY {englishName} DAY
</span>
</div>
<div className="w-10" />
</div>
</div>
<div className="px-4 py-6">
{/* 헤더 카드 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="relative overflow-hidden bg-gradient-to-br from-pink-400 via-purple-400 to-indigo-400 rounded-2xl shadow-xl mb-6"
>
{/* 배경 장식 */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-8 -right-8 w-24 h-24 bg-white/10 rounded-full" />
<div className="absolute -bottom-8 -left-8 w-32 h-32 bg-white/10 rounded-full" />
<div className="absolute top-4 right-6 text-2xl"></div>
<div className="absolute bottom-4 left-10 text-xl">🎉</div>
<div className="absolute top-1/2 right-4 text-lg">🎈</div>
</div>
<div className="relative p-5">
<div className="flex items-center gap-4 mb-4">
{/* 멤버 사진 */}
{member?.image_url && (
<div className="w-20 h-20 rounded-full border-3 border-white/50 shadow-lg overflow-hidden bg-white flex-shrink-0">
<img src={member.image_url} alt={member.name} className="w-full h-full object-cover" />
</div>
)}
{/* 내용 */}
<div className="flex-1 text-white">
<div className="flex items-center gap-2 mb-1">
<span className="text-3xl">🎂</span>
<h1 className="font-bold text-xl tracking-wide">HAPPY {englishName} DAY</h1>
</div>
<p className="text-white/80 text-sm">
{year} {birthdayThisYear?.getMonth() + 1} {birthdayThisYear?.getDate()}
</p>
</div>
</div>
{/* 년도 뱃지 */}
<div className="bg-white/20 backdrop-blur-sm rounded-xl px-4 py-2 inline-flex items-center gap-2">
<span className="text-white/70 text-xs font-medium">YEAR</span>
<span className="text-white text-xl font-bold">{year}</span>
</div>
</div>
</motion.div>
{/* 생일카페 섹션 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-white rounded-2xl shadow-md p-5"
>
<h2 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
<span></span>
생일카페
</h2>
{/* 준비 중 메시지 */}
<div className="text-center py-8">
<div className="text-5xl mb-3">🎁</div>
<p className="text-gray-500 text-sm">
{year} {decodedMemberName} 생일카페 정보가 준비 중입니다
</p>
<p className="text-gray-400 text-xs mt-1">생일카페 정보가 등록되면 이곳에 표시됩니다</p>
</div>
</motion.div>
</div>
</div>
);
}
export default MobileBirthday;

View file

@ -0,0 +1,152 @@
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { ChevronRight } from 'lucide-react';
import { fetchApi } from '@/api/client';
//
const memberEnglishName = {
송하영: 'HAYOUNG',
박지원: 'JIWON',
이채영: 'CHAEYOUNG',
이나경: 'NAKYUNG',
백지헌: 'JIHEON',
장규리: 'GYURI',
이새롬: 'SAEROM',
노지선: 'JISUN',
이서연: 'SEOYEON',
};
/**
* PC 생일 페이지
*/
function PCBirthday() {
const { memberName, year } = useParams();
// URL
const decodedMemberName = decodeURIComponent(memberName || '');
const englishName = memberEnglishName[decodedMemberName];
//
const {
data: member,
isLoading: memberLoading,
error,
} = useQuery({
queryKey: ['member', decodedMemberName],
queryFn: () => fetchApi(`/members/${encodeURIComponent(decodedMemberName)}`),
enabled: !!decodedMemberName,
});
if (!decodedMemberName || error) {
return (
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-2">멤버를 찾을 없습니다</h1>
<Link to="/schedule" className="text-primary hover:underline">
일정으로 돌아가기
</Link>
</div>
</div>
);
}
if (memberLoading) {
return (
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
//
const birthDate = member?.birth_date ? new Date(member.birth_date) : null;
const birthdayThisYear = birthDate ? new Date(parseInt(year), birthDate.getMonth(), birthDate.getDate()) : null;
return (
<div className="min-h-[calc(100vh-64px)] bg-gradient-to-b from-pink-50 to-purple-50">
<div className="max-w-4xl mx-auto px-6 py-8">
{/* 네비게이션 */}
<div className="flex items-center gap-2 text-sm text-gray-500 mb-8">
<Link to="/schedule" className="hover:text-primary transition-colors">
일정
</Link>
<ChevronRight size={14} />
<span className="font-medium bg-gradient-to-r from-pink-500 via-purple-500 to-indigo-500 bg-clip-text text-transparent">
HAPPY {englishName} DAY
</span>
</div>
{/* 헤더 카드 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="relative overflow-hidden bg-gradient-to-r from-pink-400 via-purple-400 to-indigo-400 rounded-3xl shadow-xl mb-8"
>
{/* 배경 장식 */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white/10 rounded-full" />
<div className="absolute -bottom-10 -left-10 w-48 h-48 bg-white/10 rounded-full" />
<div className="absolute top-1/3 right-1/4 w-20 h-20 bg-white/5 rounded-full" />
<div className="absolute top-6 right-12 text-4xl"></div>
<div className="absolute bottom-6 left-16 text-3xl">🎉</div>
<div className="absolute top-1/2 right-8 text-2xl">🎈</div>
</div>
<div className="relative flex items-center p-8 gap-8">
{/* 멤버 사진 */}
{member?.image_url && (
<div className="flex-shrink-0">
<div className="w-32 h-32 rounded-full border-4 border-white/50 shadow-xl overflow-hidden bg-white">
<img src={member.image_url} alt={member.name} className="w-full h-full object-cover" />
</div>
</div>
)}
{/* 내용 */}
<div className="flex-1 text-white">
<div className="flex items-center gap-3 mb-2">
<span className="text-5xl">🎂</span>
<h1 className="font-bold text-4xl tracking-wide">HAPPY {englishName} DAY</h1>
</div>
<p className="text-white/80 text-lg mt-2">
{year} {birthdayThisYear?.getMonth() + 1} {birthdayThisYear?.getDate()}
</p>
</div>
{/* 년도 뱃지 */}
<div className="flex-shrink-0 bg-white/20 backdrop-blur-sm rounded-2xl px-6 py-4 text-center">
<div className="text-white/70 text-sm font-medium">YEAR</div>
<div className="text-white text-4xl font-bold">{year}</div>
</div>
</div>
</motion.div>
{/* 생일카페 섹션 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-white rounded-2xl shadow-lg p-8"
>
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center gap-2">
<span></span>
생일카페
</h2>
{/* 준비 중 메시지 */}
<div className="text-center py-12">
<div className="text-6xl mb-4">🎁</div>
<p className="text-gray-500 text-lg">
{year} {decodedMemberName} 생일카페 정보가 준비 중입니다
</p>
<p className="text-gray-400 text-sm mt-2">생일카페 정보가 등록되면 이곳에 표시됩니다</p>
</div>
</motion.div>
</div>
</div>
);
}
export default PCBirthday;