Files
tasteby/frontend/src/components/RestaurantDetail.tsx
joungmin cdee37e341 UI/UX 개선: 모바일 네비게이션, 로그인 모달, 지도 기능, 캐치테이블 연동
- 모바일 하단 네비게이션(홈/식당목록/내주변/찜/내정보) 추가
- 로그인 버튼을 모달 방식으로 변경 (소셜 로그인 확장 가능)
- 내위치 기반 정렬 및 영역 필터, 지도 내위치 버튼 추가
- 채널 필터 시 해당 채널만 마커/범례 표시
- 캐치테이블 검색/연동 (단건/벌크), NONE 저장 패턴
- 벌크 트랜스크립트 SSE (Playwright 브라우저 재사용)
- 테이블링/캐치테이블 버튼 UI 차별화
- Google Maps 링크 모바일 호환, 초기화 버튼, 검색 라벨 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:49:16 +09:00

254 lines
9.9 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { api, getToken } from "@/lib/api";
import type { Restaurant, VideoLink } from "@/lib/api";
import ReviewSection from "@/components/ReviewSection";
import { RestaurantDetailSkeleton } from "@/components/Skeleton";
interface RestaurantDetailProps {
restaurant: Restaurant;
onClose: () => void;
}
export default function RestaurantDetail({
restaurant,
onClose,
}: RestaurantDetailProps) {
const [videos, setVideos] = useState<VideoLink[]>([]);
const [loading, setLoading] = useState(true);
const [favorited, setFavorited] = useState(false);
const [favLoading, setFavLoading] = useState(false);
useEffect(() => {
setLoading(true);
api
.getRestaurantVideos(restaurant.id)
.then(setVideos)
.catch(() => setVideos([]))
.finally(() => setLoading(false));
// Load favorite status if logged in
if (getToken()) {
api.getFavoriteStatus(restaurant.id)
.then((r) => setFavorited(r.favorited))
.catch(() => {});
}
}, [restaurant.id]);
const handleToggleFavorite = async () => {
if (!getToken()) return;
setFavLoading(true);
try {
const res = await api.toggleFavorite(restaurant.id);
setFavorited(res.favorited);
} catch { /* ignore */ }
finally { setFavLoading(false); }
};
return (
<div className="p-4 space-y-4">
<div className="flex justify-between items-start">
<div className="flex items-center gap-2">
<h2 className="text-lg font-bold dark:text-gray-100">{restaurant.name}</h2>
{getToken() && (
<button
onClick={handleToggleFavorite}
disabled={favLoading}
className={`text-xl leading-none transition-colors ${
favorited ? "text-rose-500" : "text-gray-300 dark:text-gray-600 hover:text-rose-400"
}`}
title={favorited ? "찜 해제" : "찜하기"}
>
{favorited ? "♥" : "♡"}
</button>
)}
{restaurant.business_status === "CLOSED_PERMANENTLY" && (
<span className="px-2 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded text-xs font-semibold">
</span>
)}
{restaurant.business_status === "CLOSED_TEMPORARILY" && (
<span className="px-2 py-0.5 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 rounded text-xs font-semibold">
</span>
)}
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none"
>
x
</button>
</div>
{restaurant.rating && (
<div className="flex items-center gap-2 text-sm">
<span className="text-blue-500 dark:text-blue-400 font-medium text-xs">Google</span>
<span className="text-yellow-500 dark:text-yellow-400">{"★".repeat(Math.round(restaurant.rating))}</span>
<span className="font-medium dark:text-gray-200">{restaurant.rating}</span>
{restaurant.rating_count && (
<span className="text-gray-400 dark:text-gray-500 text-xs">({restaurant.rating_count.toLocaleString()})</span>
)}
</div>
)}
<div className="space-y-1 text-sm dark:text-gray-300">
{restaurant.cuisine_type && (
<p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.cuisine_type}
</p>
)}
{restaurant.address && (
<p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.address}
</p>
)}
{restaurant.region && (
<p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.region}
</p>
)}
{restaurant.price_range && (
<p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.price_range}
</p>
)}
{restaurant.phone && (
<p>
<span className="text-gray-500 dark:text-gray-400">:</span>{" "}
<a href={`tel:${restaurant.phone}`} className="text-orange-600 dark:text-orange-400 hover:underline">
{restaurant.phone}
</a>
</p>
)}
{restaurant.google_place_id && (
<p>
<a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name)}`}
target="_blank"
rel="noopener noreferrer"
className="text-orange-600 dark:text-orange-400 hover:underline text-xs"
>
Google Maps에서
</a>
</p>
)}
</div>
{restaurant.tabling_url && restaurant.tabling_url !== "NONE" && (
<a
href={restaurant.tabling_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 bg-rose-500 hover:bg-rose-600 text-white rounded-lg text-sm font-semibold transition-colors"
>
<span>T</span>
<span> </span>
</a>
)}
{restaurant.catchtable_url && restaurant.catchtable_url !== "NONE" && (
<a
href={restaurant.catchtable_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 bg-violet-500 hover:bg-violet-600 text-white rounded-lg text-sm font-semibold transition-colors"
>
<span>C</span>
<span> </span>
</a>
)}
<div>
<h3 className="font-semibold text-sm mb-2 dark:text-gray-200"> </h3>
{loading ? (
<div className="space-y-3 animate-pulse">
{[1, 2].map((i) => (
<div key={i} className="border dark:border-gray-700 rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2">
<div className="h-4 w-16 bg-gray-200 dark:bg-gray-700 rounded-sm" />
<div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
<div className="h-4 w-full bg-gray-200 dark:bg-gray-700 rounded" />
<div className="flex gap-1">
<div className="h-5 w-14 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-5 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
</div>
))}
</div>
) : videos.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400"> </p>
) : (
<div className="space-y-3">
{videos.map((v) => (
<div key={v.video_id} className="border dark:border-gray-700 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
{v.channel_name && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400 rounded text-[10px] font-semibold">
<span className="text-[9px]"></span>
{v.channel_name}
</span>
)}
{v.published_at && (
<span className="text-[10px] text-gray-400 dark:text-gray-500">
{v.published_at.slice(0, 10)}
</span>
)}
</div>
<a
href={v.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-medium text-red-600 dark:text-red-400 hover:underline"
>
<svg viewBox="0 0 24 24" className="w-4 h-4 flex-shrink-0 fill-current" aria-hidden="true">
<path d="M23.5 6.2c-.3-1-1-1.8-2-2.1C19.6 3.5 12 3.5 12 3.5s-7.6 0-9.5.6c-1 .3-1.7 1.1-2 2.1C0 8.1 0 12 0 12s0 3.9.5 5.8c.3 1 1 1.8 2 2.1 1.9.6 9.5.6 9.5.6s7.6 0 9.5-.6c1-.3 1.7-1.1 2-2.1.5-1.9.5-5.8.5-5.8s0-3.9-.5-5.8zM9.5 15.5V8.5l6.3 3.5-6.3 3.5z"/>
</svg>
{v.title}
</a>
{v.foods_mentioned.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{v.foods_mentioned.map((f, i) => (
<span
key={i}
className="px-2 py-0.5 bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded text-xs"
>
{f}
</span>
))}
</div>
)}
{v.evaluation?.text && (
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{v.evaluation.text}
</p>
)}
{v.guests.length > 0 && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
: {v.guests.join(", ")}
</p>
)}
</div>
))}
</div>
)}
</div>
{videos.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg px-4 py-3 text-center space-y-1">
<p className="text-xs text-gray-500 dark:text-gray-400">
.
</p>
<p className="text-xs text-gray-400 dark:text-gray-500">
!
</p>
</div>
)}
<ReviewSection restaurantId={restaurant.id} />
</div>
);
}