o2o-site-AEO/solution/frontend/src/features/onboarding/Step3DataReview.tsx
Mina Choi a61f9724ea [feat] solution/frontend: 온보딩을 상호명부터 — 단계를 주소창으로 옮기고 업종은 검색이 정한다
업종을 먼저 고르게 하면 경계에서 멈춘다("우리는 카페인가 음식점인가"). 그런데 상호명은
100% 안다. 그리고 업종은 AI 를 부를 필요가 없다 — 카카오·네이버 검색 응답에 분류가
이미 들어 있고(category_group_code), 지금까지 받아 놓고 안 썼다.

- 단계가 스토어에서 주소창으로: ?step=search|industry|collect|template|generating|editor.
  번호가 아니라 이름인 이유 — 단계가 4→3 으로 줄어 옛 북마크가 다른 화면을 연다
- 시작점이 상호명 검색(Step2PlaceSearch)이다. 업종 선택은 못 정했을 때의 갈래로 남는다
- 업종은 후보의 category 로 잡히고, 못 정하면 고르게 하고, 정해져도 [바꾸기] 로 바꾼다.
  ★ 확정 뒤 변경은 신원 확인부터 다시 받는다 — Req_UpdatePlace 에 category 가 없어
    PATCH 로 못 고치고, placeAdapter 가 리페치마다 덮어써서 조용히 되돌아간다
- 랜딩 진입: ?new=1 · ?q=<상호명> · ?industry=<업종>. 한 번 읽고 replace 로 지운다
- ★ ?new=1 이 setSearchParams({}) 로 **모든 쿼리를 날리던 것**을 고쳤다 — q 가 읽히기 전에 사라졌다
- selectIndustry 가 사장님이 친 상호·위치를 지우던 것도 고쳤다(업종이 첫 화면일 땐 늘 빈 값이라 안 보였다)
- 로고는 어디서나 / 로 간다. 에디터에서는 span 이라 아예 안 눌렸다

tsc·eslint·vite build 통과
2026-09-03 10:20:57 +09:00

515 lines
21 KiB
TypeScript

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 {useBuilderStore, useUnverifiedFields} from '@/stores/builder';
import {ChannelConfirmPanel} from './ChannelConfirmPanel';
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<string, string> = {
ready: '내 가게 주소를 알려주세요',
discovering: '공개 채널을 찾는 중입니다',
channels: '이 채널들이 사장님 가게가 맞나요?',
crawling: '정보를 수집하는 중입니다',
review: '수집된 정보가 맞는지 확인해주세요',
};
const STAGE_DESCRIPTION: Record<string, string> = {
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 unverified = useUnverifiedFields();
const placeId = confirmedIdentity?.placeId ?? null;
const collect = useCollectFlow(placeId);
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';
/** 아직 실제 수집이 붙지 않은 줄들 — 업종 예시다. 그렇게 부른다. */
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';
return (
<div className="flex flex-1 flex-col justify-between bg-muted/30">
<WizardStage
current={2}
wide={stage === 'review' || stage === 'channels'}
title={STAGE_TITLE[stage]}
description={STAGE_DESCRIPTION[stage]}
>
{stage === 'ready' && (
<div className="space-y-3">
<IdentityCard />
{/* ★ 자동 해석이 실패했다는 사실을 서버가 알려준 경우 —
"수집했는데 아무것도 안 나왔다"로 끝나지 않게 다음 동작을 지목한다. */}
{collect.naverPlaceMissing && (
<p className="flex items-start gap-1.5 rounded-xl border border-warning/30 bg-warning/10 p-3 text-[11px] leading-relaxed text-warning">
<TriangleAlert className="mt-px size-3.5 shrink-0" />
<span>
<strong>네이버 플레이스를 자동으로 찾지 못했습니다.</strong> 아래에 내 가게의
네이버 지도 주소를 직접 붙여넣어 주세요 — 그러면 확실하게 가져옵니다.
</span>
</p>
)}
{/* ★ 1순위 경로. URL 만 있으면 100% 되므로 화면에서도 맨 위에 둔다. */}
{canCollect && <ChannelUrlInput onAdd={collect.addLink} isBusy={collect.isAdding} />}
{canCollect && (
<Button
variant="primary"
size="lg"
className="w-full"
onClick={collect.crawl}
disabled={collect.confirmedCount === 0}
>
<Search />
<span>
{collect.confirmedCount === 0
? '주소를 먼저 붙여넣어 주세요'
: `등록한 ${collect.confirmedCount}개 주소에서 정보 가져오기`}
</span>
</Button>
)}
{/* 부차 경로 — 남겨두되 기대치를 정직하게 적는다. 실측 성공률이 낮다. */}
{canCollect && (
<div className="rounded-2xl border border-border bg-card p-4">
<p className="text-xs font-bold">주소를 못 찾겠으면</p>
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">
상호로 {config.channels.map((c) => c.name).join(' · ')} 등 공개 채널을 대신
찾아봅니다. <strong>못 찾을 수 있습니다</strong> — 상호 표기가 조금만 달라도
실패합니다. 그때는 위에 주소를 붙여넣어 주세요.
</p>
<label
htmlFor="discover-public-channels"
className="mt-3 flex cursor-pointer items-start justify-between gap-3 rounded-xl border border-border bg-muted/40 p-3"
>
<span>
<span className="block text-xs font-semibold">추가 채널까지 AI로 찾기</span>
<span className="mt-0.5 block text-[10px] leading-relaxed text-muted-foreground">
Perplexity 유료 검색을 1회 사용합니다. URL만 후보로 저장하며, 확인한 링크만
에디터와 사이트 푸터에 표시됩니다.
</span>
</span>
<Switch
id="discover-public-channels"
checked={collect.discoverChannels}
onCheckedChange={collect.setDiscoverChannels}
aria-label="Perplexity로 추가 공개 채널 찾기"
/>
</label>
<Button
variant="outline"
size="sm"
className="mt-2.5 w-full"
onClick={collect.discover}
>
<Search />
<span>자동으로 찾아보기</span>
</Button>
</div>
)}
{confirmedIdentity?.origin === 'owner' && (
<>
<p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-[11px] leading-relaxed text-warning">
직접 입력으로 진행 중이라 수집이 열리지 않습니다. 동일 업소 검증을 통과한
가게만 채널을 긁을 수 있습니다 — 2단계에서 지도 검색으로 가게를 확인하시면
주소 붙여넣기와 자동 찾기가 모두 열립니다.
</p>
<Button variant="outline" className="w-full" onClick={() => goToStep('template')}>
<span>수집 없이 다음 단계로</span>
<ArrowRight />
</Button>
</>
)}
<Button variant="ghost" size="sm" className="w-full" onClick={() => goToStep('search')}>
가게를 다시 찾을게요
</Button>
</div>
)}
{stage === 'discovering' && (
<WizardWaiting
title="공개된 채널 URL 을 찾는 중입니다"
description={`${storeName}의 예약·지도·SNS 채널을 찾고 있어요`}
/>
)}
{stage === 'crawling' && (
<WizardWaiting
title="확정하신 채널에서 정보를 수집 중입니다"
description="운영 정보와 사진을 정돈하고 있어요"
/>
)}
{stage === 'channels' && (
<div className="space-y-3">
<div className="rounded-2xl border border-border bg-card p-5">
<ChannelConfirmPanel
links={collect.links}
confirmedCount={collect.confirmedCount}
confirmingId={collect.confirmingId}
onConfirm={collect.confirm}
/>
</div>
<ChannelUrlInput onAdd={collect.addLink} isBusy={collect.isAdding} />
<Button
variant="primary"
size="lg"
className="w-full"
onClick={collect.crawl}
disabled={collect.confirmedCount === 0}
>
<Search />
<span>
{collect.confirmedCount === 0
? '확정한 채널이 없습니다'
: `확정한 ${collect.confirmedCount}개 채널에서 수집하기`}
</span>
</Button>
</div>
)}
{stage === 'review' && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isSample ? (
<Badge variant="warning">예시 데이터 ({infoFields.length}개 항목)</Badge>
) : (
<Badge variant="success">수집 완료 ({infoFields.length}개 항목)</Badge>
)}
{unverified.length > 0 && (
<Badge variant="warning">확인 필요 {unverified.length}</Badge>
)}
</div>
<div className="flex items-center gap-1 rounded-lg bg-muted p-0.5 text-xs">
{(['info', 'photos'] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
'cursor-pointer rounded-md px-3 py-1 font-medium transition-all',
activeTab === tab
? 'bg-card font-semibold text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
)}
>
{tab === 'info' ? `기본정보 (${infoFields.length})` : `사진 (${photos.length})`}
</button>
))}
</div>
</div>
{isSample && (
<p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-[11px] leading-relaxed text-warning">
<strong>상호·주소는 2단계에서 확인하신 값</strong>이고, 나머지 줄과 사진은 아직
실제 수집 결과가 아니라 <strong>{config.name} 업종 예시</strong>입니다.
</p>
)}
{activeTab === 'info' ? (
<ul className="space-y-2">
{infoFields.map((field) => (
<FieldRow key={field.id} fieldId={field.id} />
))}
</ul>
) : (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{photos.map((photo) => (
<figure
key={photo.id}
className="group relative aspect-4/3 overflow-hidden rounded-xl border border-border bg-muted"
>
<img
src={photo.url}
alt={photo.title}
loading="lazy"
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
<figcaption className="absolute inset-x-0 bottom-0 truncate bg-gradient-to-t from-black/70 to-transparent p-2 text-[11px] text-white">
{photo.title}
</figcaption>
</figure>
))}
</div>
)}
<Button variant="primary" size="lg" className="w-full" onClick={() => goToStep('template')}>
<span>다음: 템플릿 고르기</span>
<ArrowRight />
</Button>
<label
htmlFor="rediscover-public-channels"
className="flex cursor-pointer items-center justify-between gap-3 rounded-xl border border-border bg-card p-3"
>
<span>
<span className="block text-xs font-semibold">추가 채널 URL도 다시 찾기</span>
<span className="block text-[10px] text-muted-foreground">
켜면 다음 수집에서 Perplexity 유료 검색을 1회 사용합니다.
</span>
</span>
<Switch
id="rediscover-public-channels"
checked={collect.discoverChannels}
onCheckedChange={collect.setDiscoverChannels}
aria-label="Perplexity로 추가 공개 채널 다시 찾기"
/>
</label>
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={collect.discover}
disabled={!canCollect}
>
<Search />
<span>채널 URL 다시 찾기</span>
</Button>
</div>
)}
</WizardStage>
<WizardFooter
hint={
stage === 'ready' ? (
'수집을 먼저 돌려야 다음으로 넘어갈 수 있습니다.'
) : stage === 'channels' ? (
'내 채널을 확정한 뒤 수집하면 다음으로 넘어갑니다.'
) : stage === 'review' && unverified.length > 0 ? (
<span className="flex items-center gap-1 text-warning">
<TriangleAlert className="size-3.5" />
확인 안 된 항목 {unverified.length}개는 사이트에 나가지 않습니다.
</span>
) : stage === 'review' ? (
'확인된 값만 사이트와 AI 검색 답변에 실립니다.'
) : (
'수집을 돌리면 찾은 값이 출처와 함께 여기에 나옵니다.'
)
}
onPrev={() => goToStep('search')}
onNext={() => goToStep('template')}
nextLabel="다음: 템플릿 선택"
/**
* ★ 수집 전에는 잠근다.
* 여기서 그냥 넘어가면 fact 도 사진도 없는 채로 템플릿을 고르고 발행까지 가서,
* 서버 게이트가 NO_UNIQUE_CONTENT 로 막는다 — 사장님은 세 화면을 지나온 뒤에야
* "처음부터 아무것도 없었다"는 사실을 알게 된다. 그 전에 막는 것이 맞다.
*/
nextDisabled={stage !== 'review'}
/>
</div>
);
}
/** 2단계에서 확인된 가게 — 3단계 내내 "무엇을 수집하는지"의 근거로 남는다. */
function IdentityCard() {
const confirmedIdentity = useBuilderStore((s) => s.confirmedIdentity);
if (!confirmedIdentity) {
return (
<div className="rounded-xl border border-warning/30 bg-warning/10 p-4 text-xs text-warning">
아직 가게를 확인하지 않았습니다. 2단계에서 내 가게를 먼저 골라주세요.
</div>
);
}
return (
<div className="rounded-2xl border border-border bg-card p-5">
<p className="text-sm font-bold">{confirmedIdentity.name}</p>
{confirmedIdentity.address && (
<p className="mt-1 flex items-start gap-1 text-xs text-muted-foreground">
<MapPin className="mt-px size-3 shrink-0" />
<span>{confirmedIdentity.address}</span>
</p>
)}
<div className="mt-2.5 flex items-center gap-1.5">
{confirmedIdentity.origin === 'external' ? (
<>
<Badge variant="outline" className="text-[10px]">
{confirmedIdentity.sourceLabel}
</Badge>
<Badge variant="success" className="text-[10px]">
동일 업소 확인됨
</Badge>
</>
) : (
<Badge variant="warning" className="text-[10px]">
직접 입력 · 동일 업소 미검증
</Badge>
)}
</div>
</div>
);
}
/**
* 수집된 값 한 줄 — 확인이 이 화면의 목적이므로 [맞아요]/[아니에요]가 줄 안에 있다.
*
* ★ 승인은 스토어의 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 (
<li
className={cn(
'rounded-xl border p-3 text-xs transition-colors',
needsCheck ? 'border-warning/40 bg-warning/5' : 'border-border bg-card',
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex flex-wrap items-center gap-1.5">
<span className="font-semibold">{field.label}</span>
{field.source && (
<Badge variant="outline" className="text-[10px]">
{field.source}
</Badge>
)}
{field.critical && (
<Badge variant="warning" className="text-[10px]">
예약 관련
</Badge>
)}
</div>
<p className="font-medium">{field.value}</p>
</div>
{needsCheck ? (
<div className="flex shrink-0 items-center gap-1">
<Button
size="sm"
variant="primary"
onClick={() => verifyField(field.id)}
disabled={isSaving}
>
{isSaving ? <Loader2 className="animate-spin" /> : <Check />}
<span>맞아요</span>
</Button>
<Button size="sm" variant="outline" onClick={openEditor} disabled={isSaving}>
<Pencil />
<span>아니에요</span>
</Button>
</div>
) : (
<Badge variant="success" className="shrink-0">
확인됨
</Badge>
)}
</div>
{isEditing && (
<div className="mt-2 flex items-center gap-1.5 border-t border-border pt-2">
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submit();
if (e.key === 'Escape') setIsEditing(false);
}}
placeholder={`${field.label}의 정확한 값`}
autoFocus
/>
<Button size="sm" variant="primary" onClick={submit} disabled={!draft.trim()}>
저장
</Button>
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)}>
취소
</Button>
</div>
)}
</li>
);
}