지역 정보(맛집·관광지·축제)는 행정구역 코드(local_contents.region_code) 단위로 캐시돼 "그 시군구에 있는 것"을 줬다. 양양군 업장 옆 5km 속초 관광지는 빠지고 같은 군 반대편 30km 맛집이 붙는 구조라, 캔버스의 지역 정보 섹션은 늘 "준비 중"이었다. 업장 좌표로 TourAPI 를 직접 물어 업장 단위(place_contents)에 담고, 캔버스는 스크린샷으로 받은 형식(도보 시간 필터 + 카드 캐러셀)으로 통일했다.
114 lines
4.1 KiB
TypeScript
114 lines
4.1 KiB
TypeScript
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<string, LocalGuideData>();
|
|
|
|
/**
|
|
* 업장 주변 가이드(맛집·명소·축제). 날씨(useWeather)와 같은 규약이다 —
|
|
* 실패해도 에러를 띄우지 않고 빈 목록을 유지한다. 부가 정보라 사장님 작업을 막을 이유가 없다.
|
|
*
|
|
* ★ 값은 서버(local.place_contents)가 소유하고, 발행본과 같은 필터·모양으로 온다.
|
|
* 캔버스가 보여주는 목록 = 발행 사이트에 나갈 목록. 첫 조회 때 서버가 채우므로 비어 있는 건 잠깐이다.
|
|
*/
|
|
export function useLocalGuide(): LocalGuideData {
|
|
const placeId = useBuilderStore((s) => s.placeId);
|
|
const [guide, setGuide] = useState<LocalGuideData>(() => (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;
|
|
}
|