From e6ffdad888ece5ab45efd376ead36309812a25a1 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 08:35:25 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/frontend:=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=20=EC=95=84=EC=9D=B4=ED=85=9C=EC=9D=84=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=EC=9C=BC=EB=A1=9C=20=EB=84=A3=EB=8A=94=EB=8B=A4=20?= =?UTF-8?q?=E2=80=94=20=EB=A0=88=ED=8A=B8=EB=A1=9C=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=EC=9D=B4=20=EA=B8=B0=EB=B3=B8=20=EA=B5=AC=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EB=8D=B0=EB=A0=A4=EC=98=A8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시드에 세 아이템을 박아 두니 내용 없는 칸이 목록에 늘 붙어 있었다. 붙여넣기 아이템은 JSON 이 없으면 빈 섹션이라 "쓸 사람만 넣는" 쪽이 맞다. - canvas/addable.ts: 추가 가능한 섹션 목록. dataSpec 이 단일 출처라 아이템을 만들면 여기 자동으로 나타난다 — 목록을 따로 들면 만들어 놓고 고를 수 없는 상태가 된다 - SectionListPanel: 하단 [+ 섹션 추가] + 썸네일 목록. 나중에 넣은 섹션만 휴지통으로 뺀다 (업종 기본 섹션은 스위치로 끄는 것이지 빼는 게 아니다). 내용이 있으면 빼기 전에 한 번 묻는다 - stores/builder: addSection 은 이미 있으면 새로 만들지 않고 켜기만 한다 — 새로 만들면 넣어 둔 JSON 이 날아간다 - shared/TemplateItem.defaultSectionTypes + 업종마다 레트로 템플릿 하나(옛 항구·옛 다방·노포·시간여행). 고르면 세 아이템이 함께 들어온다. 넣기만 하고 빼지 않는다 — 템플릿을 눌러 보다 넣어 둔 섹션이 사라지면 사장님은 그게 템플릿 때문인 줄 모르고 자기가 지웠다고 생각한다 - siteTheme/applyTheme: 저장 payload 에 type 을 싣는다. 시드에 없는 섹션은 id 로 못 찾아 복원 때 통째로 버려졌다 — 사장님이 채운 JSON 까지 같이 사라지는 자리였다 - industryData: 세 아이템을 시드에서 뺐다 tsc·eslint·vite build 통과(frontend·admin), 추가·삭제·템플릿 연동·저장복원 왕복 12건 확인 --- docs/DEVLOG.md | 12 ++- solution/frontend/src/data/industryData.ts | 96 +++++++++++++++---- .../src/features/builder/SectionListPanel.tsx | 96 ++++++++++++++++++- .../src/features/builder/canvas/addable.ts | 51 ++++++++++ .../src/features/publish/siteTheme.ts | 9 ++ solution/frontend/src/stores/builder.ts | 76 +++++++++++++-- solution/shared/src/types/builder.ts | 7 ++ 7 files changed, 317 insertions(+), 30 deletions(-) create mode 100644 solution/frontend/src/features/builder/canvas/addable.ts diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 423d0d4..bd61939 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -30,12 +30,22 @@ 깨진 조각만 인용하는 형. 앞의 것만 보면 후자에서 위치를 통째로 잃는다 — 조각을 원문에서 되찾아 센다. - 파싱은 **절대 throw 하지 않는다.** 편집 중인 JSON 은 늘 깨져 있고, 깨진 순간 캔버스가 죽으면 못 고친다. +**섹션 관리에 붙인 것** +- 좌측 패널 하단 **[+ 섹션 추가]** → 목록에서 골라 넣는다. 시드에 박아 두지 않는 이유는, + 붙여넣기 아이템은 내용이 없으면 빈 칸이라 아무도 안 쓰는 항목이 늘 붙어 있게 되기 때문이다. +- 나중에 넣은 섹션만 휴지통으로 뺄 수 있다(업종 기본 섹션은 스위치로 끈다). +- **레트로 템플릿**(업종마다 하나: 옛 항구 · 옛 다방 · 노포 · 시간여행)을 고르면 세 아이템이 함께 들어온다. + `TemplateItem.defaultSectionTypes` 가 그 계약이고, **넣기만 하고 빼지 않는다** — + 템플릿을 눌러 보다 넣어 둔 섹션이 사라지면 사장님은 자기가 지웠다고 생각한다. +- 저장 payload 에 `type` 을 실었다. 시드에 없는 섹션은 복원 때 `id` 로 못 찾아 **통째로 버려졌다** + (사장님이 채운 JSON 까지 같이). 이제 `type` 으로 되살린다. + **아직 안 한 것** - 발행 사이트(`solution/site`)는 `variantId` 도 `data` 도 아직 안 읽는다. 지금은 빌더 캔버스 전용이다. - 프롬프트는 상호·주소를 박아 내보낸다(빈칸을 남기면 사장님이 못 채우고 그대로 보낸다). **검증** — `tsc --noEmit` · `eslint` · `vite build` 통과(frontend·admin). 세 배리에이션 SSR 렌더 확인, -파서 경계 12건 확인. +파서 경계 12건 + 추가·삭제·템플릿·저장복원 왕복 12건 확인. --- diff --git a/solution/frontend/src/data/industryData.ts b/solution/frontend/src/data/industryData.ts index e8c2f95..f25f07a 100644 --- a/solution/frontend/src/data/industryData.ts +++ b/solution/frontend/src/data/industryData.ts @@ -22,11 +22,6 @@ 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: [ @@ -115,6 +110,25 @@ export const INDUSTRY_CONFIGS: Record = { }, fontStyle: 'Conversion Driven', }, + { + id: 'stay-retro-port', + industryId: 'stay', + name: '옛 항구', + tone: 'photo', + toneLabel: '레트로 감성', + description: '도넛판과 일력, 승차권. 오래된 항구 도시의 인상으로 묵는 곳을 소개합니다.', + colors: { + primary: '#1b1a15', + secondary: '#4c4739', + bg: '#e4dac0', + card: '#f2ebd9', + text: '#1b1a15', + accent: '#bf2f1b', + }, + fontStyle: 'Retro Signage', + // 레트로 템플릿을 고르면 7080 아이템이 함께 들어온다 — 이 템플릿이 팔려는 게 바로 그것이다. + defaultSectionTypes: ['songs', 'daily', 'course'], + }, ], }, @@ -139,11 +153,6 @@ 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: [ @@ -215,6 +224,25 @@ export const INDUSTRY_CONFIGS: Record = { }, fontStyle: 'Dynamic Commerce', }, + { + id: 'cafe-retro-dabang', + industryId: 'cafe', + name: '옛 다방', + tone: 'photo', + toneLabel: '레트로 감성', + description: 'LP 와 갱지, 손글씨. 다방 시절의 인상으로 지금의 커피를 이야기합니다.', + colors: { + primary: '#1b1a15', + secondary: '#4c4739', + bg: '#e4dac0', + card: '#f2ebd9', + text: '#1b1a15', + accent: '#bf2f1b', + }, + fontStyle: 'Retro Signage', + // 레트로 템플릿을 고르면 7080 아이템이 함께 들어온다 — 이 템플릿이 팔려는 게 바로 그것이다. + defaultSectionTypes: ['songs', 'daily', 'course'], + }, ], }, @@ -239,11 +267,6 @@ 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: [ @@ -315,6 +338,25 @@ export const INDUSTRY_CONFIGS: Record = { }, fontStyle: 'Direct Reserve', }, + { + id: 'restaurant-retro-nopo', + industryId: 'restaurant', + name: '노포', + tone: 'photo', + toneLabel: '레트로 감성', + description: '간판 활자와 성냥갑 톤. 오래 해온 집이라는 사실 자체가 메뉴판입니다.', + colors: { + primary: '#1b1a15', + secondary: '#4c4739', + bg: '#e4dac0', + card: '#f2ebd9', + text: '#1b1a15', + accent: '#bf2f1b', + }, + fontStyle: 'Retro Signage', + // 레트로 템플릿을 고르면 7080 아이템이 함께 들어온다 — 이 템플릿이 팔려는 게 바로 그것이다. + defaultSectionTypes: ['songs', 'daily', 'course'], + }, ], }, @@ -339,11 +381,6 @@ 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: [ @@ -415,6 +452,25 @@ export const INDUSTRY_CONFIGS: Record = { }, fontStyle: 'Instant Booking', }, + { + id: 'tour-retro-timetrip', + industryId: 'tour', + name: '시간여행', + tone: 'photo', + toneLabel: '레트로 감성', + description: '연표와 승차권, 일력. 지역의 시간을 걸어 다니는 체험으로 엮습니다.', + colors: { + primary: '#1b1a15', + secondary: '#4c4739', + bg: '#e4dac0', + card: '#f2ebd9', + text: '#1b1a15', + accent: '#bf2f1b', + }, + fontStyle: 'Retro Signage', + // 레트로 템플릿을 고르면 7080 아이템이 함께 들어온다 — 이 템플릿이 팔려는 게 바로 그것이다. + defaultSectionTypes: ['songs', 'daily', 'course'], + }, ], }, }; diff --git a/solution/frontend/src/features/builder/SectionListPanel.tsx b/solution/frontend/src/features/builder/SectionListPanel.tsx index cd062e5..feb2f11 100644 --- a/solution/frontend/src/features/builder/SectionListPanel.tsx +++ b/solution/frontend/src/features/builder/SectionListPanel.tsx @@ -6,12 +6,15 @@ import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import {CSS} from '@dnd-kit/utilities'; +import {useState} from 'react'; import type {SectionItem} from '@o2o/shared'; -import {GripVertical, Layers, Lock, Shapes} from 'lucide-react'; +import {Check, GripVertical, Layers, Lock, Plus, Shapes, Trash2, X} from 'lucide-react'; import {Switch} from '@/components/ui/switch'; import {cn} from '@/lib/utils'; import {useBuilderStore} from '@/stores/builder'; +import {ADDABLE_SECTIONS, addableSection} from './canvas/addable'; import {resolveVariant} from './canvas/registry'; +import {VariantThumb} from './canvas/thumbs'; interface SectionListPanelProps { onAfterSelect?: () => void; @@ -28,7 +31,10 @@ function SortableSection({section, index, onAfterSelect}: SortableSectionProps) const isSelected = useBuilderStore((s) => s.selectedSectionId === section.id); const selectSection = useBuilderStore((s) => s.selectSection); const toggleSection = useBuilderStore((s) => s.toggleSection); + const removeSection = useBuilderStore((s) => s.removeSection); const variant = resolveVariant(section, industry); + // 업종 기본 섹션은 스위치로 끄는 것이지 빼는 게 아니다 — 뺄 수 있는 건 나중에 넣은 것뿐이다. + const canRemove = Boolean(addableSection(section.type)); const {attributes, listeners, setNodeRef, transform, transition, isDragging} = useSortable({ id: section.id, }); @@ -131,6 +137,20 @@ function SortableSection({section, index, onAfterSelect}: SortableSectionProps)
event.stopPropagation()} role="presentation"> + {canRemove && ( + + )} {section.isLocked ? ( @@ -144,9 +164,70 @@ function SortableSection({section, index, onAfterSelect}: SortableSectionProps) ); } +/** [+ 섹션 추가] 가 여는 목록. 이미 들어 있는 것은 '넣음' 으로 표시만 하고 다시 넣지 않는다. */ +function AddSectionPicker({onClose, onAfterSelect}: {onClose: () => void; onAfterSelect?: () => void}) { + const sections = useBuilderStore((s) => s.sections); + const addSection = useBuilderStore((s) => s.addSection); + + return ( +
+
+ 추가할 섹션 + +
+

+ 내용은 [콘텐츠] 탭에서 프롬프트를 복사해 받은 JSON 을 붙여넣어 채웁니다. +

+
    + {ADDABLE_SECTIONS.map((item) => { + const already = sections.some((section) => section.type === item.type); + return ( +
  • + +
  • + ); + })} +
+
+ ); +} + export function SectionListPanel({onAfterSelect}: SectionListPanelProps) { const sections = useBuilderStore((s) => s.sections); const reorderSection = useBuilderStore((s) => s.reorderSection); + const [isPickerOpen, setPickerOpen] = useState(false); const sensors = useSensors( useSensor(PointerSensor, {activationConstraint: {distance: 6}}), useSensor(KeyboardSensor, {coordinateGetter: sortableKeyboardCoordinates}), @@ -191,6 +272,19 @@ export function SectionListPanel({onAfterSelect}: SectionListPanelProps) { + {isPickerOpen ? ( + setPickerOpen(false)} onAfterSelect={onAfterSelect} /> + ) : ( + + )} +

* 자물쇠 항목은 검색·AI 노출에 필요한 마크업이 달려 있어 고정입니다.

diff --git a/solution/frontend/src/features/builder/canvas/addable.ts b/solution/frontend/src/features/builder/canvas/addable.ts new file mode 100644 index 0000000..35c7814 --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/addable.ts @@ -0,0 +1,51 @@ +/** + * 나중에 넣을 수 있는 섹션 — [+ 섹션 추가] 가 고르는 목록. + * + * 업종 시드(industryData)는 "처음부터 있는 것"이고 여기는 "필요하면 넣는 것"이다. + * 붙여넣기 아이템은 내용이 없으면 빈 섹션이라, 시드에 박아 두면 아무도 안 쓰는 칸이 늘 붙어 있다. + */ +import type {SectionItem} from '@o2o/shared'; +import {SECTION_DATA_SPEC} from './dataSpec'; +import {defaultVariant} from './registry'; +import type {ThumbKey} from './types'; + +export interface AddableSection { + type: string; + name: string; + description: string; + thumb: ThumbKey; +} + +/** + * 추가할 수 있는 섹션 목록. + * + * ★ dataSpec 이 단일 출처다 — 아이템을 하나 더 만들면 여기에도 자동으로 나타난다. + * 목록을 따로 들면 아이템을 추가하고 이 표를 잊어 "만들었는데 고를 수가 없는" 상태가 된다. + */ +export const ADDABLE_SECTIONS: AddableSection[] = Object.values(SECTION_DATA_SPEC).map((spec) => { + const variant = defaultVariant(spec.kind); + return { + type: spec.kind, + name: spec.label, + description: variant?.description ?? '', + thumb: variant?.thumb ?? 'cards', + }; +}); + +export function addableSection(type: string): AddableSection | undefined { + return ADDABLE_SECTIONS.find((item) => item.type === type); +} + +/** 목록에 새로 넣을 섹션 한 줄. id 는 타입과 같다 — 같은 아이템을 두 번 넣지 않는다. */ +export function newSectionOf(type: string): SectionItem | undefined { + const addable = addableSection(type); + if (!addable) return undefined; + return { + id: type, + type, + name: addable.name, + description: addable.description, + isLocked: false, + isEnabled: true, + }; +} diff --git a/solution/frontend/src/features/publish/siteTheme.ts b/solution/frontend/src/features/publish/siteTheme.ts index 73d8141..2b45ff0 100644 --- a/solution/frontend/src/features/publish/siteTheme.ts +++ b/solution/frontend/src/features/publish/siteTheme.ts @@ -42,6 +42,14 @@ export interface SiteThemePayload { colorPaletteId?: string | null; sections: { id: string; + /** + * 섹션 타입. + * + * ★ 예전엔 안 실었다 — 시드에 다 있으니 id 로 찾으면 됐다. [+ 섹션 추가] 가 생기면서 + * 시드에 없는 섹션이 저장되기 시작했고, type 이 없으면 복원 때 그게 뭐였는지 알 길이 없어 + * 통째로 버려진다(사장님이 채운 JSON 까지 같이). + */ + type?: string; name: string; enabled: boolean; locked: boolean; @@ -76,6 +84,7 @@ export function toThemePayload( colorPaletteId, sections: sections.map((s) => ({ id: s.id, + type: s.type, name: s.name, enabled: s.isEnabled, locked: s.isLocked, diff --git a/solution/frontend/src/stores/builder.ts b/solution/frontend/src/stores/builder.ts index b3c7db5..6d34f1a 100644 --- a/solution/frontend/src/stores/builder.ts +++ b/solution/frontend/src/stores/builder.ts @@ -9,6 +9,7 @@ import type { ViewportMode, } from '@o2o/shared'; import {FALLBACK_INDUSTRY, INDUSTRY_CONFIGS} from '@/data/industryData'; +import {addableSection, newSectionOf} from '@/features/builder/canvas/addable'; import {COLOR_PALETTE_PRESETS} from '@/features/builder/colorPalettes'; import {createFactSaver, type FactSaver} from '@/features/builder/factSave'; import { @@ -125,6 +126,10 @@ interface BuilderState { selectSection: (id: string | null) => void; toggleSection: (id: string) => void; reorderSection: (fromIndex: number, toIndex: number) => void; + /** [+ 섹션 추가] — 이미 있으면 켜기만 한다. */ + addSection: (type: string) => void; + /** 나중에 넣은 섹션만 뺀다. 업종 기본 섹션은 스위치로 끄는 것이지 빼는 게 아니다. */ + removeSection: (id: string) => void; updateSectionContent: (sectionId: string, patch: Pick) => void; /** 붙여넣기 아이템의 원문 JSON. 깨져 있어도 그대로 담는다 — 판단은 렌더러가 한다. */ updateSectionData: (sectionId: string, data: string) => void; @@ -460,8 +465,31 @@ export const useBuilderStore = create((set, get) => ({ setGatherStage: (gatherStage) => set({gatherStage}), finishGather: () => set({isGathering: false, gatherCompleted: true, gatherStage: 3}), + /** + * 템플릿을 고른다. 그 템플릿이 데리고 오는 섹션이 있으면 함께 들어온다. + * + * ★ 넣기만 하고 빼지 않는다. 템플릿을 눌러 보다가 넣어 둔 섹션이 사라지면 + * 사장님은 그게 템플릿 때문인 줄 모르고 자기가 지웠다고 생각한다. + */ selectTemplate: (templateId) => { - set({templateId, colorPaletteId: null}); + set((state) => { + const template = INDUSTRY_CONFIGS[state.industry].templates.find((t) => t.id === templateId); + const wanted = template?.defaultSectionTypes ?? []; + const added = wanted + .filter((type) => !state.sections.some((sec) => sec.type === type)) + .map(newSectionOf) + .filter((sec): sec is SectionItem => sec !== undefined); + return { + templateId, + colorPaletteId: null, + sections: [ + ...state.sections.map((sec) => + wanted.includes(sec.type) ? {...sec, isEnabled: true} : sec, + ), + ...added, + ], + }; + }); persistTheme(); }, selectColorPalette: (colorPaletteId) => { @@ -514,6 +542,33 @@ export const useBuilderStore = create((set, get) => ({ persistTheme(); }, + addSection: (type) => { + const created = newSectionOf(type); + if (!created) return; + set((state) => { + const existing = state.sections.find((sec) => sec.type === type); + // 이미 있으면 새로 만들지 않는다 — 넣어 둔 JSON 이 날아간다. + if (existing) { + return { + sections: state.sections.map((sec) => + sec.id === existing.id ? {...sec, isEnabled: true} : sec, + ), + selectedSectionId: existing.id, + }; + } + return {sections: [...state.sections, created], selectedSectionId: created.id}; + }); + persistTheme(); + }, + + removeSection: (id) => { + set((state) => ({ + sections: state.sections.filter((sec) => sec.id !== id), + selectedSectionId: state.selectedSectionId === id ? null : state.selectedSectionId, + })); + persistTheme(); + }, + updateSectionData: (sectionId, data) => { set((state) => ({ sections: state.sections.map((section) => @@ -566,17 +621,22 @@ export const useBuilderStore = create((set, get) => ({ // (SectionItem 에서는 선택 키다) 아래 filter 의 타입 술어가 컴파일되지 않는다. .map((saved): SectionItem | null => { const seed = seedById.get(saved.id); - if (!seed) return null; + // ★ 시드에 없어도 [+ 섹션 추가] 로 넣을 수 있는 것이면 되살린다. + // 여기서 버리면 사장님이 추가하고 JSON 까지 채운 섹션이 새로고침 한 번에 사라진다. + const revived = + seed ?? + (saved.type && addableSection(saved.type) ? newSectionOf(saved.type) : undefined); + if (!revived) return null; seedById.delete(saved.id); return { - ...seed, - name: saved.name || seed.name, - description: saved.description ?? seed.description, - body: saved.body ?? seed.body, - data: saved.data ?? seed.data, + ...revived, + name: saved.name || revived.name, + description: saved.description ?? revived.description, + body: saved.body ?? revived.body, + data: saved.data ?? revived.data, // 잠긴 섹션은 시드의 판단이 이긴다 — 서버에 꺼진 채로 저장돼 있더라도 // SEO·필수 마크업이 달린 섹션을 화면에서 꺼진 것처럼 보이게 하지 않는다. - isEnabled: seed.isLocked ? true : saved.enabled, + isEnabled: revived.isLocked ? true : saved.enabled, ...(saved.variantId ? {variantId: saved.variantId} : {}), }; }) diff --git a/solution/shared/src/types/builder.ts b/solution/shared/src/types/builder.ts index e118776..1f4487c 100644 --- a/solution/shared/src/types/builder.ts +++ b/solution/shared/src/types/builder.ts @@ -68,6 +68,13 @@ export interface TemplateItem { accent: string; }; fontStyle: string; + /** + * 이 템플릿을 고르면 함께 들어오는 섹션(canvas/addable 의 타입). + * + * ★ 넣기만 하고 빼지 않는다. 템플릿을 이리저리 눌러보다 사장님이 넣어 둔 섹션이 사라지면 + * 그게 템플릿 때문인 줄 모른다. + */ + defaultSectionTypes?: string[]; } export interface PhotoItem {