o2o-site-AEO/solution/frontend/src/features/onboarding/usePlaceSearch.ts
Mina Choi 22b7623aeb [fix] solution/frontend: 2단계에서 로그인을 요구하지 않는다
로그인은 에디터 진입에서 한 번 받는 것이 이 앱의 흐름인데, 장소 API 가 전부 토큰을 요구해서
(place.py) 2단계가 로그인 벽이 되어 있었다. 토큰이 없으면 서버를 부르지 않고 입력한 상호·주소로
신원을 세워 3단계로 넘어간다. 검증은 로그인 뒤에 다시 할 수 있다.
2026-09-02 09:20:55 +09:00

281 lines
11 KiB
TypeScript

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 {ensureAutoSession} from '@/lib/autoSession';
import {notify, notifyApiError} from '@/lib/notify';
/** 빌더 업종 → places.category. 반대 방향은 stores/builder 의 CATEGORY_TO_INDUSTRY 다. */
const INDUSTRY_TO_CATEGORY: Record<IndustryType, PlaceCategory> = {
stay: PlaceCategoryEnum.LODGING,
cafe: PlaceCategoryEnum.CAFE,
restaurant: PlaceCategoryEnum.RESTAURANT,
tour: PlaceCategoryEnum.TOUR_ACTIVITY,
};
/** 후보를 어느 장소 DB 에서 찾았는지 — 사장님이 판단할 근거로 카드에 그대로 붙인다. */
const SOURCE_LABEL: Record<number, string> = {
[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<PlaceSearchState>(INITIAL);
const [isConfirming, setIsConfirming] = useState(false);
/**
* 이 위저드가 만든 사업장. 상호를 고쳐 다시 검색해도 사업장을 새로 만들지 않는다.
* ★ 새로고침을 넘어온 경우 스토어에 남아 있는 사업장을 그대로 이어받는다 — 안 그러면
* 되살아난 화면이 같은 가게를 한 번 더 만든다.
*/
const placeIdRef = useRef<string | null>(existingPlaceId);
const inflight = useRef<AbortController | null>(null);
/** 마지막으로 검색한 상호. URL 확정 때 사업장 껍데기 이름으로 쓴다. */
const nameRef = useRef<string>('');
const reset = useCallback(() => {
inflight.current?.abort();
inflight.current = null;
setState(INITIAL);
}, []);
/** 사업장 껍데기 확보. 이미 만들었으면 상호만 맞춰 둔다(같은 위저드에서 두 번 만들지 않는다). */
const ensurePlace = useCallback(
async (name: string, signal: AbortSignal): Promise<string | null> => {
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;
// ★ 자동 로그인이 켜져 있으면 끝날 때까지 기다린다. 이걸 안 기다리면 페이지를 열자마자
// 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
await ensureAutoSession();
// ★ 토큰이 없으면 화면(Step2)이 검색을 부르지 않고 입력값으로 넘어간다 — 2단계는 로그인 벽이 아니다.
// 그래도 여기 도달했다면 세션이 도중에 끊긴 것이므로, 로그인을 요구하지 말고 조용히 물러난다.
if (!getAccessToken()) {
setState({...INITIAL});
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<ConfirmedIdentity | null> => {
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<ConfirmedIdentity | null> => {
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};
}