o2o-site-AEO/solution/frontend/src/features/builder/RightTabsPanel.tsx
Mina Choi 23162783f9 [feat] solution/frontend: 붙여넣기 아이템 셋 — 가요 다방·오늘의 한 장·반나절 산책
gunsan_365_story_db.xlsx 365행을 뜯어 보니 고유 주제는 52개이고 한 주제가 7회씩 돈다
(접미사 10개만 회전). 날짜 축으로 카드를 늘어놓으면 이레마다 같은 카드가 돌아온다 —
그래서 묶는 축을 주제로 잡고 날짜는 일력 한 장에만 썼다. 같은 파일 DB_Guide 가 가사·원문
전재를 금지해 가요 스키마에 lyrics 필드를 아예 두지 않았다. 없는 칸은 채울 수 없다.

- canvas/dataSpec.ts: 스키마·예시·프롬프트 단일 표. 레지스트리와 같은 결이라 한 줄을 더하면
  캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다. 파싱은 절대 throw 하지 않는다 — 편집 중인 JSON 은
  늘 깨져 있고 그때 캔버스가 죽으면 고칠 방법이 없다
- shared/types/builder: SectionItem.data 는 파싱본이 아니라 원문 문자열이다. 파싱본만 들면
  JSON 이 깨진 순간 사장님이 쓴 걸 잃는다
- variants/{songs,daily,course}: 턴테이블·일력·승차권. 카드 격자를 쓰지 않고 전부 가로로 넘긴다
- RightTabsPanel: JSON 칸 + 프롬프트 복사/보기·예시 넣기·줄맞춤. 프롬프트에 상호와 주소에서 뽑은
  시·군·구를 박아 내보낸다 — 빈칸을 남기면 못 채우고 그대로 보내고 모델이 엉뚱한 곳을 지어낸다
- dataSpec.locate: JSON.parse 오류가 두 형식이다. position 형만 보면 조각 인용 형에서 위치를
  통째로 잃는다 — 조각을 원문에서 되찾아 센다
- SECTION_DATA_MAX_CHARS: 테마 상한 64KB(site_service._THEME_MAX_BYTES)를 세 섹션이 함께
  넘길 수 있고 거절은 발행 직전에야 드러난다. 화면에서 먼저 끊는다
- industryData: 네 업종에 꺼진 채로 넣는다. 내용 없이 켜져 있으면 발행본에 빈 섹션이 나간다
- index.html: Gugi·Gowun Batang·Nanum Pen Script. 없으면 고딕으로 떨어져 감성이 사라진다

발행 사이트(solution/site)는 아직 variantId 도 data 도 읽지 않는다 — 지금은 빌더 캔버스 전용이다.

tsc·eslint·vite build 통과(frontend·admin), 세 배리에이션 SSR 렌더 확인, 파서 경계 12건 확인
2026-09-01 17:58:58 +09:00

639 lines
26 KiB
TypeScript

import {useState} from 'react';
import {
AlertTriangle,
Check,
ClipboardCopy,
Image as ImageIcon,
Info,
FileText,
Loader2,
MessageCircleQuestion,
Pencil,
Plus,
Shapes,
Sparkles,
Trash2,
Wand2,
X,
} from 'lucide-react';
import type {InfoField} from '@o2o/shared';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {PasteFactsPanel, RecollectPanel} from '@/features/onboarding';
import {cn} from '@/lib/utils';
import {useBuilderStore, useUnverifiedFields, type RightTab} from '@/stores/builder';
import {INDUSTRY_CONFIGS} from '@/data/industryData';
import {FaqPanel} from './FaqPanel';
import {SectionDesignPanel} from './SectionDesignPanel';
import {resolveVariant} from './canvas/registry';
import {
buildPrompt,
dataSpecFor,
parseSectionData,
SECTION_DATA_MAX_CHARS,
} from './canvas/dataSpec';
const TABS: {id: RightTab; label: string; icon: typeof Info}[] = [
{id: 'content', label: '콘텐츠', icon: FileText},
{id: 'design', label: '디자인', icon: Shapes},
{id: 'photos', label: '사진', icon: ImageIcon},
{id: 'info', label: '정보', icon: Info},
{id: 'faq', label: 'FAQ', icon: MessageCircleQuestion},
{id: 'verify', label: '확인', icon: AlertTriangle},
];
export function RightTabsPanel() {
const activeTab = useBuilderStore((s) => s.rightTab);
const setRightTab = useBuilderStore((s) => s.setRightTab);
const unverified = useUnverifiedFields();
return (
<div className="flex h-full w-full shrink-0 select-none flex-col overflow-hidden border-l border-border bg-card lg:w-[290px]">
<div className="flex items-center gap-1 border-b border-border bg-muted/50 p-1">
{TABS.map(({id, label, icon: Icon}) => (
<button
key={id}
type="button"
onClick={() => setRightTab(id)}
aria-pressed={activeTab === id}
className={cn(
'flex min-w-0 flex-1 cursor-pointer items-center justify-center gap-0.5 rounded-md px-0.5 py-2 text-[11px] font-semibold transition-colors',
activeTab === id
? 'border border-border bg-card text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
<Icon className={cn('size-3.5', id === 'verify' && unverified.length > 0 && 'text-warning')} />
<span className="truncate">{label}</span>
{id === 'verify' && unverified.length > 0 && (
<span className="ml-0.5 rounded-full bg-warning px-1.5 font-mono text-[10px] font-bold leading-tight text-white">
{unverified.length}
</span>
)}
</button>
))}
</div>
{activeTab === 'design' && <SectionDesignPanel />}
{activeTab === 'content' && <ContentTab />}
{activeTab === 'photos' && <PhotosTab />}
{activeTab === 'info' && <InfoTab />}
{activeTab === 'faq' && <FaqPanel />}
{activeTab === 'verify' && <VerifyTab fields={unverified} />}
</div>
);
}
function ContentTab() {
const industry = useBuilderStore((s) => s.industry);
const sections = useBuilderStore((s) => s.sections);
const selectedSectionId = useBuilderStore((s) => s.selectedSectionId);
const updateSectionContent = useBuilderStore((s) => s.updateSectionContent);
const storeName = useBuilderStore((s) => s.storeName);
const setStoreName = useBuilderStore((s) => s.setStoreName);
const section = sections.find((item) => item.id === selectedSectionId);
if (!section) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-muted-foreground">
<FileText className="size-5" />
<p className="text-xs font-semibold text-foreground">편집할 섹션을 선택하세요</p>
<p className="text-[11px]">왼쪽 섹션 목록이나 미리보기에서 선택할 수 있습니다.</p>
</div>
);
}
const update = (patch: {name?: string; description?: string; body?: string}) =>
updateSectionContent(section.id, {
name: patch.name ?? section.name,
description: patch.description ?? section.description,
body: patch.body ?? section.body,
});
const variantId = resolveVariant(section, industry)?.id ?? '';
const supportsTitle = section.type !== 'local';
const supportsDescription = !(
variantId.startsWith('rooms.') || variantId.startsWith('rules.') ||
variantId.startsWith('local.') || variantId === 'menu.price-table' ||
variantId === 'space.list' || variantId === 'programs.table' ||
variantId === 'exhibition.notice'
);
const supportsBody =
variantId.startsWith('intro.') || variantId.startsWith('booking.') ||
variantId === 'inquiry.cta' || variantId === 'exhibition.notice';
return (
<div className="flex-1 space-y-4 overflow-y-auto p-3.5">
<div className="border-b border-border pb-2">
<p className="text-xs font-bold">{section.name} 콘텐츠</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">입력 즉시 미리보기에 반영됩니다.</p>
</div>
{supportsTitle && section.type === 'hero' ? (
<label className="block space-y-1">
<span className="text-xs font-semibold">대표 제목</span>
<Input value={storeName} onChange={(event) => setStoreName(event.target.value)} className="text-xs" />
</label>
) : supportsTitle ? (
<label className="block space-y-1">
<span className="text-xs font-semibold">섹션 제목</span>
<Input value={section.name} onChange={(event) => update({name: event.target.value})} className="text-xs" />
</label>
) : null}
{supportsDescription && <label className="block space-y-1">
<span className="text-xs font-semibold">한 줄 설명</span>
<Input
value={section.description ?? ''}
onChange={(event) => update({description: event.target.value})}
placeholder="제목 아래에 표시할 설명"
className="text-xs"
/>
</label>}
{supportsBody && <label className="block space-y-1">
<span className="text-xs font-semibold">본문</span>
<textarea
value={section.body ?? ''}
onChange={(event) => update({body: event.target.value})}
placeholder="섹션에 표시할 본문을 입력하세요"
rows={7}
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-xs leading-relaxed outline-none focus:border-ring focus:ring-2 focus:ring-ring/30"
/>
</label>}
{dataSpecFor(section.type) && <SectionDataPanel sectionId={section.id} sectionType={section.type} />}
{!supportsTitle && !supportsDescription && !supportsBody && !dataSpecFor(section.type) && (
<p className="rounded-md border border-dashed border-border p-3 text-center text-[11px] text-muted-foreground">
이 레이아웃에는 직접 입력하는 문구가 없습니다. 수집된 데이터만 표시합니다.
</p>
)}
<p className="rounded-md bg-muted/50 p-2.5 text-[10px] leading-relaxed text-muted-foreground">
목록형 데이터는 해당 관리 탭에서 편집하고, 이곳에서는 섹션의 제목·설명·소개 본문을 편집합니다.
</p>
</div>
);
}
/**
* 붙여넣기 아이템의 JSON 입력.
*
* ★ 원문을 그대로 저장한다. 깨진 JSON 도 담아 두고, 왜 깨졌는지만 아래에 말한다 —
* 저장을 막으면 사장님은 고칠 기회 없이 쓰던 걸 잃는다.
*/
function SectionDataPanel({sectionId, sectionType}: {sectionId: string; sectionType: string}) {
const spec = dataSpecFor(sectionType);
const sections = useBuilderStore((s) => s.sections);
const updateSectionData = useBuilderStore((s) => s.updateSectionData);
const storeName = useBuilderStore((s) => s.storeName);
const location = useBuilderStore((s) => s.location);
const industry = useBuilderStore((s) => s.industry);
const [copied, setCopied] = useState(false);
const [showPrompt, setShowPrompt] = useState(false);
const section = sections.find((item) => item.id === sectionId);
if (!spec || !section) return null;
const raw = section.data ?? '';
const parsed = parseSectionData(sectionType, raw);
const tooLong = raw.length > SECTION_DATA_MAX_CHARS;
// 상호·주소가 이미 박혀 있는 프롬프트. 사장님이 빈칸을 채울 일이 없어야 한다.
const promptText = buildPrompt(spec, {
storeName,
location,
industryLabel: INDUSTRY_CONFIGS[industry].name,
});
const copyPrompt = async () => {
const text = promptText;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1600);
} catch {
// 클립보드가 막힌 브라우저 — 프롬프트를 화면에 띄워 직접 긁을 수 있게 한다.
window.prompt('아래 프롬프트를 복사해 ChatGPT 에 붙여넣으세요', text);
}
};
/** 유효한 JSON 만 2칸 들여쓰기로 다시 쓴다. 깨져 있으면 손대지 않는다. */
const tidy = () => {
try {
updateSectionData(sectionId, JSON.stringify(JSON.parse(raw), null, 2));
} catch {
// 그대로 둔다 — 아래 오류 줄이 이미 어디가 깨졌는지 말하고 있다.
}
};
return (
<div className="space-y-2 rounded-md border border-border bg-muted/30 p-2.5">
<div className="flex items-center gap-1.5">
<Sparkles className="size-3.5 text-primary" />
<span className="text-xs font-bold">{spec.label} 내용 (JSON)</span>
</div>
<p className="text-[10px] leading-relaxed text-muted-foreground">
프롬프트를 복사해 ChatGPT·Claude 에 그대로 붙여넣으면 JSON 을 줍니다. 받은 JSON 을 아래에 붙여넣으세요.
</p>
<div className="flex flex-wrap gap-1.5">
<Button type="button" size="sm" variant="primary" className="h-7 gap-1 text-[11px]" onClick={copyPrompt}>
<ClipboardCopy className="size-3" />
{copied ? '복사됨' : '프롬프트 복사'}
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 gap-1 text-[11px]"
onClick={() => updateSectionData(sectionId, spec.sample)}
>
<Wand2 className="size-3" />
예시 넣기
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-[11px]"
onClick={() => setShowPrompt((v) => !v)}
>
{showPrompt ? '프롬프트 접기' : '프롬프트 보기'}
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-[11px]"
onClick={tidy}
disabled={!raw.trim() || Boolean(parsed.error)}
>
줄맞춤
</Button>
</div>
{showPrompt && (
<div className="space-y-1">
<p className="text-[10px] text-muted-foreground">
아래 내용이 그대로 복사됩니다. 상호와 지역은 이미 채워져 있습니다.
</p>
<textarea
readOnly
value={promptText}
rows={12}
spellCheck={false}
onFocus={(event) => event.currentTarget.select()}
className="w-full resize-y rounded-md border border-border bg-background/60 px-2.5 py-2 font-mono text-[10px] leading-relaxed text-muted-foreground outline-none"
/>
</div>
)}
<textarea
value={raw}
onChange={(event) => updateSectionData(sectionId, event.target.value)}
placeholder={`{ "kind": "${spec.kind}", "items": [ ... ] }`}
rows={10}
spellCheck={false}
className="w-full resize-y rounded-md border border-input bg-background px-2.5 py-2 font-mono text-[11px] leading-relaxed outline-none focus:border-ring focus:ring-2 focus:ring-ring/30"
/>
{parsed.error ? (
<p className="flex items-start gap-1.5 text-[10px] leading-relaxed text-destructive">
<AlertTriangle className="mt-px size-3 shrink-0" />
{parsed.error}
</p>
) : raw.trim() ? (
<p className="text-[10px] text-muted-foreground">
{spec.label} {parsed.items.length}개 · 출처 {parsed.sourced}개
{parsed.unverified > 0 && ` · 확인필요 ${parsed.unverified}개`}
</p>
) : null}
{parsed.kindMismatch && (
<p className="flex items-start gap-1.5 text-[10px] leading-relaxed text-warning">
<AlertTriangle className="mt-px size-3 shrink-0" />
이 JSON 은 &quot;{parsed.kindMismatch}&quot; 아이템 것입니다. {spec.label} 섹션에는 kind 가 &quot;{spec.kind}&quot; 인
JSON 을 넣어 주세요.
</p>
)}
{tooLong && (
<p className="flex items-start gap-1.5 text-[10px] leading-relaxed text-destructive">
<AlertTriangle className="mt-px size-3 shrink-0" />
너무 깁니다({raw.length.toLocaleString()}자). {SECTION_DATA_MAX_CHARS.toLocaleString()}자 아래로 줄여
주세요 — 이 상태로는 디자인 설정이 서버에 저장되지 않습니다.
</p>
)}
</div>
);
}
function PhotosTab() {
const photos = useBuilderStore((s) => s.photos);
const setPrimaryPhoto = useBuilderStore((s) => s.setPrimaryPhoto);
const togglePhotoVisibility = useBuilderStore((s) => s.togglePhotoVisibility);
const visibleCount = photos.filter((photo) => photo.isVisible !== false).length;
return (
<div className="flex-1 space-y-3 overflow-y-auto p-3.5">
<div className="flex items-center justify-between border-b border-border pb-2">
<span className="text-xs font-bold">업장 이미지 에셋 ({photos.length})</span>
<span className="text-[10px] text-muted-foreground">갤러리 {visibleCount}장</span>
</div>
<div className="grid grid-cols-2 gap-2.5">
{photos.map((photo) => (
<div
key={photo.id}
className={cn(
'group relative aspect-4/3 overflow-hidden rounded-md border transition-all',
photo.isVisible === false && 'opacity-45 grayscale',
photo.isPrimary
? 'border-2 border-primary ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/40',
)}
>
<button type="button" onClick={() => setPrimaryPhoto(photo.id)} className="size-full cursor-pointer">
<img
src={photo.url}
alt={photo.title}
loading="lazy"
referrerPolicy="no-referrer"
className="size-full object-cover"
/>
</button>
<span className="absolute inset-x-0 bottom-0 block truncate bg-gradient-to-t from-black/80 to-transparent p-1.5 text-left text-[10px] font-medium text-white">
{photo.title}
</span>
{photo.isPrimary ? (
<span className="absolute left-1 top-1 rounded bg-primary px-1.5 py-0.5 text-[9px] font-bold text-primary-foreground">
대표
</span>
) : (
<span className="absolute left-1 top-1 rounded bg-black/40 px-1 py-0.5 text-[9px] text-white opacity-0 transition-opacity group-hover:opacity-100">
대표 지정
</span>
)}
<label className="absolute right-1 top-1 flex cursor-pointer items-center gap-1 rounded bg-black/65 px-1.5 py-1 text-[9px] font-semibold text-white">
<input
type="checkbox"
checked={photo.isVisible !== false}
onChange={() => togglePhotoVisibility(photo.id)}
className="size-3 accent-white"
/>
표시
</label>
</div>
))}
</div>
<p className="rounded-md border border-dashed border-border bg-muted/40 p-2.5 text-center text-xs text-muted-foreground">
<span className="block font-medium">새 사진 추가</span>
<span className="text-[10px]">
업로드한 사진은 출처가 <code className="font-mono">owner</code>로 기록됩니다
</span>
</p>
</div>
);
}
function InfoTab() {
const infoFields = useBuilderStore((s) => s.infoFields);
const updateField = useBuilderStore((s) => s.updateField);
const updateFieldLabel = useBuilderStore((s) => s.updateFieldLabel);
const addInfoField = useBuilderStore((s) => s.addInfoField);
const removeInfoField = useBuilderStore((s) => s.removeInfoField);
const [newLabel, setNewLabel] = useState('');
const [newValue, setNewValue] = useState('');
const placeId = useBuilderStore((s) => s.placeId);
// ★ 셀렉터 안에서 새 배열을 만들지 않는다(스토어 상단 주의사항) — state 의 배열을 그대로 받아
// 여기서 includes 로 좁힌다. 셀렉터가 매번 새 배열을 돌려주면 무한 렌더로 죽는다.
const savingFieldIds = useBuilderStore((s) => s.savingFieldIds);
return (
<div className="flex-1 space-y-3 overflow-y-auto p-3.5">
<div className="flex items-center justify-between border-b border-border pb-2">
<span className="text-xs font-bold">기본 정보 수정</span>
{/* 데모는 이 화면 안에서만 도는 값이고, 실사업장은 고치는 즉시 서버로 올라간다. */}
<span className="text-[10px] text-muted-foreground">
{placeId ? '고치면 서버에 저장' : '캔버스 즉시 반영'}
</span>
</div>
<div className="space-y-3">
{infoFields.map((field) => {
const isHidden = field.requiresVerification && !field.isVerified;
const isSaving = savingFieldIds.includes(field.id);
return (
<div key={field.id} className="space-y-1">
<div className="flex items-center justify-between gap-2">
{field.id.startsWith('custom_') ? (
<Input
aria-label="항목 이름"
value={field.label}
onChange={(event) => updateFieldLabel(field.id, event.target.value)}
className="h-7 flex-1 text-xs font-semibold"
/>
) : <label htmlFor={`field-${field.id}`} className="text-xs font-semibold">{field.label}</label>}
{isSaving ? (
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Loader2 className="size-2.5 animate-spin" />
저장 중
</span>
) : isHidden ? (
<Badge variant="warning" className="text-[10px]">
확인 대기 (숨김)
</Badge>
) : (
<span className="text-[10px] text-muted-foreground">노출 중</span>
)}
</div>
<Input
id={`field-${field.id}`}
value={field.value}
onChange={(e) => updateField(field.id, e.target.value)}
className="text-xs"
/>
{field.id.startsWith('custom_') && (
<button
type="button"
onClick={() => removeInfoField(field.id)}
className="flex items-center gap-1 text-[10px] text-destructive hover:underline"
>
<Trash2 className="size-3" /> 항목 삭제
</button>
)}
</div>
);
})}
</div>
<div className="space-y-2 rounded-lg border border-dashed border-border bg-muted/30 p-3">
<div className="flex items-center gap-1.5 text-xs font-bold"><Plus className="size-3.5" /> 새 정보 항목</div>
<Input value={newLabel} onChange={(event) => setNewLabel(event.target.value)} placeholder="항목 이름 (예: 반려동물 동반)" className="text-xs" />
<Input value={newValue} onChange={(event) => setNewValue(event.target.value)} placeholder="내용" className="text-xs" />
<Button
size="sm"
className="w-full"
disabled={!newLabel.trim() || !newValue.trim()}
onClick={() => {
addInfoField(newLabel.trim(), newValue.trim());
setNewLabel('');
setNewValue('');
}}
>
<Plus /> 항목 추가
</Button>
</div>
<p className="text-[11px] leading-relaxed text-muted-foreground">
여기서 직접 고친 값은 <strong className="text-foreground">사장님이 확정한 값</strong>으로
기록되어, 이후 자동 수집이 덮어쓰지 않습니다.
</p>
{/* ★ 재수집을 [정보] 탭 아래에 둔다. 에디터에서 "이 값이 옛날 값인데"를 깨닫는 자리가
바로 여기라, 다시 가져오는 버튼도 같은 자리에 있어야 찾는다.
데모(placeId 없음)에서는 패널이 스스로 사라진다. */}
<RecollectPanel placeId={placeId} />
<PasteFactsPanel placeId={placeId} />
</div>
);
}
function VerifyTab({fields}: {fields: InfoField[]}) {
const verifyField = useBuilderStore((s) => s.verifyField);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState('');
const startEdit = (field: InfoField) => {
setEditingId(field.id);
setDraft(field.value);
};
const saveEdit = (fieldId: string) => {
const trimmed = draft.trim();
if (trimmed) verifyField(fieldId, trimmed);
setEditingId(null);
setDraft('');
};
return (
<div className="flex-1 space-y-3 overflow-y-auto p-3.5">
<div className="flex items-center justify-between border-b border-border pb-2">
<span className="text-xs font-bold">확인 필요 항목 ({fields.length})</span>
<span className="text-[10px] font-semibold text-warning">승인해야 사이트에 노출</span>
</div>
{fields.length === 0 ? (
<div className="my-4 space-y-2 rounded-lg border border-success/30 bg-success/8 p-6 text-center">
<span className="mx-auto flex size-8 items-center justify-center rounded-full bg-success/15 text-success">
<Check className="size-4" />
</span>
<h3 className="text-xs font-bold text-success">모든 정보가 확인되었습니다</h3>
<p className="text-[11px] text-success/90">
확인된 항목만 사이트와 구조화 데이터에 나갑니다.
</p>
</div>
) : (
<div className="space-y-3">
<p className="text-[11px] leading-relaxed text-muted-foreground">
공개 채널에서 자동 수집한 값입니다. 맞으면{' '}
<strong className="text-foreground">[맞아요]</strong>, 틀리면{' '}
<strong className="text-foreground">[아니에요]</strong>로 고쳐주세요. 확인 전에는{' '}
<strong className="text-foreground">사이트에 나가지 않습니다.</strong>
</p>
{fields.map((field) => {
const isEditing = editingId === field.id;
return (
<div
key={field.id}
className="space-y-2 rounded-lg border border-warning/40 bg-warning/8 p-3 text-xs"
>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1 font-bold">
<AlertTriangle className="size-3.5 text-warning" />
{field.label}
</span>
{field.source && (
<span className="shrink-0 text-[10px] text-muted-foreground">
{field.source}
</span>
)}
</div>
{!isEditing ? (
<>
<p className="break-words rounded border border-warning/30 bg-card p-2 font-medium">
{field.value}
</p>
<div className="flex items-center gap-1.5 pt-1">
<Button
variant="primary"
size="sm"
className="flex-1"
onClick={() => verifyField(field.id)}
>
<Check />
<span>맞아요</span>
</Button>
<Button
variant="secondary"
size="sm"
className="flex-1"
onClick={() => startEdit(field)}
>
<Pencil />
<span>아니에요</span>
</Button>
</div>
</>
) : (
<div className="space-y-2 pt-1">
<label
htmlFor={`verify-${field.id}`}
className="block text-[11px] font-semibold"
>
정확한 정보를 입력해주세요
</label>
<Input
id={`verify-${field.id}`}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={`${field.label} 정보 입력`}
autoFocus
className="text-xs"
onKeyDown={(event) => {
if (event.key === 'Enter') saveEdit(field.id);
if (event.key === 'Escape') setEditingId(null);
}}
/>
<div className="flex items-center gap-1.5">
<Button
variant="primary"
size="sm"
className="flex-1"
onClick={() => saveEdit(field.id)}
disabled={!draft.trim()}
>
반영하고 승인
</Button>
<Button
variant="ghost"
size="icon"
aria-label="수정 취소"
onClick={() => setEditingId(null)}
>
<X />
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}