사장님이 후보를 고른 뒤에도 "네이버 플레이스를 자동으로 찾지 못했습니다" 가 떴다.
찾을 수 있는데도 그랬다 — 자동 발견이 확정 경로(로그인 뒤)에만 있었고, 공개 검색은
상호·주소만 돌려줬다. 그리고 확정이 사업장 생성을 요구해서, 로그인 없이 시작하기로 한
위저드가 검색 직후부터 막혔다(자동 로그인이 그걸 가리고 있었다).
- place_service.search_places_public: 응답에 naver_place_url 을 싣는다. 넓은 검색어
한 페이지에서 못 찾은 후보는 그 후보만 겨냥해 다시 찾는다(상위 2건, 429 회피).
실측: 12개 상호 전부 발견. 전에는 4개 중 2개
- naver_place_lookup._region_hint: 주소에서 시·군·구까지만 뽑아 검색을 좁힌다.
첫 토막('경기도')만 쓰면 **다른 동네 동명 업소**가 잡히고, 그 id 로 검증하면 남의
가게가 이 사이트의 기준 정보가 된다 — 실측으로 한 번 겪었다
- ttl_cache(신규) + 공개 검색 10분 캐시: 검색 1회가 네이버를 최대 3번 긁는데 인증이
없어 새로고침만으로 나간다. 실측 1.38s → 0.005s. **빈 결과는 캐시하지 않는다** —
일시적 0건을 굳히면 사장님이 10분간 막힌다
- 확정은 서버를 부르지 않는다(usePlaceSearch). 화면에만 남기고, 수집 직전 로그인 뒤
ensureServerPlace 가 생성 → 검증을 한 번에 한다. 나눠 두면 "사업장은 생겼는데 검증이
빠진" 상태가 생기고 수집이 PLACE_NOT_VERIFIED 로 조용히 거절된다
- Step3: 수집 버튼이 로그인 모달을 연다(/login 으로 튕기지 않는다 — 위저드 상태가
주소창에 없어 돌아올 길이 없다). 로그인하면 이어서 돈다
- Step2: 후보 카드에 '네이버 플레이스 찾음' 배지. 붙여넣기 칸은 접는다 —
펼쳐 두면 시도도 전에 실패한 것으로 읽힌다. 뒤로 오면 처음 화면으로
- ChannelUrlInput: [추가] → [이 주소로 가져오기]. 로그인 전에는 addLink 가 placeId 가
없어 **조용히 return** 해서 입력칸만 비워졌다(useChannelLinks.ts:37)
- LoginPage: admin/1234 기본값 제거. 배포 번들에 그대로 나가 있었다
- 기본 발행 호스트를 localhost 로(compose 4곳 · site_payload.DEFAULT_HOST · .env.example).
운영 도메인을 기본값으로 두면 .env 를 안 채운 로컬 빌드가 조용히 운영 주소를 번들에
굽는다 — 실측: 로컬에서 만든 링크가 킹서버로 갔다. localhost 는 http 로 조립한다
검증: tsc·eslint·vite build 통과. 브라우저로 전 구간 확인(검색 → 확정 → 로그인 →
자동 발견 → 검증 → 수집 fact 27건·사진 10장·메뉴 23건 → 사진 분석).
백엔드 테스트는 이 워크트리에서 못 돌렸다 — config.test.toml 이 없어 DB 인증이 실패한다.
662 lines
28 KiB
TypeScript
662 lines
28 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 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<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 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<typeof confirmedIdentity>(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 (
|
|
<div className="flex flex-1 items-center justify-center bg-muted/30 px-4 py-10">
|
|
<SignInForm
|
|
submitLabel="로그인하고 정보 가져오기"
|
|
onSignedIn={() => {
|
|
setSigningIn(false);
|
|
const resume = pendingAfterSignIn.current;
|
|
pendingAfterSignIn.current = null;
|
|
if (resume) void beginCollect(resume);
|
|
}}
|
|
header={
|
|
<div className="space-y-1 text-center">
|
|
<h1 className="text-sm font-bold">정보를 가져오려면 로그인해 주세요</h1>
|
|
<p className="text-xs leading-relaxed text-muted-foreground">
|
|
고르신 가게는 그대로 있습니다. 수집한 정보를 사장님 계정에 담아 두기 위해
|
|
여기서 한 번만 받습니다.
|
|
</p>
|
|
</div>
|
|
}
|
|
footer={
|
|
<button
|
|
type="button"
|
|
onClick={() => setSigningIn(false)}
|
|
className="w-full text-center text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
|
>
|
|
나중에 하기
|
|
</button>
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-1 flex-col justify-between bg-muted/30">
|
|
<WizardStage
|
|
current={2}
|
|
wide={stage === 'review' || stage === 'channels'}
|
|
title={stage === 'ready' && pendingUrl ? '네이버 플레이스를 찾았습니다' : STAGE_TITLE[stage]}
|
|
description={
|
|
stage === 'ready' && pendingUrl
|
|
? '이 가게에서 영업시간·전화·편의시설과 사진을 그대로 가져옵니다.'
|
|
: 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>
|
|
)}
|
|
|
|
{/* ★ 이미 찾아 둔 주소가 있으면 붙여넣기 안내를 접는다. 펼쳐 두면 사장님은
|
|
"또 못 찾았구나" 로 읽고 지도를 열러 간다 — 찾아 놓고 그러는 건 최악이다. */}
|
|
{canCollect && pendingUrl && !urlBoxOpen ? (
|
|
<div className="rounded-2xl border border-success/40 bg-success/5 p-4">
|
|
<p className="flex items-center gap-1.5 text-xs font-bold">
|
|
<Check className="size-3.5 text-success" />
|
|
네이버 플레이스를 찾았습니다
|
|
</p>
|
|
<p className="mt-1 truncate text-[11px] text-muted-foreground">{pendingUrl}</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => setUrlBoxOpen(true)}
|
|
className="mt-2 cursor-pointer text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
|
>
|
|
이 가게가 아닌가요? — 주소 직접 넣기
|
|
</button>
|
|
</div>
|
|
) : (
|
|
canCollect && (
|
|
<ChannelUrlInput
|
|
onAdd={addUrl}
|
|
isBusy={collect.isAdding || isUploading}
|
|
searchQuery={[confirmedIdentity?.name, confirmedIdentity?.address]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
/>
|
|
)
|
|
)}
|
|
|
|
{canCollect && (
|
|
<Button
|
|
variant="primary"
|
|
size="lg"
|
|
className="w-full"
|
|
onClick={() => void beginCollect()}
|
|
disabled={isUploading || !hasSource}
|
|
>
|
|
<Search />
|
|
<span>
|
|
{!hasSource
|
|
? '주소를 먼저 붙여넣어 주세요'
|
|
: collect.confirmedCount > 0
|
|
? `등록한 ${collect.confirmedCount}개 주소에서 정보 가져오기`
|
|
: isUploading
|
|
? '내 가게를 찾는 중입니다...'
|
|
: '내 가게 정보 자동으로 가져오기'}
|
|
</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}
|
|
searchQuery={[confirmedIdentity?.name, confirmedIdentity?.address].filter(Boolean).join(' ')}
|
|
/>
|
|
|
|
<Button
|
|
variant="primary"
|
|
size="lg"
|
|
className="w-full"
|
|
onClick={() => void beginCollect()}
|
|
disabled={isUploading || !hasSource}
|
|
>
|
|
<Search />
|
|
<span>
|
|
{!hasSource
|
|
? '확정한 채널이 없습니다'
|
|
: 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>
|
|
);
|
|
}
|