import {useEffect, useState} from 'react'; import type {FestivalCard, GuideCard} from '@/features/builder/canvas/variants/local/types'; import {useBuilderStore} from '@/stores/builder'; export interface LocalGuideData { foods: GuideCard[]; spots: GuideCard[]; festivals: FestivalCard[]; /** 서버가 가장 최근에 수집한 시각(ISO). 아직 아무것도 없으면 undefined. */ syncedAt?: string; } const EMPTY: LocalGuideData = {foods: [], spots: [], festivals: []}; // 종류별 노출 상한. 서버(snapshot._LOCAL_MAX_PER_TYPE)가 같은 수로 자르지만, 화면이 먼저 넘치지 않게 여기서도 막는다. const MAX_PER_TYPE = 20; /** 서버 응답 항목 — 발행 payload 의 LocalPlace / FestivalEntry 와 같은 모양(백엔드 ResLocalGuide 주석). */ interface GuidePlace { name: string; category: string; searchQuery: string; distanceText?: string; distanceMeters?: number; imageUrl?: string; location?: string; description?: string; } interface GuideFestival extends GuidePlace { month: string; period?: string; officialUrl?: string; } function toCard(p: GuidePlace): GuideCard { return { name: p.name, // 설명이 없으면 주소로 대신한다 — 카드에 이름만 덜렁 있는 것보다 어디인지가 낫다. description: p.description ?? p.location ?? '', searchQuery: p.searchQuery, imageUrl: p.imageUrl, distanceMeters: typeof p.distanceMeters === 'number' ? p.distanceMeters : undefined, distanceText: p.distanceText, }; } function toFestival(f: GuideFestival): FestivalCard { return { ...toCard(f), month: f.month, period: f.period ?? '', officialUrl: f.officialUrl, }; } // 사업장 하나당 한 번만 받는다 — 필터·캐러셀 조작마다 API 를 다시 때리지 않는다. const cache = new Map(); /** * 업장 주변 가이드(맛집·명소·축제). 날씨(useWeather)와 같은 규약이다 — * 실패해도 에러를 띄우지 않고 빈 목록을 유지한다. 부가 정보라 사장님 작업을 막을 이유가 없다. * * ★ 값은 서버(local.place_contents)가 소유하고, 발행본과 같은 필터·모양으로 온다. * 캔버스가 보여주는 목록 = 발행 사이트에 나갈 목록. 첫 조회 때 서버가 채우므로 비어 있는 건 잠깐이다. */ export function useLocalGuide(): LocalGuideData { const placeId = useBuilderStore((s) => s.placeId); const [guide, setGuide] = useState(() => (placeId && cache.get(placeId)) || EMPTY); useEffect(() => { if (!placeId) { setGuide(EMPTY); return; } const cached = cache.get(placeId); if (cached) { setGuide(cached); return; } let alive = true; const controller = new AbortController(); async function fetchGuide() { try { const baseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9800'; const query = new URLSearchParams({place_id: placeId!}); const res = await fetch(`${baseUrl}/v1/local/guide?${query}`, {signal: controller.signal}); if (!res.ok) return; const data = await res.json(); if (!data?.result?.success || !alive) return; const next: LocalGuideData = { foods: ((data.restaurants ?? []) as GuidePlace[]).slice(0, MAX_PER_TYPE).map(toCard), spots: ((data.attractions ?? []) as GuidePlace[]).slice(0, MAX_PER_TYPE).map(toCard), festivals: ((data.festivals ?? []) as GuideFestival[]).slice(0, MAX_PER_TYPE).map(toFestival), syncedAt: data.synced_at ?? undefined, }; // ★ 빈 응답은 캐시하지 않는다 — 수집 전에 열어둔 화면이 빈 결과를 물고 있으면 // 수집이 끝나도 새로고침 전까지 계속 "준비 중"으로 보인다. if (next.foods.length || next.spots.length || next.festivals.length) cache.set(placeId!, next); setGuide(next); } catch { // 빈 목록 유지 — 화면에 에러를 띄우지 않는다. } } void fetchGuide(); return () => { alive = false; controller.abort(); }; }, [placeId]); return guide; }