업종을 먼저 고르게 하면 경계에서 멈춘다("우리는 카페인가 음식점인가"). 그런데 상호명은
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 통과
515 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|