From 23162783f9efa41eba37831b8dcb2bf62603866e Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 1 Sep 2026 17:58:58 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[feat]=20solution/frontend:=20=EB=B6=99?= =?UTF-8?q?=EC=97=AC=EB=84=A3=EA=B8=B0=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EC=85=8B=20=E2=80=94=20=EA=B0=80=EC=9A=94=20=EB=8B=A4=EB=B0=A9?= =?UTF-8?q?=C2=B7=EC=98=A4=EB=8A=98=EC=9D=98=20=ED=95=9C=20=EC=9E=A5=C2=B7?= =?UTF-8?q?=EB=B0=98=EB=82=98=EC=A0=88=20=EC=82=B0=EC=B1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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건 확인 --- docs/DEVLOG.md | 34 ++ solution/frontend/index.html | 5 + solution/frontend/src/data/industryData.ts | 20 + .../src/features/builder/RightTabsPanel.tsx | 166 ++++++- .../src/features/builder/canvas/dataSpec.ts | 419 ++++++++++++++++++ .../src/features/builder/canvas/registry.ts | 38 ++ .../canvas/variants/course/CourseTickets.tsx | 172 +++++++ .../canvas/variants/daily/DailyCalendar.tsx | 162 +++++++ .../builder/canvas/variants/retro/common.tsx | 153 +++++++ .../builder/canvas/variants/retro/retro.css | 69 +++ .../canvas/variants/songs/SongsTurntable.tsx | 160 +++++++ .../frontend/src/features/dev/mockProps.ts | 6 +- .../src/features/publish/siteTheme.ts | 3 + solution/frontend/src/stores/builder.ts | 12 + solution/shared/src/types/builder.ts | 7 + 15 files changed, 1424 insertions(+), 2 deletions(-) create mode 100644 solution/frontend/src/features/builder/canvas/dataSpec.ts create mode 100644 solution/frontend/src/features/builder/canvas/variants/course/CourseTickets.tsx create mode 100644 solution/frontend/src/features/builder/canvas/variants/daily/DailyCalendar.tsx create mode 100644 solution/frontend/src/features/builder/canvas/variants/retro/common.tsx create mode 100644 solution/frontend/src/features/builder/canvas/variants/retro/retro.css create mode 100644 solution/frontend/src/features/builder/canvas/variants/songs/SongsTurntable.tsx diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index b0ec3bf..423d0d4 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,40 @@ --- +## 2026-09-01 — 붙여넣기 아이템 셋: 가요 다방 · 오늘의 한 장 · 반나절 산책 + +**한 일** +- 섹션 타입 3개 추가(`songs` · `daily` · `course`). 데이터가 수집(fact)이 아니라 + **사장님이 붙여넣은 JSON** 에서 온다 — 새 갈래다. +- `canvas/dataSpec.ts` 신설: 스키마·예시·프롬프트가 한 표에 모인다. 배리에이션 레지스트리와 같은 결이라 + 여기 한 줄을 더하면 캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다. +- `SectionItem.data?: string` 추가. **파싱본이 아니라 원문 문자열**을 담는다. +- [콘텐츠] 탭에 JSON 칸 + [프롬프트 복사] [프롬프트 보기] [예시 넣기] [줄맞춤]. +- 업종 시드 넷 모두에 세 섹션을 **꺼진 채로** 넣었다. + +**왜 이 모양인가** +`gunsan_365_story_db.xlsx`(365행)를 분석한 결과 **고유 주제는 52개고 한 주제가 7회씩 돈다** +(접미사 10개만 회전). 날짜 축으로 카드를 늘어놓으면 이레마다 같은 카드가 돌아온다 — +그래서 묶는 축을 주제로 잡고, 날짜는 일력 한 장에만 썼다. +같은 시트 `DB_Guide` 가 **가사·현대문학 원문 전재를 금지**해서 가요 스키마에 `lyrics` 필드를 +아예 두지 않았다. 없는 칸은 채울 수 없다. + +**밟은 함정** +- **테마 상한 64KB**(`site_service._THEME_MAX_BYTES`). 세 섹션이 각자 JSON 을 채우면 넘고, + 거절은 발행 직전에야 드러난다. → `SECTION_DATA_MAX_CHARS`(12,000자)로 화면에서 먼저 끊는다. +- **`JSON.parse` 오류 메시지가 두 형식이다.** `position N (line L column C)` 형과, 위치 없이 + 깨진 조각만 인용하는 형. 앞의 것만 보면 후자에서 위치를 통째로 잃는다 — 조각을 원문에서 되찾아 센다. +- 파싱은 **절대 throw 하지 않는다.** 편집 중인 JSON 은 늘 깨져 있고, 깨진 순간 캔버스가 죽으면 못 고친다. + +**아직 안 한 것** +- 발행 사이트(`solution/site`)는 `variantId` 도 `data` 도 아직 안 읽는다. 지금은 빌더 캔버스 전용이다. +- 프롬프트는 상호·주소를 박아 내보낸다(빈칸을 남기면 사장님이 못 채우고 그대로 보낸다). + +**검증** — `tsc --noEmit` · `eslint` · `vite build` 통과(frontend·admin). 세 배리에이션 SSR 렌더 확인, +파서 경계 12건 확인. + +--- + ## 2026-09-01 — 설정을 `.env` 하나로 모았다 **한 일** diff --git a/solution/frontend/index.html b/solution/frontend/index.html index c243534..1e9c809 100644 --- a/solution/frontend/index.html +++ b/solution/frontend/index.html @@ -12,6 +12,11 @@ href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300..900&display=swap" rel="stylesheet" /> + + Web4Ai · AI 웹 빌더 diff --git a/solution/frontend/src/data/industryData.ts b/solution/frontend/src/data/industryData.ts index 8ac5e8c..e8c2f95 100644 --- a/solution/frontend/src/data/industryData.ts +++ b/solution/frontend/src/data/industryData.ts @@ -22,6 +22,11 @@ export const INDUSTRY_CONFIGS: Record = { { id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '위치 안내 및 대중교통 경로' }, { id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' }, { id: 'local', type: 'local', name: '지역 정보', isLocked: false, isEnabled: true, description: '주변 관광지 및 맛집 추천' }, + // 붙여넣기 아이템 — 수집이 아니라 사장님이 넣은 JSON 으로 그린다(canvas/dataSpec.ts). + // ★ 기본은 꺼 둔다. 내용이 없는 채로 켜져 있으면 발행본에 빈 섹션이 나간다. + { id: 'songs', type: 'songs', name: '가요 다방', isLocked: false, isEnabled: false, description: '이 동네를 노래한 곡을 도넛판으로' }, + { id: 'daily', type: 'daily', name: '오늘의 한 장', isLocked: false, isEnabled: false, description: '날마다 바뀌는 우리 동네 이야기 한 장' }, + { id: 'course', type: 'course', name: '반나절 산책', isLocked: false, isEnabled: false, description: '가게에서 출발하는 도보 코스를 승차권으로' }, { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '고객들이 자주 묻는 질문과 답변' }, ], templates: [ @@ -134,6 +139,11 @@ export const INDUSTRY_CONFIGS: Record = { { id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '드라이브 코스 및 주차 진입로 안내' }, { id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 매장 주변 날씨' }, { id: 'local', type: 'local', name: '주변 나들이', isLocked: false, isEnabled: true, description: '양평 드라이브 코스 및 명소' }, + // 붙여넣기 아이템 — 수집이 아니라 사장님이 넣은 JSON 으로 그린다(canvas/dataSpec.ts). + // ★ 기본은 꺼 둔다. 내용이 없는 채로 켜져 있으면 발행본에 빈 섹션이 나간다. + { id: 'songs', type: 'songs', name: '가요 다방', isLocked: false, isEnabled: false, description: '이 동네를 노래한 곡을 도넛판으로' }, + { id: 'daily', type: 'daily', name: '오늘의 한 장', isLocked: false, isEnabled: false, description: '날마다 바뀌는 우리 동네 이야기 한 장' }, + { id: 'course', type: 'course', name: '반나절 산책', isLocked: false, isEnabled: false, description: '가게에서 출발하는 도보 코스를 승차권으로' }, { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '반려견 동반, 주차, 케어키즈존 안내' }, ], templates: [ @@ -229,6 +239,11 @@ export const INDUSTRY_CONFIGS: Record = { { id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '지하철역 출구 및 발렛부스 위치' }, { id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 매장 주변 날씨' }, { id: 'local', type: 'local', name: '주변 안내', isLocked: false, isEnabled: true, description: '청담 명품거리 및 갤러리 안내' }, + // 붙여넣기 아이템 — 수집이 아니라 사장님이 넣은 JSON 으로 그린다(canvas/dataSpec.ts). + // ★ 기본은 꺼 둔다. 내용이 없는 채로 켜져 있으면 발행본에 빈 섹션이 나간다. + { id: 'songs', type: 'songs', name: '가요 다방', isLocked: false, isEnabled: false, description: '이 동네를 노래한 곡을 도넛판으로' }, + { id: 'daily', type: 'daily', name: '오늘의 한 장', isLocked: false, isEnabled: false, description: '날마다 바뀌는 우리 동네 이야기 한 장' }, + { id: 'course', type: 'course', name: '반나절 산책', isLocked: false, isEnabled: false, description: '가게에서 출발하는 도보 코스를 승차권으로' }, { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '콜키지 정책, 알러지 케어, 주차 안내' }, ], templates: [ @@ -324,6 +339,11 @@ export const INDUSTRY_CONFIGS: Record = { { id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '이천 예스파크 진입로 및 주차장' }, { id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 체험장 주변 날씨' }, { id: 'local', type: 'local', name: '주변 관광 코스', isLocked: false, isEnabled: true, description: '도예마을 맛집 및 주변 힐링 스팟' }, + // 붙여넣기 아이템 — 수집이 아니라 사장님이 넣은 JSON 으로 그린다(canvas/dataSpec.ts). + // ★ 기본은 꺼 둔다. 내용이 없는 채로 켜져 있으면 발행본에 빈 섹션이 나간다. + { id: 'songs', type: 'songs', name: '가요 다방', isLocked: false, isEnabled: false, description: '이 동네를 노래한 곡을 도넛판으로' }, + { id: 'daily', type: 'daily', name: '오늘의 한 장', isLocked: false, isEnabled: false, description: '날마다 바뀌는 우리 동네 이야기 한 장' }, + { id: 'course', type: 'course', name: '반나절 산책', isLocked: false, isEnabled: false, description: '가게에서 출발하는 도보 코스를 승차권으로' }, { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '작품 수령 기간, 복장, 환불 규정' }, ], templates: [ diff --git a/solution/frontend/src/features/builder/RightTabsPanel.tsx b/solution/frontend/src/features/builder/RightTabsPanel.tsx index 8d7c5d8..c8ad7cb 100644 --- a/solution/frontend/src/features/builder/RightTabsPanel.tsx +++ b/solution/frontend/src/features/builder/RightTabsPanel.tsx @@ -2,6 +2,7 @@ import {useState} from 'react'; import { AlertTriangle, Check, + ClipboardCopy, Image as ImageIcon, Info, FileText, @@ -10,7 +11,9 @@ import { Pencil, Plus, Shapes, + Sparkles, Trash2, + Wand2, X, } from 'lucide-react'; import type {InfoField} from '@o2o/shared'; @@ -20,9 +23,16 @@ 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}, @@ -148,7 +158,8 @@ function ContentTab() { 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" /> } - {!supportsTitle && !supportsDescription && !supportsBody && ( + {dataSpecFor(section.type) && } + {!supportsTitle && !supportsDescription && !supportsBody && !dataSpecFor(section.type) && (

이 레이아웃에는 직접 입력하는 문구가 없습니다. 수집된 데이터만 표시합니다.

@@ -160,6 +171,159 @@ function ContentTab() { ); } +/** + * 붙여넣기 아이템의 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 && ( +
+

+ 아래 내용이 그대로 복사됩니다. 상호와 지역은 이미 채워져 있습니다. +

+