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 (
{TABS.map(({id, label, icon: Icon}) => (
))}
{activeTab === 'design' &&
}
{activeTab === 'content' &&
}
{activeTab === 'photos' &&
}
{activeTab === 'info' &&
}
{activeTab === 'faq' &&
}
{activeTab === 'verify' &&
}
);
}
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 (
편집할 섹션을 선택하세요
왼쪽 섹션 목록이나 미리보기에서 선택할 수 있습니다.
);
}
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 (
{section.name} 콘텐츠
입력 즉시 미리보기에 반영됩니다.
{supportsTitle && section.type === 'hero' ? (
) : supportsTitle ? (
) : null}
{supportsDescription &&
}
{supportsBody &&
}
{dataSpecFor(section.type) &&
}
{!supportsTitle && !supportsDescription && !supportsBody && !dataSpecFor(section.type) && (
이 레이아웃에는 직접 입력하는 문구가 없습니다. 수집된 데이터만 표시합니다.
)}
목록형 데이터는 해당 관리 탭에서 편집하고, 이곳에서는 섹션의 제목·설명·소개 본문을 편집합니다.
);
}
/**
* 붙여넣기 아이템의 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 (
{spec.label} 내용 (JSON)
프롬프트를 복사해 ChatGPT·Claude 에 그대로 붙여넣으면 JSON 을 줍니다. 받은 JSON 을 아래에 붙여넣으세요.
{showPrompt && (
아래 내용이 그대로 복사됩니다. 상호와 지역은 이미 채워져 있습니다.
)}
);
}
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 (
업장 이미지 에셋 ({photos.length})
갤러리 {visibleCount}장
{photos.map((photo) => (
{photo.title}
{photo.isPrimary ? (
대표
) : (
대표 지정
)}
))}
새 사진 추가
업로드한 사진은 출처가 owner로 기록됩니다
);
}
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 (
기본 정보 수정
{/* 데모는 이 화면 안에서만 도는 값이고, 실사업장은 고치는 즉시 서버로 올라간다. */}
{placeId ? '고치면 서버에 저장' : '캔버스 즉시 반영'}
{infoFields.map((field) => {
const isHidden = field.requiresVerification && !field.isVerified;
const isSaving = savingFieldIds.includes(field.id);
return (
);
})}
여기서 직접 고친 값은 사장님이 확정한 값으로
기록되어, 이후 자동 수집이 덮어쓰지 않습니다.
{/* ★ 재수집을 [정보] 탭 아래에 둔다. 에디터에서 "이 값이 옛날 값인데"를 깨닫는 자리가
바로 여기라, 다시 가져오는 버튼도 같은 자리에 있어야 찾는다.
데모(placeId 없음)에서는 패널이 스스로 사라진다. */}
);
}
function VerifyTab({fields}: {fields: InfoField[]}) {
const verifyField = useBuilderStore((s) => s.verifyField);
const [editingId, setEditingId] = useState(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 (
확인 필요 항목 ({fields.length})
승인해야 사이트에 노출
{fields.length === 0 ? (
모든 정보가 확인되었습니다
확인된 항목만 사이트와 구조화 데이터에 나갑니다.
) : (
공개 채널에서 자동 수집한 값입니다. 맞으면{' '}
[맞아요], 틀리면{' '}
[아니에요]로 고쳐주세요. 확인 전에는{' '}
사이트에 나가지 않습니다.
{fields.map((field) => {
const isEditing = editingId === field.id;
return (
{field.label}
{field.source && (
{field.source}
)}
{!isEditing ? (
<>
{field.value}
>
) : (
)}
);
})}
)}
);
}