import {useEffect, useRef, useState} from 'react'; import {ArrowRight, Check, MapPin, Search, TriangleAlert} from 'lucide-react'; import type {IndustryType} from '@o2o/shared'; import type {PlaceSearchItem} from '@/api'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; import {INDUSTRY_CONFIGS} from '@/data/industryData'; import {CATEGORY_TO_INDUSTRY} from '@/features/builder/placeAdapter'; import {cn} from '@/lib/utils'; import type {PendingPick} from '@/stores/builder'; import {useBuilderStore} from '@/stores/builder'; import {PlaceUrlBox} from './PlaceUrlBox'; import {usePlaceSearch} from './usePlaceSearch'; import {useWizardStep} from './wizardUrl'; import {WizardFooter} from './WizardFooter'; import {WizardStage, WizardWaiting} from './WizardStage'; const STAGE_TITLE: Record = { input: '가게 이름을 알려주세요', searching: '내 가게를 찾는 중입니다', picking: '이 중에 사장님 가게가 있나요?', unavailable: '지도 검색을 사용할 수 없습니다', confirmed: '이 가게로 확인되었습니다', }; const STAGE_DESCRIPTION: Record = { input: '업종은 안 고르셔도 됩니다 — 찾은 가게의 분류에서 자동으로 정해집니다.', searching: '', picking: '고른 가게의 상호·주소가 이 사이트의 기준 정보가 되고, 업종도 그 분류에서 정해집니다.', unavailable: '', confirmed: '이제 이 가게의 공개 채널에서 정보를 수집합니다.', }; /** * 1단계 — 내 가게 확인. **위저드의 시작점이다.** * * ★ 예전에는 업종 선택이 앞에 있었다. 그런데 사장님이 100% 아는 건 자기 가게 **이름**이고, * 업종은 경계에서 멈춘다("우리는 카페인가 음식점인가"). 그래서 순서를 뒤집었다 — * 상호명을 먼저 받고, 업종은 검색 결과의 분류가 정한다(services/place_category.py). * 못 정했을 때만 업종 화면(`?step=industry`)으로 넘긴다. * * 한 화면에 한 가지만 묻는다: 상호를 묻거나 · 기다리거나 · 후보를 고르게 하거나 · 확정을 보여준다. */ export function Step2PlaceSearch() { const industry = useBuilderStore((s) => s.industry); const storeName = useBuilderStore((s) => s.storeName); const location = useBuilderStore((s) => s.location); const confirmedIdentity = useBuilderStore((s) => s.confirmedIdentity); const pendingPick = useBuilderStore((s) => s.pendingPick); const setStoreName = useBuilderStore((s) => s.setStoreName); const setLocation = useBuilderStore((s) => s.setLocation); const selectIndustry = useBuilderStore((s) => s.selectIndustry); const setPendingPick = useBuilderStore((s) => s.setPendingPick); const confirmIdentity = useBuilderStore((s) => s.confirmIdentity); const clearIdentity = useBuilderStore((s) => s.clearIdentity); // 새로고침을 넘어온 경우 이미 만들어진 사업장을 그대로 쓴다(같은 위저드에서 두 번 만들지 않는다). const search = usePlaceSearch(confirmedIdentity?.placeId ?? null); const [, goToStep] = useWizardStep(); const [placeUrl, setPlaceUrl] = useState(''); /** ★ 후보가 있으면 붙여넣기 칸을 접는다. 펼쳐 두면 "자동으로 못 찾았다"는 신호로 읽힌다 — * 이 시점에는 자동 발견을 시도조차 안 했다(수집 직전에 서버가 한다). 목록에 내 가게가 * 없을 때만 여는 탈출구다. */ const [urlBoxOpen, setUrlBoxOpen] = useState(false); /** 고른 후보가 들고 온 네이버 플레이스 주소(검색 단계에서 서버가 찾아 준 값). */ const naverUrlRef = useRef(null); /** 확정 직후 2단계로 넘어가는 한 번은 초기화를 건너뛴다. */ const justConfirmed = useRef(false); const foundCount = search.items.filter((i) => i.naver_place_url).length; const canSearch = storeName.trim().length > 0 && search.phase !== 'searching'; /** * 확정된 사업장을 주소창에 남기며 다음 단계로. * * ★ `?placeId=` 가 새로고침을 넘기는 유일한 수단이다. 위저드 상태는 브라우저에 저장하지 않는다 * (stores/builder 주석): 저장하면 새 가게를 만들러 들어와도 지난 가게가 확정된 것으로 떠 버린다. * 주소창에 두면 새로고침은 서버에서 복원되고(usePlaceSync), 주소를 새로 열면 깨끗하다. * ★ 단계와 사업장을 **한 번에** 바꾼다. 나눠 쓰면 그 사이에 "placeId 는 붙었는데 아직 1단계"인 * 주소가 히스토리에 한 칸 생기고, 뒤로가기가 그 칸에 걸린다. */ const advance = (placeId: string | null) => { goToStep('collect', { params: {placeId, flow: placeId ? 'onboarding' : null}, }); }; const finishPick = async (pick: PendingPick, nextIndustry: IndustryType) => { const identity = await search.confirmPick(pick, nextIndustry); setPendingPick(null); if (!identity) return; // 실패 사유는 search.pickError 가 화면에 남긴다 justConfirmed.current = true; confirmIdentity( naverUrlRef.current ? {...identity, naverPlaceUrl: naverUrlRef.current} : identity, ); advance(identity.placeId); }; /** * 업종 화면을 다녀온 후보를 이어서 확정한다. * * ★ 업종이 **찍혀서** 돌아온 것만 이어간다. 뒤로가기로 돌아온 경우(업종을 안 고른 경우)까지 * 확정하면 사장님이 고르지도 않은 기본 업종으로 사업장이 만들어진다. */ const resumed = useRef(false); useEffect(() => { const industryFromPicker = pendingPick?.industry; if (!pendingPick || !industryFromPicker || resumed.current) return; resumed.current = true; void finishPick(pendingPick, industryFromPicker); // eslint-disable-next-line react-hooks/exhaustive-deps -- 한 번만 이어붙이는 일이라 pendingPick 만 본다 }, [pendingPick]); /** * 후보 하나를 골랐다 — 여기서 업종이 정해진다. * * ★ `category` 는 서버가 외부 분류에서 **추정한** 값이고, 못 정하면 키 자체가 없다 * (RemoveNoneResponse 가 null 필드를 지운다). 억지로 하나를 고르지 않고 사장님에게 묻는다 — * 업종은 수집 스키마와 JSON-LD 타입을 통째로 정하는 값이라 틀리면 되돌리는 값이 비싸다. */ const choose = (item: PlaceSearchItem) => { const pick: PendingPick = { name: item.name ?? '', address: item.road_address ?? '', }; // ★ 검색 단계에서 서버가 이미 찾아 둔 플레이스 주소를 그대로 물고 간다. // 이게 있으면 수집 직전에 다시 찾을 필요가 없고, 사장님에게 지도 주소를 묻지도 않는다. naverUrlRef.current = item.naver_place_url ?? null; const guessed = item.category != null ? (CATEGORY_TO_INDUSTRY[item.category] ?? null) : null; if (!guessed) { setPendingPick(pick); goToStep('industry'); return; } selectIndustry(guessed); void finishPick(pick, guessed); }; const runSearch = () => { clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다 void search.searchPublic(storeName, location); }; const backToInput = () => { clearIdentity(); // 다른 가게를 찾으러 간다 — 주소창에 남은 사업장도 같이 놓아준다. goToStep('search', {params: {placeId: null, flow: null}, replace: true}); search.reset(); }; /** 네이버 플레이스 URL 로 확정 — 가장 확실한 경로다(usePlaceSearch.confirmByUrl 주석 참고). */ const pickByUrl = async () => { const url = placeUrl.trim(); if (!url) return; const identity = await search.confirmByUrl(url); if (!identity) return; justConfirmed.current = true; confirmIdentity(identity); advance(identity.placeId); }; /** * 업종을 바꾸러 간다. * * ★ **서버에 사업장이 이미 만들어졌으면 신원 확인부터 다시 받는다.** places.category 는 만들 때 * 정해지고 PATCH 로 못 고친다(Req_UpdatePlace 에 category 가 없다) — 화면에서만 바꾸면 * 리페치 한 번에 서버 값으로 되돌아가서, 사장님 눈에는 바꾼 게 씹힌 것으로 보인다. * 확정 전(로그인 전 포함)에는 서버에 아무것도 없으니 업종만 갈아도 된다. */ const identityIsOnServer = Boolean(confirmedIdentity?.placeId); const changeIndustry = () => { if (identityIsOnServer) { clearIdentity(); goToStep('industry', {params: {placeId: null, flow: null}}); return; } goToStep('industry'); }; // ── 화면 고르기. 위에서부터 먼저 맞는 것 하나만 그린다 ────────────── // const stage = confirmedIdentity ? 'confirmed' : search.phase === 'searching' ? 'searching' : search.phase === 'unavailable' ? 'unavailable' : search.phase === 'done' ? 'picking' : 'input'; /** * 2단계에서 **뒤로 오면 처음(상호 입력)으로 되돌린다.** * * ★ 왜 확정 화면을 안 띄우나: 뒤로가기는 "다시 고르겠다" 는 뜻인데, 확정 화면만 뜨면 * 다른 가게로 바꾸러 온 사장님이 목록을 못 찾는다. 후보 목록을 되살리는 방법도 있지만 * 공개 검색이 유료 외부 API 라 뒤로 올 때마다 다시 부르게 된다. * ★ 이 화면을 **정상적으로 지나간** 경우(확정 직후 2단계로 넘어감)에는 돌지 않는다 — * `justConfirmed` 가 그 한 번을 막는다. 아니면 확정하자마자 스스로 지워 버린다. */ useEffect(() => { if (!confirmedIdentity) return; if (justConfirmed.current) { justConfirmed.current = false; return; } clearIdentity(); search.reset(); goToStep('search', {params: {placeId: null, flow: null}, replace: true}); // eslint-disable-next-line react-hooks/exhaustive-deps -- 들어올 때 한 번만 판단한다 }, []); const searchQuery = [storeName, location].filter(Boolean).join(' '); /** 뒤로 와서 목록을 다시 볼 때, 이미 고른 후보를 알아보게 한다. */ const isPicked = (item: PlaceSearchItem) => Boolean(confirmedIdentity) && item.name === confirmedIdentity?.name && (item.road_address ?? '') === (confirmedIdentity?.address ?? ''); return (
{stage === 'input' && (
setStoreName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && canSearch) runSearch(); }} placeholder="가게 이름을 입력하세요" autoFocus />
setLocation(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && canSearch) runSearch(); }} placeholder="예: 제주시 애월읍" />

같은 이름의 가게가 많을 때 구분하는 데 쓰입니다. 비워도 검색됩니다.

)} {stage === 'searching' && ( )} {stage === 'picking' && (

0 ? 'border border-border bg-card text-muted-foreground' : 'border border-warning/30 bg-warning/10 text-warning', )} > {search.items.length === 0 ? '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.' : foundCount > 0 ? `${foundCount}곳은 네이버 플레이스까지 찾았습니다 — 고르시면 영업시간·전화·사진을 그대로 가져옵니다.` : '아래 목록에서 사장님 가게를 골라 주세요.'}

{search.pickError && (

{search.pickError}

)}
    {search.items.map((item, index) => (
  • ))}
{/* 목록에서 고르면 업종은 그 후보가 정한다 — 이 줄은 아래 '주소로 확정' 경로용이다. */} {search.items.length > 0 && !urlBoxOpen && !search.pickError ? ( ) : ( void pickByUrl()} isBusy={search.isConfirming} compact={search.items.length > 0 && !search.pickError} searchQuery={searchQuery} /> )}
)} {stage === 'unavailable' && (

{search.unavailableReason}

void pickByUrl()} isBusy={search.isConfirming} searchQuery={searchQuery} />
)} {stage === 'confirmed' && confirmedIdentity && (

{confirmedIdentity.name}

{confirmedIdentity.address && (

{confirmedIdentity.address}

)} {confirmedIdentity.phone && (

{confirmedIdentity.phone}

)}
{confirmedIdentity.origin === 'external' ? ( <> {confirmedIdentity.sourceLabel} 동일 업소 확인됨 ) : ( {confirmedIdentity.sourceLabel} · 동일 업소 미검증 )}
)}
advance(confirmedIdentity?.placeId ?? null)} nextLabel="다음: 데이터 수집" nextDisabled={!confirmedIdentity} />
); } /** 후보 카드의 추정 업종. 못 정한 후보는 "고르면 묻는다"를 미리 알려 준다. */ function IndustryBadge({category}: {category: PickedCategory}) { const industry = category != null ? (CATEGORY_TO_INDUSTRY[category] ?? null) : null; return industry ? ( {INDUSTRY_CONFIGS[industry].name} ) : ( 업종 직접 선택 ); } /** PlaceSearchItem.category — 서버가 못 정하면 키 자체가 없다(RemoveNoneResponse). */ type PickedCategory = PlaceSearchItem['category']; /** * 지금 어떤 업종으로 진행 중인지 + 바꾸는 길. * * ★ 업종을 자동으로 정하기로 한 이상, **정해진 값이 화면에 보여야 한다.** 안 보이면 사장님은 * 숙박 스키마로 카페 사이트를 만들고 있다는 걸 수집 결과를 보고서야 안다. */ function IndustryLine({ industry, onChange, label = '업종', note, }: { industry: IndustryType; onChange: () => void; label?: string; note?: string; }) { return (
{label}: {INDUSTRY_CONFIGS[industry].name} {note && {note}}
); }