import {useEffect, useRef, useState} from 'react'; import {ArrowRight, Check, Loader2, MapPin, Pencil, Search, TriangleAlert} from 'lucide-react'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; import {Switch} from '@/components/ui/switch'; import {INDUSTRY_CONFIGS} from '@/data/industryData'; import {cn} from '@/lib/utils'; import type {LinkChannel as LinkChannelCode} from '@/api'; import {getAccessToken} from '@/api'; import {SignInForm} from '@/features/auth/SignInForm'; import {useAuthStore} from '@/stores/auth'; import {useBuilderStore, useUnverifiedFields} from '@/stores/builder'; import {ChannelConfirmPanel} from './ChannelConfirmPanel'; import {ensureServerPlace} from './ensureServerPlace'; import {ChannelUrlInput} from './ChannelUrlInput'; import {useCollectFlow} from './useCollectFlow'; import {useWizardStep} from './wizardUrl'; import {WizardFooter} from './WizardFooter'; import {WizardStage, WizardWaiting} from './WizardStage'; const STAGE_TITLE: Record = { ready: '내 가게 주소를 알려주세요', discovering: '공개 채널을 찾는 중입니다', channels: '이 채널들이 사장님 가게가 맞나요?', crawling: '정보를 수집하는 중입니다', review: '수집된 정보가 맞는지 확인해주세요', }; const STAGE_DESCRIPTION: Record = { ready: '네이버 지도에서 복사한 주소 하나면 영업시간·전화·사진까지 그대로 가져옵니다.', discovering: '', channels: '링크를 열어 내 가게가 맞는지 확인한 뒤 확정해 주세요. 확정한 URL 만 크롤링합니다.', crawling: '', review: '찾은 값은 전부 출처와 함께 보여드립니다. 사장님이 [맞아요]를 누른 값만 사이트와 AI 검색 답변에 나갑니다.', }; /** 채널 URL 확인과 수집 결과 검토 단계(진행 표시 2번). */ export function Step3DataReview() { const industry = useBuilderStore((s) => s.industry); const storeName = useBuilderStore((s) => s.storeName); const confirmedIdentity = useBuilderStore((s) => s.confirmedIdentity); const gatherCompleted = useBuilderStore((s) => s.gatherCompleted); const infoFields = useBuilderStore((s) => s.infoFields); const photos = useBuilderStore((s) => s.photos); const [, goToStep] = useWizardStep(); const confirmIdentity = useBuilderStore((s) => s.confirmIdentity); const user = useAuthStore((s) => s.user); const unverified = useUnverifiedFields(); const placeId = confirmedIdentity?.placeId ?? null; const collect = useCollectFlow(placeId); /** 로그인 관문을 이 화면에서 연다 — 위저드에서 서버를 처음 부르는 지점이 여기다. */ const [signingIn, setSigningIn] = useState(false); const [isUploading, setIsUploading] = useState(false); /** 찾아 둔 주소가 있어도 사장님이 직접 넣겠다고 하면 펼친다. */ const [urlBoxOpen, setUrlBoxOpen] = useState(false); /** * 수집 시작 — **위저드에서 서버를 처음 부르는 지점.** * * ★ 1단계의 확정은 화면에만 남는다(usePlaceSearch). 그래서 여기서 세 가지를 순서대로 한다: * 로그인 → 사업장 생성·검증(ensureServerPlace) → 수집. 로그인을 앞 단계로 올리면 * 아무것도 못 본 사장님에게 가입을 요구하게 된다(docs/PRODUCT.md "로그인 최소"). */ /** 로그인 모달을 띄우기 직전의 신원. 로그인이 끝나면 그대로 이어 돌린다. */ const pendingAfterSignIn = useRef(null); const beginCollect = async (identity = confirmedIdentity) => { if (!identity) return; if (!user && !getAccessToken()) { // ★ 로그인 뒤에 이어서 돌 값을 들고 있는다. 안 들고 있으면 로그인만 하고 제자리로 // 돌아와, 사장님이 같은 버튼을 두 번 눌러야 한다(실측 2026-09-03). pendingAfterSignIn.current = identity; setSigningIn(true); return; } if (!identity.placeId) { setIsUploading(true); const uploaded = await ensureServerPlace(identity, industry); setIsUploading(false); if (!uploaded?.placeId) return; confirmIdentity(uploaded); return; // placeId 가 스토어에 박히면 아래 자동 수집 effect 가 이어받는다 } void collect.crawl(); }; /** * 사장님이 붙여넣은 주소를 받는다. * * ★ 서버에 사업장이 없으면 `collect.addLink` 는 **조용히 아무것도 안 한다**(placeId 가 * 없어서 첫 줄에서 return). 그러면 입력칸만 비워져 사장님은 실패로 읽는다(실측). * 그래서 그 경우엔 신원에 주소를 싣고 같은 흐름(로그인 → 생성 → 검증 → 수집)으로 보낸다. */ const addUrl = async (url: string, channel: LinkChannelCode, title: string) => { if (confirmedIdentity?.placeId) { await collect.addLink(url, channel, title); return; } if (!confirmedIdentity) return; const next = {...confirmedIdentity, naverPlaceUrl: url}; confirmIdentity(next); await beginCollect(next); }; const collectPhase = collect.phase; const startAutomaticCollect = collect.crawl; const autoCollectStarted = useRef(false); // 2단계에서 네이버 플레이스를 URL로 확정하는 순간 채널도 함께 등록·확정된다. // 이 화면에 들어오면 추가 버튼 없이 곧바로 그 채널에서 수집을 시작한다. useEffect(() => { if ( autoCollectStarted.current || !placeId || confirmedIdentity?.origin !== 'external' || gatherCompleted || collect.isCheckingActive || collectPhase !== 'idle' ) { return; } autoCollectStarted.current = true; void startAutomaticCollect(); }, [ placeId, confirmedIdentity?.origin, gatherCompleted, collect.isCheckingActive, collectPhase, startAutomaticCollect, ]); // 사업장 동기화는 BuilderPage 가 한 곳에서 맡는다(같은 placeId 를 두 번 구독하지 않는다). const [activeTab, setActiveTab] = useState<'info' | 'photos'>('info'); const config = INDUSTRY_CONFIGS[industry]; const canCollect = confirmedIdentity?.origin === 'external'; /** ★ 버튼을 언제 열어 두나. * `confirmedCount` 는 **서버에 사업장이 있어야** 나오는 값이다. 그것만 보면 로그인 전에는 * 버튼이 영원히 잠겨, 자동 발견이 돌 기회조차 없다(실측 2026-09-03: 후보를 골라도 * "주소를 먼저 붙여넣어 주세요" 로 잠긴 채 멈춘다). * 아직 서버에 안 올렸으면(=placeId 없음) 자동 발견을 시도해 볼 여지가 있으므로 열어 둔다. * 올렸는데도 채널이 0이면 그때는 자동 발견이 실패한 것이라 붙여넣기를 요구한다. */ const pendingUrl = confirmedIdentity?.naverPlaceUrl; const autoLookupPending = canCollect && !placeId; const hasSource = collect.confirmedCount > 0 || Boolean(pendingUrl) || autoLookupPending; /** 아직 실제 수집이 붙지 않은 줄들 — 업종 예시다. 그렇게 부른다. */ const isSample = !confirmedIdentity || confirmedIdentity.origin === 'owner'; const stage = collect.phase === 'discovering' ? 'discovering' : collect.phase === 'crawling' ? 'crawling' : collect.phase === 'awaiting-confirm' ? 'channels' : gatherCompleted || collect.phase === 'done' ? 'review' : 'ready'; // ★ /login 으로 튕기지 않는다. 여기까지 쌓은 위저드 상태는 주소창에 없어서 돌아올 길이 없다. if (signingIn) { return (
{ setSigningIn(false); const resume = pendingAfterSignIn.current; pendingAfterSignIn.current = null; if (resume) void beginCollect(resume); }} header={

정보를 가져오려면 로그인해 주세요

고르신 가게는 그대로 있습니다. 수집한 정보를 사장님 계정에 담아 두기 위해 여기서 한 번만 받습니다.

} footer={ } />
); } return (
{stage === 'ready' && (
{/* ★ 자동 해석이 실패했다는 사실을 서버가 알려준 경우 — "수집했는데 아무것도 안 나왔다"로 끝나지 않게 다음 동작을 지목한다. */} {collect.naverPlaceMissing && (

네이버 플레이스를 자동으로 찾지 못했습니다. 아래에 내 가게의 네이버 지도 주소를 직접 붙여넣어 주세요 — 그러면 확실하게 가져옵니다.

)} {/* ★ 이미 찾아 둔 주소가 있으면 붙여넣기 안내를 접는다. 펼쳐 두면 사장님은 "또 못 찾았구나" 로 읽고 지도를 열러 간다 — 찾아 놓고 그러는 건 최악이다. */} {canCollect && pendingUrl && !urlBoxOpen ? (

네이버 플레이스를 찾았습니다

{pendingUrl}

) : ( canCollect && ( ) )} {canCollect && ( )} {/* 부차 경로 — 남겨두되 기대치를 정직하게 적는다. 실측 성공률이 낮다. */} {canCollect && (

주소를 못 찾겠으면

상호로 {config.channels.map((c) => c.name).join(' · ')} 등 공개 채널을 대신 찾아봅니다. 못 찾을 수 있습니다 — 상호 표기가 조금만 달라도 실패합니다. 그때는 위에 주소를 붙여넣어 주세요.

)} {confirmedIdentity?.origin === 'owner' && ( <>

직접 입력으로 진행 중이라 수집이 열리지 않습니다. 동일 업소 검증을 통과한 가게만 채널을 긁을 수 있습니다 — 2단계에서 지도 검색으로 가게를 확인하시면 주소 붙여넣기와 자동 찾기가 모두 열립니다.

)}
)} {stage === 'discovering' && ( )} {stage === 'crawling' && ( )} {stage === 'channels' && (
)} {stage === 'review' && (
{isSample ? ( 예시 데이터 ({infoFields.length}개 항목) ) : ( 수집 완료 ({infoFields.length}개 항목) )} {unverified.length > 0 && ( 확인 필요 {unverified.length} )}
{(['info', 'photos'] as const).map((tab) => ( ))}
{isSample && (

상호·주소는 2단계에서 확인하신 값이고, 나머지 줄과 사진은 아직 실제 수집 결과가 아니라 {config.name} 업종 예시입니다.

)} {activeTab === 'info' ? (
    {infoFields.map((field) => ( ))}
) : (
{photos.map((photo) => (
{photo.title}
{photo.title}
))}
)}
)}
0 ? ( 확인 안 된 항목 {unverified.length}개는 사이트에 나가지 않습니다. ) : stage === 'review' ? ( '확인된 값만 사이트와 AI 검색 답변에 실립니다.' ) : ( '수집을 돌리면 찾은 값이 출처와 함께 여기에 나옵니다.' ) } onPrev={() => goToStep('search')} onNext={() => goToStep('template')} nextLabel="다음: 템플릿 선택" /** * ★ 수집 전에는 잠근다. * 여기서 그냥 넘어가면 fact 도 사진도 없는 채로 템플릿을 고르고 발행까지 가서, * 서버 게이트가 NO_UNIQUE_CONTENT 로 막는다 — 사장님은 세 화면을 지나온 뒤에야 * "처음부터 아무것도 없었다"는 사실을 알게 된다. 그 전에 막는 것이 맞다. */ nextDisabled={stage !== 'review'} />
); } /** 2단계에서 확인된 가게 — 3단계 내내 "무엇을 수집하는지"의 근거로 남는다. */ function IdentityCard() { const confirmedIdentity = useBuilderStore((s) => s.confirmedIdentity); if (!confirmedIdentity) { return (
아직 가게를 확인하지 않았습니다. 2단계에서 내 가게를 먼저 골라주세요.
); } return (

{confirmedIdentity.name}

{confirmedIdentity.address && (

{confirmedIdentity.address}

)}
{confirmedIdentity.origin === 'external' ? ( <> {confirmedIdentity.sourceLabel} 동일 업소 확인됨 ) : ( 직접 입력 · 동일 업소 미검증 )}
); } /** * 수집된 값 한 줄 — 확인이 이 화면의 목적이므로 [맞아요]/[아니에요]가 줄 안에 있다. * * ★ 승인은 스토어의 verifyField 가 그대로 fact 전이 API 로 올린다 — * 화면만 바뀌고 서버가 모르는 승인은 없다. */ function FieldRow({fieldId}: {fieldId: string}) { const field = useBuilderStore((s) => s.infoFields.find((f) => f.id === fieldId)); const savingFieldIds = useBuilderStore((s) => s.savingFieldIds); const verifyField = useBuilderStore((s) => s.verifyField); const [isEditing, setIsEditing] = useState(false); const [draft, setDraft] = useState(''); if (!field) return null; const isSaving = savingFieldIds.includes(field.id); const needsCheck = field.requiresVerification && !field.isVerified; const openEditor = () => { setDraft(field.value); setIsEditing(true); }; const submit = () => { const trimmed = draft.trim(); if (trimmed) verifyField(field.id, trimmed); setIsEditing(false); }; return (
  • {field.label} {field.source && ( {field.source} )} {field.critical && ( 예약 관련 )}

    {field.value}

    {needsCheck ? (
    ) : ( 확인됨 )}
    {isEditing && (
    setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') submit(); if (e.key === 'Escape') setIsEditing(false); }} placeholder={`${field.label}의 정확한 값`} autoFocus />
    )}
  • ); }