Skip to content

Commit 3d21a88

Browse files
committed
feat: 방문기록 타임라인 추가
1 parent 49c92ad commit 3d21a88

3 files changed

Lines changed: 453 additions & 92 deletions

File tree

src/components/VisitTimeline.tsx

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { Link } from 'react-router';
2+
import { Calendar, MapPin, Clock, ChevronDown, Utensils } from 'lucide-react';
3+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
4+
import { Button } from '@/components/ui/button';
5+
import type { VisitWithRestaurant } from '@/types/visit';
6+
7+
interface VisitTimelineProps {
8+
className?: string;
9+
visits: VisitWithRestaurant[];
10+
loading: boolean;
11+
error: string | null;
12+
totalCount: number;
13+
hasMore: boolean;
14+
onLoadMore: () => void;
15+
}
16+
17+
interface GroupedVisits {
18+
[date: string]: VisitWithRestaurant[];
19+
}
20+
21+
export function VisitTimeline({
22+
className,
23+
visits,
24+
loading,
25+
error,
26+
totalCount,
27+
hasMore,
28+
onLoadMore,
29+
}: VisitTimelineProps) {
30+
const formatDate = (dateString: string) => {
31+
const date = new Date(dateString);
32+
const today = new Date();
33+
const yesterday = new Date(today);
34+
yesterday.setDate(yesterday.getDate() - 1);
35+
36+
const isToday = date.toDateString() === today.toDateString();
37+
const isYesterday = date.toDateString() === yesterday.toDateString();
38+
39+
if (isToday) return '오늘';
40+
if (isYesterday) return '어제';
41+
42+
return date.toLocaleDateString('ko-KR', {
43+
year: 'numeric',
44+
month: 'long',
45+
day: 'numeric',
46+
weekday: 'long',
47+
});
48+
};
49+
50+
const formatTime = (dateString: string) => {
51+
const date = new Date(dateString);
52+
return date.toLocaleTimeString('ko-KR', {
53+
hour: '2-digit',
54+
minute: '2-digit',
55+
});
56+
};
57+
58+
const groupVisitsByDate = (visits: VisitWithRestaurant[]): GroupedVisits => {
59+
return visits.reduce((groups, visit) => {
60+
const date = new Date(visit.created_at).toDateString();
61+
if (!groups[date]) {
62+
groups[date] = [];
63+
}
64+
groups[date].push(visit);
65+
return groups;
66+
}, {} as GroupedVisits);
67+
};
68+
69+
const groupedVisits = groupVisitsByDate(visits);
70+
const sortedDates = Object.keys(groupedVisits).sort((a, b) => new Date(b).getTime() - new Date(a).getTime());
71+
72+
if (loading && visits.length === 0) {
73+
return (
74+
<Card className={className}>
75+
<CardHeader>
76+
<CardTitle className="text-xl font-semibold flex items-center">
77+
<Calendar className="w-6 h-6 mr-2 text-green-500" />
78+
방문 기록 타임라인
79+
</CardTitle>
80+
</CardHeader>
81+
<CardContent>
82+
<div className="flex justify-center py-8">
83+
<p className="text-gray-500">방문 기록을 불러오는 중...</p>
84+
</div>
85+
</CardContent>
86+
</Card>
87+
);
88+
}
89+
90+
if (error) {
91+
return (
92+
<Card className={className}>
93+
<CardHeader>
94+
<CardTitle className="text-xl font-semibold flex items-center">
95+
<Calendar className="w-6 h-6 mr-2 text-green-500" />
96+
방문 기록 타임라인
97+
</CardTitle>
98+
</CardHeader>
99+
<CardContent>
100+
<div className="flex justify-center py-8">
101+
<p className="text-red-500">{error}</p>
102+
</div>
103+
</CardContent>
104+
</Card>
105+
);
106+
}
107+
108+
if (visits.length === 0) {
109+
return (
110+
<Card className={className}>
111+
<CardHeader>
112+
<CardTitle className="text-xl font-semibold flex items-center">
113+
<Calendar className="w-6 h-6 mr-2 text-green-500" />
114+
방문 기록 타임라인
115+
</CardTitle>
116+
</CardHeader>
117+
<CardContent>
118+
<div className="text-center py-8">
119+
<Calendar className="w-12 h-12 mx-auto text-gray-300 mb-4" />
120+
<p className="text-gray-500 mb-4">아직 방문한 식당이 없습니다.</p>
121+
<Link to="/">
122+
<Button variant="outline">맛집 찾아보기</Button>
123+
</Link>
124+
</div>
125+
</CardContent>
126+
</Card>
127+
);
128+
}
129+
130+
return (
131+
<Card className={className}>
132+
<CardHeader>
133+
<CardTitle className="text-xl font-semibold flex items-center">
134+
<Calendar className="w-6 h-6 mr-2 text-green-500" />
135+
방문 기록 타임라인 ({totalCount})
136+
</CardTitle>
137+
</CardHeader>
138+
<CardContent>
139+
{sortedDates.map((dateKey) => (
140+
<div key={dateKey} className="relative">
141+
<div className="flex items-center gap-3 mb-2 relative">
142+
<div className="bg-green-100 text-green-800 px-4 py-2 rounded-full text-sm font-semibold">
143+
{formatDate(groupedVisits[dateKey][0].created_at)}
144+
</div>
145+
<div className="text-sm text-gray-500 font-medium">{groupedVisits[dateKey].length}곳 방문</div>
146+
</div>
147+
148+
<div className="space-y-2 pb-6">
149+
{groupedVisits[dateKey].map((visit) => (
150+
<div key={visit.visit_id} className="relative flex items-start gap-4">
151+
<div className="flex-1">
152+
{visit.restaurant_name ? (
153+
<Link
154+
to={`/restaurant/${visit.restaurant_id}`}
155+
className="block bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md hover:border-gray-300 transition-all duration-200"
156+
>
157+
<div className="flex items-start justify-between gap-3 mb-3">
158+
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1">{visit.restaurant_name}</h3>
159+
<div className="flex items-center text-sm text-gray-500 flex-shrink-0 gap-1">
160+
<Clock className="w-4 h-4" />
161+
{formatTime(visit.created_at)}
162+
</div>
163+
</div>
164+
165+
<div className="space-y-2">
166+
{visit.restaurant_category && (
167+
<div className="flex items-center gap-2 text-sm text-gray-600">
168+
<Utensils className="w-4 h-4 flex-shrink-0 text-gray-400" />
169+
<span className="truncate">{visit.restaurant_category}</span>
170+
</div>
171+
)}
172+
{visit.restaurant_address && (
173+
<div className="flex items-center gap-2 text-sm text-gray-600">
174+
<MapPin className="w-4 h-4 flex-shrink-0 text-gray-400" />
175+
<span className="truncate">{visit.restaurant_address}</span>
176+
</div>
177+
)}
178+
</div>
179+
</Link>
180+
) : (
181+
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
182+
<div className="flex items-start justify-between gap-3 mb-3">
183+
<h3 className="text-lg font-medium text-gray-600">식당 정보 불러오는 중...</h3>
184+
<div className="flex items-center text-sm text-gray-500 flex-shrink-0 gap-1">
185+
<Clock className="w-4 h-4" />
186+
{formatTime(visit.created_at)}
187+
</div>
188+
</div>
189+
190+
<div className="space-y-2">
191+
{visit.restaurant_category && (
192+
<div className="flex items-center gap-2 text-sm text-gray-600">
193+
<Utensils className="w-4 h-4 flex-shrink-0 text-gray-400" />
194+
<span className="truncate">{visit.restaurant_category}</span>
195+
</div>
196+
)}
197+
{visit.restaurant_address && (
198+
<div className="flex items-center gap-2 text-sm text-gray-600">
199+
<MapPin className="w-4 h-4 flex-shrink-0 text-gray-400" />
200+
<span className="truncate">{visit.restaurant_address}</span>
201+
</div>
202+
)}
203+
</div>
204+
</div>
205+
)}
206+
</div>
207+
</div>
208+
))}
209+
</div>
210+
</div>
211+
))}
212+
213+
{hasMore && (
214+
<div className="flex justify-center pt-6 border-t border-gray-100">
215+
<Button onClick={onLoadMore} disabled={loading} variant="outline" className="w-full max-w-sm">
216+
{loading ? (
217+
<div className="flex items-center gap-2">
218+
<div className="w-4 h-4 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin" />
219+
불러오는 중...
220+
</div>
221+
) : (
222+
<div className="flex items-center gap-2">
223+
<ChevronDown className="w-4 h-4" />
224+
더보기
225+
</div>
226+
)}
227+
</Button>
228+
</div>
229+
)}
230+
</CardContent>
231+
</Card>
232+
);
233+
}

0 commit comments

Comments
 (0)