[feat] solution/frontend: 섹션 아이템을 버튼으로 넣는다 — 레트로 템플릿이 기본 구성을 데려온다
시드에 세 아이템을 박아 두니 내용 없는 칸이 목록에 늘 붙어 있었다. 붙여넣기 아이템은 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건 확인
This commit is contained in:
parent
23162783f9
commit
e6ffdad888
@ -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건 확인.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -22,11 +22,6 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
|
||||
{ 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<IndustryType, IndustryData> = {
|
||||
},
|
||||
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<IndustryType, IndustryData> = {
|
||||
{ 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<IndustryType, IndustryData> = {
|
||||
},
|
||||
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<IndustryType, IndustryData> = {
|
||||
{ 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<IndustryType, IndustryData> = {
|
||||
},
|
||||
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<IndustryType, IndustryData> = {
|
||||
{ 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<IndustryType, IndustryData> = {
|
||||
},
|
||||
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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@ -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)
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1" onClick={(event) => event.stopPropagation()} role="presentation">
|
||||
{canRemove && (
|
||||
<button
|
||||
type="button"
|
||||
title={`${section.name} 섹션 빼기`}
|
||||
aria-label={`${section.name} 섹션 빼기`}
|
||||
onClick={() => {
|
||||
if (section.data?.trim() && !window.confirm(`${section.name} 에 넣은 내용도 함께 지워집니다. 뺄까요?`)) return;
|
||||
removeSection(section.id);
|
||||
}}
|
||||
className="rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{section.isLocked ? (
|
||||
<span title="SEO·필수 마크업이 달린 섹션이라 끌 수 없습니다." className="p-1 text-muted-foreground">
|
||||
<Lock className="size-4" />
|
||||
@ -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 (
|
||||
<div className="border-t border-border bg-muted/40 p-2">
|
||||
<div className="flex items-center justify-between px-1 pb-1.5">
|
||||
<span className="text-[11px] font-bold">추가할 섹션</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="섹션 추가 닫기"
|
||||
className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="px-1 pb-2 text-[10px] leading-relaxed text-muted-foreground">
|
||||
내용은 [콘텐츠] 탭에서 프롬프트를 복사해 받은 JSON 을 붙여넣어 채웁니다.
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{ADDABLE_SECTIONS.map((item) => {
|
||||
const already = sections.some((section) => section.type === item.type);
|
||||
return (
|
||||
<li key={item.type}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={already}
|
||||
onClick={() => {
|
||||
addSection(item.type);
|
||||
onClose();
|
||||
onAfterSelect?.();
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-2 rounded-md border p-2 text-left transition-colors',
|
||||
already
|
||||
? 'cursor-default border-border bg-muted/50 text-muted-foreground'
|
||||
: 'border-border bg-card hover:border-primary hover:bg-primary/5',
|
||||
)}
|
||||
>
|
||||
<VariantThumb thumb={item.thumb} className="mt-0.5 w-9 shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1 text-[11px] font-semibold text-foreground">
|
||||
{item.name}
|
||||
{already && <Check className="size-3 text-muted-foreground" />}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[10px] leading-relaxed text-muted-foreground">
|
||||
{already ? '이미 목록에 있습니다' : item.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{isPickerOpen ? (
|
||||
<AddSectionPicker onClose={() => setPickerOpen(false)} onAfterSelect={onAfterSelect} />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 border-t border-border bg-card p-2.5 text-[11px] font-semibold text-muted-foreground transition-colors hover:bg-primary/5 hover:text-primary"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
섹션 추가
|
||||
</button>
|
||||
)}
|
||||
|
||||
<p className="border-t border-border bg-muted/30 p-2.5 text-center text-[10px] text-muted-foreground">
|
||||
* 자물쇠 항목은 검색·AI 노출에 필요한 마크업이 달려 있어 고정입니다.
|
||||
</p>
|
||||
|
||||
51
solution/frontend/src/features/builder/canvas/addable.ts
Normal file
51
solution/frontend/src/features/builder/canvas/addable.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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<SectionItem, 'name' | 'description' | 'body'>) => void;
|
||||
/** 붙여넣기 아이템의 원문 JSON. 깨져 있어도 그대로 담는다 — 판단은 렌더러가 한다. */
|
||||
updateSectionData: (sectionId: string, data: string) => void;
|
||||
@ -460,8 +465,31 @@ export const useBuilderStore = create<BuilderState>((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<BuilderState>((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<BuilderState>((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} : {}),
|
||||
};
|
||||
})
|
||||
|
||||
@ -68,6 +68,13 @@ export interface TemplateItem {
|
||||
accent: string;
|
||||
};
|
||||
fontStyle: string;
|
||||
/**
|
||||
* 이 템플릿을 고르면 함께 들어오는 섹션(canvas/addable 의 타입).
|
||||
*
|
||||
* ★ 넣기만 하고 빼지 않는다. 템플릿을 이리저리 눌러보다 사장님이 넣어 둔 섹션이 사라지면
|
||||
* 그게 템플릿 때문인 줄 모른다.
|
||||
*/
|
||||
defaultSectionTypes?: string[];
|
||||
}
|
||||
|
||||
export interface PhotoItem {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user