import {useCallback, useRef, useState} from 'react'; import type {IndustryType} from '@o2o/shared'; import type {ExternalPlaceSource as ExternalPlaceSourceType, PlaceCandidate, PlaceCategory} from '@/api'; import { createPlace, ExternalPlaceSource, getAccessToken, PlaceCategory as PlaceCategoryEnum, updatePlace, verifyCandidates, verifyPlace, verifyPlaceByUrl, } from '@/api'; import type {ConfirmedIdentity} from '@/stores/builder'; import {notify, notifyApiError} from '@/lib/notify'; /** 빌더 업종 → places.category. 반대 방향은 stores/builder 의 CATEGORY_TO_INDUSTRY 다. */ const INDUSTRY_TO_CATEGORY: Record = { stay: PlaceCategoryEnum.LODGING, cafe: PlaceCategoryEnum.CAFE, restaurant: PlaceCategoryEnum.RESTAURANT, tour: PlaceCategoryEnum.TOUR_ACTIVITY, }; /** 후보를 어느 장소 DB 에서 찾았는지 — 사장님이 판단할 근거로 카드에 그대로 붙인다. */ const SOURCE_LABEL: Record = { [ExternalPlaceSource.KAKAO]: '카카오맵', [ExternalPlaceSource.NAVER]: '네이버 지도', }; export type SearchPhase = /** 아직 검색 전. */ | 'idle' /** 사업장 생성 + 후보 조회 중. */ | 'searching' /** 후보를 받았다(0건일 수도 있다 — outcome 이 no_candidate). */ | 'done' /** * 장소 DB 를 부를 수 없다 — 로그인이 없거나 백엔드가 응답하지 않는다. * ★ 이때 가짜 후보를 지어내지 않는다. 검색 결과인 척하는 예시 카드는 * 사장님이 남의 가게를 자기 가게로 확정하게 만드는 가장 빠른 길이다. */ | 'unavailable'; export interface PlaceSearchState { phase: SearchPhase; /** 외부 장소 DB 후보. 서버가 자동 확정하지 않으므로 항상 사람이 고른다. */ candidates: PlaceCandidate[]; /** matched | ambiguous | no_candidate — 서버 판정. 문구를 고르는 데만 쓴다. */ outcome: string; /** true 면 판정이 명확해 '이거 맞나요?' 한 번만 물어도 된다. */ autoSelectable: boolean; /** 후보를 준 장소 DB 이름. 없으면 빈 문자열. */ sourceLabel: string; source: ExternalPlaceSourceType | null; /** 검색을 못 한 이유(로그인 필요 등). phase === 'unavailable' 일 때만 채워진다. */ unavailableReason: string; } const INITIAL: PlaceSearchState = { phase: 'idle', candidates: [], outcome: '', autoSelectable: false, sourceLabel: '', source: null, unavailableReason: '', }; /** * 상호 검색 → 동일 업소 후보 조회 → 사람이 고른 후보로 확정. * * 백엔드 순서가 그대로 화면 순서다: * 1) POST /v1/place 사업장(껍데기)을 만든다 — 후보 조회가 place_id 를 요구한다 * 2) GET /v1/place/{id}/verify/candidates?query= 외부 장소 DB 후보 * 3) POST /v1/place/{id}/verify 사람이 고른 후보로 동일 업소 확정 * * ★ 3 을 통과해야 수집이 열린다(place.py: "동일 업소 검증과 채널 URL 확정이 끝나야 시작할 수 있다"). * 그래서 직접 입력 경로는 수집 없이 사장님 입력만으로 사이트를 만든다 — 검증을 우회하지 않는다. */ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string | null = null) { const [state, setState] = useState(INITIAL); const [isConfirming, setIsConfirming] = useState(false); /** * 이 위저드가 만든 사업장. 상호를 고쳐 다시 검색해도 사업장을 새로 만들지 않는다. * ★ 새로고침을 넘어온 경우 스토어에 남아 있는 사업장을 그대로 이어받는다 — 안 그러면 * 되살아난 화면이 같은 가게를 한 번 더 만든다. */ const placeIdRef = useRef(existingPlaceId); const inflight = useRef(null); /** 마지막으로 검색한 상호. URL 확정 때 사업장 껍데기 이름으로 쓴다. */ const nameRef = useRef(''); const reset = useCallback(() => { inflight.current?.abort(); inflight.current = null; setState(INITIAL); }, []); /** 사업장 껍데기 확보. 이미 만들었으면 상호만 맞춰 둔다(같은 위저드에서 두 번 만들지 않는다). */ const ensurePlace = useCallback( async (name: string, signal: AbortSignal): Promise => { if (placeIdRef.current) { // PATCH 는 취소 신호를 받지 않는다(생성물 시그니처) — 뒤에서 signal 로 결과를 버린다. await updatePlace(placeIdRef.current, {name}); if (signal.aborted) return null; return placeIdRef.current; } const created = await createPlace( {name, category: INDUSTRY_TO_CATEGORY[industry]}, undefined, signal, ); placeIdRef.current = created.place?.place_id ?? null; return placeIdRef.current; }, [industry], ); const search = useCallback( async (name: string, location: string) => { nameRef.current = name.trim(); const query = [name.trim(), location.trim()].filter(Boolean).join(' '); if (!name.trim()) return; inflight.current?.abort(); const controller = new AbortController(); inflight.current = controller; if (!getAccessToken()) { setState({ ...INITIAL, phase: 'unavailable', unavailableReason: '로그인이 만료되었습니다. 다시 로그인한 뒤 검색해 주세요.', }); return; } setState({...INITIAL, phase: 'searching'}); try { const placeId = await ensurePlace(name.trim(), controller.signal); if (!placeId) throw new Error('사업장을 만들지 못했습니다.'); const res = await verifyCandidates(placeId, {query}, undefined, controller.signal); if (controller.signal.aborted) return; const source = res.source ?? null; setState({ phase: 'done', candidates: res.candidates ?? [], outcome: res.outcome ?? '', autoSelectable: res.auto_selectable ?? false, source, sourceLabel: source ? (SOURCE_LABEL[source] ?? '외부 장소 DB') : '외부 장소 DB', unavailableReason: '', }); } catch (error) { if (controller.signal.aborted) return; notifyApiError(error, '상호를 검색하지 못했습니다.'); setState({ ...INITIAL, phase: 'unavailable', unavailableReason: '장소 DB 를 부르지 못했습니다. 백엔드가 떠 있는지 확인하시거나, 직접 입력으로 진행해 주세요.', }); } }, [ensurePlace], ); /** * 사람이 고른 후보를 이 사업장의 신원으로 확정한다(POST /verify). * 성공하면 화면에 얹을 신원을 돌려준다 — 실패하면 null. */ const confirm = useCallback( async (candidate: PlaceCandidate): Promise => { const placeId = placeIdRef.current; const address = candidate.road_address ?? candidate.address ?? ''; if (!placeId) return null; setIsConfirming(true); try { await verifyPlace(placeId, { source: state.source ?? ExternalPlaceSource.KAKAO, // 네이버는 고유 id 를 주지 않는다 — 그 경우 상호명 + 도로명주소가 중복 판정 키다. external_place_id: candidate.external_place_id ?? '', road_address: candidate.road_address ?? null, address: candidate.address ?? null, phone: candidate.phone ?? null, latitude: candidate.latitude ?? null, longitude: candidate.longitude ?? null, place_url: candidate.place_url ?? null, }); // ★ 상호도 후보 쪽으로 맞춘다. 사장님이 그 이름이 적힌 카드를 보고 "이 가게예요"를 // 눌렀는데 DB 에는 검색어로 친 이름이 남으면, 화면과 서버가 다른 상호를 들고 있게 된다. const officialName = candidate.name?.trim(); if (officialName) await updatePlace(placeId, {name: officialName}); return { placeId, name: candidate.name ?? '', address, phone: candidate.phone ?? undefined, origin: 'external', sourceLabel: state.sourceLabel || '외부 장소 DB', externalPlaceId: candidate.external_place_id ?? undefined, placeUrl: candidate.place_url ?? undefined, }; } catch (error) { notifyApiError(error, '이 가게로 확정하지 못했습니다.'); return null; } finally { setIsConfirming(false); } }, [state.source, state.sourceLabel], ); /** * 네이버 플레이스 URL 로 확정한다. * * ★ 이 경로가 가장 확실하다. 상호 검색은 동명 업소·지점명 표기 차이에서 실패하고 * (실측: '롯데호텔 서울'), 검색모델은 네이버 플레이스를 아예 못 찾는다. * 반면 사장님은 자기 가게 주소를 이미 안다 — 붙여넣는 순간 신원·채널이 동시에 확정된다. * 상호·주소는 서버가 그 URL 에서 읽어 온다(손으로 옮겨 적게 하면 오타가 남의 가게가 된다). */ const confirmByUrl = useCallback( async (url: string): Promise => { setIsConfirming(true); try { const placeId = await ensurePlace(nameRef.current || '내 가게', new AbortController().signal); if (!placeId) return null; const res = await verifyPlaceByUrl(placeId, {url}); const place = res.place; if (!place) { notify.error('가게 정보를 읽지 못했습니다.', res.msg ?? '주소를 다시 확인해 주세요.'); return null; } return { placeId, name: place.name ?? '', address: place.road_address ?? place.address ?? '', phone: place.phone ?? undefined, origin: 'external', sourceLabel: '네이버 플레이스', externalPlaceId: place.external_place_id ?? undefined, }; } catch (error) { notifyApiError(error, '이 주소로 확정하지 못했습니다.'); return null; } finally { setIsConfirming(false); } }, [ensurePlace], ); /** * 목록에 없거나 장소 DB 를 못 부를 때 — 사장님이 직접 댄 값으로 신원을 세운다. * * ★ 동일 업소 검증(POST /verify)을 하지 않는다. 검증 없이 수집을 열면 남의 가게 URL 을 * 긁을 수 있다. 그래서 이 경로는 수집 없이 직접 입력한 정보로만 사이트를 만든다. */ const confirmManual = useCallback((name: string, address: string): ConfirmedIdentity => { return { placeId: placeIdRef.current, name: name.trim(), address: address.trim(), origin: 'owner', sourceLabel: '직접 입력', }; }, []); return {...state, isConfirming, search, confirm, confirmByUrl, confirmManual, reset}; }