Skip to content

Commit 76b2731

Browse files
authored
Merge pull request #49 from boostcampwm2025/frontend-19
[FE] 메인페이지 진행중(혹은 예정)인 공연 정보 표시
2 parents aae9ed3 + 7fbc7d5 commit 76b2731

File tree

12 files changed

+694
-2
lines changed

12 files changed

+694
-2
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Ticket } from "lucide-react";
2+
3+
export default function Header() {
4+
return (
5+
<header className="bg-white border-b border-gray-200 sticky top-0 z-40">
6+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
7+
<div className="flex items-center gap-3">
8+
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center">
9+
<Ticket className="w-6 h-6 text-white" />
10+
</div>
11+
<div>
12+
<h1 className="text-xl">티켓팅 시뮬레이터</h1>
13+
<p className="text-sm text-gray-500">실전처럼 연습하세요</p>
14+
</div>
15+
</div>
16+
</div>
17+
</header>
18+
);
19+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Clock } from "lucide-react";
2+
3+
interface CountdownTimerProps {
4+
timeLeft: {
5+
days: number;
6+
hours: number;
7+
minutes: number;
8+
seconds: number;
9+
};
10+
}
11+
12+
export default function CountdownTimer({ timeLeft }: CountdownTimerProps) {
13+
const timeUnits = [
14+
{ label: "일", value: timeLeft.days },
15+
{ label: "시간", value: timeLeft.hours },
16+
{ label: "분", value: timeLeft.minutes },
17+
{ label: "초", value: timeLeft.seconds },
18+
];
19+
20+
return (
21+
<div className="text-center mb-6">
22+
<div className="flex items-center justify-center gap-2 mb-4">
23+
<Clock className="w-5 h-5" />
24+
<span className="text-sm text-white/80">티켓팅 시작까지</span>
25+
</div>
26+
<div className="grid grid-cols-4 gap-3">
27+
{timeUnits.map((item, index) => (
28+
<div
29+
key={index}
30+
className="bg-white/20 backdrop-blur-sm rounded-xl p-3"
31+
>
32+
{/* csr ssr시간의 차이가 1초정도 남 => 하이드레이션 미스매칭문제 발생 => 1초의 차이때문에 화면 깜빡임이 존재할 이유는 없다 판단해서 에러 경고 무시를 넣음 */}
33+
<div className="text-2xl md:text-3xl" suppressHydrationWarning>
34+
{String(item.value).padStart(2, "0")}
35+
</div>
36+
<div className="text-xs text-white/70 mt-1">{item.label}</div>
37+
</div>
38+
))}
39+
</div>
40+
</div>
41+
);
42+
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { useState } from "react";
2+
import { ChevronLeft, ChevronRight, ChevronUp } from "lucide-react";
3+
4+
interface DateSelectorProps {
5+
selectedDate: number | null;
6+
onDateSelect: (day: number) => void;
7+
performanceDate?: string;
8+
}
9+
10+
const weekDays = ["일", "월", "화", "수", "목", "금", "토"];
11+
12+
const getDaysInMonth = (date: Date) => {
13+
const year = date.getFullYear();
14+
const month = date.getMonth();
15+
const firstDay = new Date(year, month, 1).getDay();
16+
const daysInMonth = new Date(year, month + 1, 0).getDate();
17+
return { firstDay, daysInMonth };
18+
};
19+
20+
export default function DateSelector({
21+
selectedDate,
22+
onDateSelect,
23+
performanceDate,
24+
}: DateSelectorProps) {
25+
// performanceDate가 있으면 그 월로, 없으면 2026년 1월
26+
const getInitialMonth = () => {
27+
if (performanceDate) {
28+
const date = new Date(performanceDate);
29+
return new Date(date.getFullYear(), date.getMonth());
30+
}
31+
return new Date(2026, 0);
32+
};
33+
34+
const [currentMonth, setCurrentMonth] = useState(getInitialMonth());
35+
const [isOpen, setIsOpen] = useState(true);
36+
37+
const { firstDay, daysInMonth } = getDaysInMonth(currentMonth);
38+
39+
// performanceDate에서 날짜 추출
40+
const performanceDay = performanceDate
41+
? new Date(performanceDate).getDate()
42+
: null;
43+
const performanceMonth = performanceDate
44+
? new Date(performanceDate).getMonth()
45+
: null;
46+
const performanceYear = performanceDate
47+
? new Date(performanceDate).getFullYear()
48+
: null;
49+
50+
const handlePrevMonth = () => {
51+
setCurrentMonth(
52+
new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1)
53+
);
54+
};
55+
56+
const handleNextMonth = () => {
57+
setCurrentMonth(
58+
new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1)
59+
);
60+
};
61+
62+
const handleDateClick = (day: number) => {
63+
// 현재 달력의 년/월이 performance 년/월과 같고, 날짜도 일치할 때만 선택 가능
64+
const currentYear = currentMonth.getFullYear();
65+
const currentMonthIndex = currentMonth.getMonth();
66+
67+
// performanceDate가 없으면 선택 불가
68+
if (
69+
performanceYear === null ||
70+
performanceMonth === null ||
71+
performanceDay === null
72+
) {
73+
return;
74+
}
75+
76+
// 년, 월, 일이 모두 일치해야만 선택 가능
77+
if (
78+
performanceYear === currentYear &&
79+
performanceMonth === currentMonthIndex &&
80+
performanceDay === day
81+
) {
82+
onDateSelect(day);
83+
}
84+
};
85+
86+
return (
87+
<div className="mb-4 bg-white rounded-xl p-4 text-gray-900">
88+
<div
89+
className="flex items-center justify-between mb-3 cursor-pointer"
90+
onClick={() => setIsOpen(!isOpen)}
91+
>
92+
<h3 className="text-base">관람일</h3>
93+
<ChevronUp
94+
className={`w-5 h-5 transition-transform ${
95+
isOpen ? "" : "rotate-180"
96+
}`}
97+
/>
98+
</div>
99+
100+
{isOpen && (
101+
<>
102+
<div className="flex items-center justify-between mb-3">
103+
<button
104+
onClick={handlePrevMonth}
105+
className="p-1 hover:bg-gray-100 rounded-lg transition-colors"
106+
>
107+
<ChevronLeft className="w-4 h-4" />
108+
</button>
109+
<div className="text-sm">
110+
{currentMonth.getFullYear()}.{" "}
111+
{String(currentMonth.getMonth() + 1).padStart(2, "0")}
112+
</div>
113+
<button
114+
onClick={handleNextMonth}
115+
className="p-1 hover:bg-gray-100 rounded-lg transition-colors"
116+
>
117+
<ChevronRight className="w-4 h-4" />
118+
</button>
119+
</div>
120+
121+
<div className="grid grid-cols-7 gap-1 mb-1">
122+
{weekDays.map((day, index) => (
123+
<div
124+
key={day}
125+
className={`text-center text-xs py-1 ${
126+
index === 0
127+
? "text-red-500"
128+
: index === 6
129+
? "text-blue-500"
130+
: "text-gray-600"
131+
}`}
132+
>
133+
{day}
134+
</div>
135+
))}
136+
</div>
137+
138+
<div className="grid grid-cols-7 gap-1">
139+
{Array.from({ length: firstDay }, (_, i) => (
140+
<div key={`empty-${i}`} className="aspect-square" />
141+
))}
142+
{Array.from({ length: daysInMonth }, (_, i) => {
143+
const day = i + 1;
144+
const currentYear = currentMonth.getFullYear();
145+
const currentMonthIndex = currentMonth.getMonth();
146+
147+
const isAvailable =
148+
performanceYear !== null &&
149+
performanceMonth !== null &&
150+
performanceDay !== null &&
151+
performanceYear === currentYear &&
152+
performanceMonth === currentMonthIndex &&
153+
performanceDay === day;
154+
155+
const isSelected = selectedDate === day;
156+
const dayOfWeek = (firstDay + i) % 7;
157+
158+
return (
159+
<button
160+
key={day}
161+
onClick={() => handleDateClick(day)}
162+
disabled={!isAvailable}
163+
className={`aspect-square rounded-lg flex items-center justify-center text-xs transition-all ${
164+
isSelected
165+
? "bg-blue-600 text-white scale-110"
166+
: isAvailable
167+
? "hover:bg-blue-50 cursor-pointer bg-blue-100"
168+
: "cursor-not-allowed"
169+
} ${
170+
!isAvailable
171+
? "text-gray-300"
172+
: dayOfWeek === 0
173+
? "text-red-500"
174+
: dayOfWeek === 6
175+
? "text-blue-500"
176+
: "text-gray-900"
177+
}`}
178+
>
179+
{day}
180+
</button>
181+
);
182+
})}
183+
</div>
184+
</>
185+
)}
186+
</div>
187+
);
188+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Performance } from "@/types/performance";
2+
import { Calendar, MapPin, DollarSign, TrendingUp } from "lucide-react";
3+
4+
const getDifficultyColor = (difficulty: string) => {
5+
switch (difficulty) {
6+
case "상":
7+
return "bg-red-100 text-red-700";
8+
case "중":
9+
return "bg-yellow-100 text-yellow-700";
10+
case "하":
11+
return "bg-green-100 text-green-700";
12+
default:
13+
return "bg-gray-100 text-gray-700";
14+
}
15+
};
16+
17+
interface PerformanceInfoProps {
18+
performance: Performance;
19+
}
20+
21+
export default function PerformanceInfo({ performance }: PerformanceInfoProps) {
22+
const performanceDate = new Date(performance.performance_date);
23+
24+
console.log(performanceDate);
25+
const formattedDate = performanceDate.toLocaleDateString("ko-KR", {
26+
year: "numeric",
27+
month: "long",
28+
day: "numeric",
29+
hour: "numeric",
30+
minute: "numeric",
31+
});
32+
33+
return (
34+
<div>
35+
<div className="inline-block bg-white/20 backdrop-blur-sm px-4 py-2 rounded-full mb-4">
36+
<span className="text-sm">다음 티켓팅</span>
37+
</div>
38+
39+
<h2 className="text-3xl md:text-4xl mb-4">
40+
{performance.performance_name}
41+
</h2>
42+
43+
<div className="space-y-3 mb-6">
44+
<div className="flex items-center gap-3">
45+
<Calendar className="w-5 h-5 text-white/80" />
46+
<span>{formattedDate}</span>
47+
</div>
48+
<div className="flex items-center gap-3">
49+
<MapPin className="w-5 h-5 text-white/80" />
50+
<span>{performance.venue_name}</span>
51+
</div>
52+
</div>
53+
</div>
54+
);
55+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { useState } from "react";
2+
import { ChevronUp } from "lucide-react";
3+
4+
interface RoundSelectorProps {
5+
selectedRound: string | null;
6+
onRoundSelect: (roundId: string) => void;
7+
}
8+
9+
const rounds = [
10+
{
11+
id: "1",
12+
label: "1회 18:00",
13+
details: "LOVE석 2 / PEACE석 1 / 가족석(2인석/4인석) 2",
14+
},
15+
];
16+
17+
export default function RoundSelector({
18+
selectedRound,
19+
onRoundSelect,
20+
}: RoundSelectorProps) {
21+
const [isOpen, setIsOpen] = useState(true);
22+
23+
return (
24+
<div className="mb-4 bg-white rounded-xl p-4 text-gray-900">
25+
<div
26+
className="flex items-center justify-between mb-3 cursor-pointer"
27+
onClick={() => setIsOpen(!isOpen)}
28+
>
29+
<h3 className="text-base">회차</h3>
30+
<ChevronUp
31+
className={`w-5 h-5 transition-transform ${
32+
isOpen ? "" : "rotate-180"
33+
}`}
34+
/>
35+
</div>
36+
37+
{isOpen && (
38+
<div className="space-y-2">
39+
{rounds.map((round) => (
40+
<button
41+
key={round.id}
42+
onClick={() => onRoundSelect(round.id)}
43+
className={`w-full p-3 rounded-lg border-2 transition-all text-left ${
44+
selectedRound === round.id
45+
? "border-blue-600 bg-blue-50"
46+
: "border-gray-200 hover:border-blue-300"
47+
}`}
48+
>
49+
<div
50+
className={`text-sm mb-1 ${
51+
selectedRound === round.id ? "text-blue-600" : "text-gray-900"
52+
}`}
53+
>
54+
{round.label}
55+
</div>
56+
<div className="text-xs text-gray-600">{round.details}</div>
57+
</button>
58+
))}
59+
</div>
60+
)}
61+
</div>
62+
);
63+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { ErrorBoundary } from "react-error-boundary";
2+
import { Suspense } from "react";
3+
import TicketingData from "./TicketingData";
4+
5+
export default function Ticketing() {
6+
return (
7+
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
8+
<div className="bg-gradient-to-br from-purple-600 via-purple-500 to-pink-500 rounded-3xl p-8 md:p-12 text-white shadow-2xl">
9+
<div className="grid md:grid-cols-2 gap-8 items-center">
10+
<ErrorBoundary fallback={<div>추후 에러 표시 화면 구현</div>}>
11+
<Suspense fallback={<div>로딩.... 필요한가??</div>}>
12+
<TicketingData />
13+
</Suspense>
14+
</ErrorBoundary>
15+
</div>
16+
</div>
17+
</main>
18+
);
19+
}

0 commit comments

Comments
 (0)