- 테이블링/캐치테이블 검색: Google+Playwright → DuckDuckGo HTML 파싱 (브라우저 불필요) - 검색 딜레이 5~15초 → 2~5초로 단축 - 프론트엔드: 정보 텍스트 계층 개선 (폰트 크기/색상 조정) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
265 lines
11 KiB
TypeScript
265 lines
11 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 MemoSection from "@/components/MemoSection";
|
|
import { RestaurantDetailSkeleton } from "@/components/Skeleton";
|
|
import Icon from "@/components/Icon";
|
|
|
|
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-xl 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 ? "찜 해제" : "찜하기"}
|
|
>
|
|
<Icon name="favorite" size={20} filled={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"
|
|
>
|
|
<Icon name="close" size={18} />
|
|
</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-xs text-gray-500 dark:text-gray-400">
|
|
{restaurant.cuisine_type && (
|
|
<p>
|
|
<span className="text-gray-400 dark:text-gray-500">종류</span> <span className="text-gray-600 dark:text-gray-300">{restaurant.cuisine_type}</span>
|
|
</p>
|
|
)}
|
|
{restaurant.address && (
|
|
<p>
|
|
<span className="text-gray-400 dark:text-gray-500">주소</span> <span className="text-gray-600 dark:text-gray-300">{restaurant.address}</span>
|
|
</p>
|
|
)}
|
|
{restaurant.region && (
|
|
<p>
|
|
<span className="text-gray-400 dark:text-gray-500">지역</span> <span className="text-gray-600 dark:text-gray-300">{restaurant.region}</span>
|
|
</p>
|
|
)}
|
|
{restaurant.price_range && (
|
|
<p>
|
|
<span className="text-gray-400 dark:text-gray-500">가격대</span> <span className="text-gray-600 dark:text-gray-300">{restaurant.price_range}</span>
|
|
</p>
|
|
)}
|
|
{restaurant.phone && (
|
|
<p>
|
|
<span className="text-gray-400 dark:text-gray-500">전화</span>{" "}
|
|
<a href={`tel:${restaurant.phone}`} className="text-brand-600 dark:text-brand-400 hover:underline">
|
|
{restaurant.phone}
|
|
</a>
|
|
</p>
|
|
)}
|
|
{restaurant.google_place_id && (
|
|
<p className="flex gap-3">
|
|
<a
|
|
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name + (restaurant.address ? " " + restaurant.address : restaurant.region ? " " + restaurant.region.replace(/\|/g, " ") : ""))}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-brand-600 dark:text-brand-400 hover:underline text-xs"
|
|
>
|
|
Google Maps에서 보기
|
|
</a>
|
|
{(!restaurant.region || restaurant.region.split("|")[0] === "한국") && (
|
|
<a
|
|
href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-green-600 dark:text-green-400 hover:underline text-xs"
|
|
>
|
|
네이버 지도에서 보기
|
|
</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">
|
|
<Icon name="play_circle" size={11} filled className="text-red-400" />
|
|
{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"
|
|
>
|
|
<Icon name="play_circle" size={16} filled className="flex-shrink-0" />
|
|
{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-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-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} />
|
|
<MemoSection restaurantId={restaurant.id} />
|
|
</div>
|
|
);
|
|
}
|