o2o-site-AEO/solution/frontend/src/features/onboarding/Step3DataReview.tsx
Mina Choi 238d4c25c4 [feat] solution: 네이버 플레이스를 검색 단계에서 찾는다 — 로그인은 수집 직전 한 번
사장님이 후보를 고른 뒤에도 "네이버 플레이스를 자동으로 찾지 못했습니다" 가 떴다.
찾을 수 있는데도 그랬다 — 자동 발견이 확정 경로(로그인 뒤)에만 있었고, 공개 검색은
상호·주소만 돌려줬다. 그리고 확정이 사업장 생성을 요구해서, 로그인 없이 시작하기로 한
위저드가 검색 직후부터 막혔다(자동 로그인이 그걸 가리고 있었다).

- 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 인증이 실패한다.
2026-09-03 16:37:12 +09:00

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>
);
}