[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 은 늘 깨져 있고, 깨진 순간 캔버스가 죽으면 못 고친다.
|
- 파싱은 **절대 throw 하지 않는다.** 편집 중인 JSON 은 늘 깨져 있고, 깨진 순간 캔버스가 죽으면 못 고친다.
|
||||||
|
|
||||||
|
**섹션 관리에 붙인 것**
|
||||||
|
- 좌측 패널 하단 **[+ 섹션 추가]** → 목록에서 골라 넣는다. 시드에 박아 두지 않는 이유는,
|
||||||
|
붙여넣기 아이템은 내용이 없으면 빈 칸이라 아무도 안 쓰는 항목이 늘 붙어 있게 되기 때문이다.
|
||||||
|
- 나중에 넣은 섹션만 휴지통으로 뺄 수 있다(업종 기본 섹션은 스위치로 끈다).
|
||||||
|
- **레트로 템플릿**(업종마다 하나: 옛 항구 · 옛 다방 · 노포 · 시간여행)을 고르면 세 아이템이 함께 들어온다.
|
||||||
|
`TemplateItem.defaultSectionTypes` 가 그 계약이고, **넣기만 하고 빼지 않는다** —
|
||||||
|
템플릿을 눌러 보다 넣어 둔 섹션이 사라지면 사장님은 자기가 지웠다고 생각한다.
|
||||||
|
- 저장 payload 에 `type` 을 실었다. 시드에 없는 섹션은 복원 때 `id` 로 못 찾아 **통째로 버려졌다**
|
||||||
|
(사장님이 채운 JSON 까지 같이). 이제 `type` 으로 되살린다.
|
||||||
|
|
||||||
**아직 안 한 것**
|
**아직 안 한 것**
|
||||||
- 발행 사이트(`solution/site`)는 `variantId` 도 `data` 도 아직 안 읽는다. 지금은 빌더 캔버스 전용이다.
|
- 발행 사이트(`solution/site`)는 `variantId` 도 `data` 도 아직 안 읽는다. 지금은 빌더 캔버스 전용이다.
|
||||||
- 프롬프트는 상호·주소를 박아 내보낸다(빈칸을 남기면 사장님이 못 채우고 그대로 보낸다).
|
- 프롬프트는 상호·주소를 박아 내보낸다(빈칸을 남기면 사장님이 못 채우고 그대로 보낸다).
|
||||||
|
|
||||||
**검증** — `tsc --noEmit` · `eslint` · `vite build` 통과(frontend·admin). 세 배리에이션 SSR 렌더 확인,
|
**검증** — `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: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '위치 안내 및 대중교통 경로' },
|
||||||
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' },
|
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' },
|
||||||
{ id: 'local', type: 'local', 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: '고객들이 자주 묻는 질문과 답변' },
|
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '고객들이 자주 묻는 질문과 답변' },
|
||||||
],
|
],
|
||||||
templates: [
|
templates: [
|
||||||
@ -115,6 +110,25 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
|
|||||||
},
|
},
|
||||||
fontStyle: 'Conversion Driven',
|
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: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '드라이브 코스 및 주차 진입로 안내' },
|
||||||
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 매장 주변 날씨' },
|
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 매장 주변 날씨' },
|
||||||
{ id: 'local', type: 'local', 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: '반려견 동반, 주차, 케어키즈존 안내' },
|
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '반려견 동반, 주차, 케어키즈존 안내' },
|
||||||
],
|
],
|
||||||
templates: [
|
templates: [
|
||||||
@ -215,6 +224,25 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
|
|||||||
},
|
},
|
||||||
fontStyle: 'Dynamic Commerce',
|
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: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '지하철역 출구 및 발렛부스 위치' },
|
||||||
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 매장 주변 날씨' },
|
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 매장 주변 날씨' },
|
||||||
{ id: 'local', type: 'local', 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: '콜키지 정책, 알러지 케어, 주차 안내' },
|
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '콜키지 정책, 알러지 케어, 주차 안내' },
|
||||||
],
|
],
|
||||||
templates: [
|
templates: [
|
||||||
@ -315,6 +338,25 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
|
|||||||
},
|
},
|
||||||
fontStyle: 'Direct Reserve',
|
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: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '이천 예스파크 진입로 및 주차장' },
|
||||||
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 체험장 주변 날씨' },
|
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 체험장 주변 날씨' },
|
||||||
{ id: 'local', type: 'local', 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: '작품 수령 기간, 복장, 환불 규정' },
|
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '작품 수령 기간, 복장, 환불 규정' },
|
||||||
],
|
],
|
||||||
templates: [
|
templates: [
|
||||||
@ -415,6 +452,25 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
|
|||||||
},
|
},
|
||||||
fontStyle: 'Instant Booking',
|
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,
|
SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import {CSS} from '@dnd-kit/utilities';
|
import {CSS} from '@dnd-kit/utilities';
|
||||||
|
import {useState} from 'react';
|
||||||
import type {SectionItem} from '@o2o/shared';
|
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 {Switch} from '@/components/ui/switch';
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import {useBuilderStore} from '@/stores/builder';
|
import {useBuilderStore} from '@/stores/builder';
|
||||||
|
import {ADDABLE_SECTIONS, addableSection} from './canvas/addable';
|
||||||
import {resolveVariant} from './canvas/registry';
|
import {resolveVariant} from './canvas/registry';
|
||||||
|
import {VariantThumb} from './canvas/thumbs';
|
||||||
|
|
||||||
interface SectionListPanelProps {
|
interface SectionListPanelProps {
|
||||||
onAfterSelect?: () => void;
|
onAfterSelect?: () => void;
|
||||||
@ -28,7 +31,10 @@ function SortableSection({section, index, onAfterSelect}: SortableSectionProps)
|
|||||||
const isSelected = useBuilderStore((s) => s.selectedSectionId === section.id);
|
const isSelected = useBuilderStore((s) => s.selectedSectionId === section.id);
|
||||||
const selectSection = useBuilderStore((s) => s.selectSection);
|
const selectSection = useBuilderStore((s) => s.selectSection);
|
||||||
const toggleSection = useBuilderStore((s) => s.toggleSection);
|
const toggleSection = useBuilderStore((s) => s.toggleSection);
|
||||||
|
const removeSection = useBuilderStore((s) => s.removeSection);
|
||||||
const variant = resolveVariant(section, industry);
|
const variant = resolveVariant(section, industry);
|
||||||
|
// 업종 기본 섹션은 스위치로 끄는 것이지 빼는 게 아니다 — 뺄 수 있는 건 나중에 넣은 것뿐이다.
|
||||||
|
const canRemove = Boolean(addableSection(section.type));
|
||||||
const {attributes, listeners, setNodeRef, transform, transition, isDragging} = useSortable({
|
const {attributes, listeners, setNodeRef, transform, transition, isDragging} = useSortable({
|
||||||
id: section.id,
|
id: section.id,
|
||||||
});
|
});
|
||||||
@ -131,6 +137,20 @@ function SortableSection({section, index, onAfterSelect}: SortableSectionProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1" onClick={(event) => event.stopPropagation()} role="presentation">
|
<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 ? (
|
{section.isLocked ? (
|
||||||
<span title="SEO·필수 마크업이 달린 섹션이라 끌 수 없습니다." className="p-1 text-muted-foreground">
|
<span title="SEO·필수 마크업이 달린 섹션이라 끌 수 없습니다." className="p-1 text-muted-foreground">
|
||||||
<Lock className="size-4" />
|
<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) {
|
export function SectionListPanel({onAfterSelect}: SectionListPanelProps) {
|
||||||
const sections = useBuilderStore((s) => s.sections);
|
const sections = useBuilderStore((s) => s.sections);
|
||||||
const reorderSection = useBuilderStore((s) => s.reorderSection);
|
const reorderSection = useBuilderStore((s) => s.reorderSection);
|
||||||
|
const [isPickerOpen, setPickerOpen] = useState(false);
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {activationConstraint: {distance: 6}}),
|
useSensor(PointerSensor, {activationConstraint: {distance: 6}}),
|
||||||
useSensor(KeyboardSensor, {coordinateGetter: sortableKeyboardCoordinates}),
|
useSensor(KeyboardSensor, {coordinateGetter: sortableKeyboardCoordinates}),
|
||||||
@ -191,6 +272,19 @@ export function SectionListPanel({onAfterSelect}: SectionListPanelProps) {
|
|||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</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">
|
<p className="border-t border-border bg-muted/30 p-2.5 text-center text-[10px] text-muted-foreground">
|
||||||
* 자물쇠 항목은 검색·AI 노출에 필요한 마크업이 달려 있어 고정입니다.
|
* 자물쇠 항목은 검색·AI 노출에 필요한 마크업이 달려 있어 고정입니다.
|
||||||
</p>
|
</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;
|
colorPaletteId?: string | null;
|
||||||
sections: {
|
sections: {
|
||||||
id: string;
|
id: string;
|
||||||
|
/**
|
||||||
|
* 섹션 타입.
|
||||||
|
*
|
||||||
|
* ★ 예전엔 안 실었다 — 시드에 다 있으니 id 로 찾으면 됐다. [+ 섹션 추가] 가 생기면서
|
||||||
|
* 시드에 없는 섹션이 저장되기 시작했고, type 이 없으면 복원 때 그게 뭐였는지 알 길이 없어
|
||||||
|
* 통째로 버려진다(사장님이 채운 JSON 까지 같이).
|
||||||
|
*/
|
||||||
|
type?: string;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
@ -76,6 +84,7 @@ export function toThemePayload(
|
|||||||
colorPaletteId,
|
colorPaletteId,
|
||||||
sections: sections.map((s) => ({
|
sections: sections.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
|
type: s.type,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
enabled: s.isEnabled,
|
enabled: s.isEnabled,
|
||||||
locked: s.isLocked,
|
locked: s.isLocked,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import type {
|
|||||||
ViewportMode,
|
ViewportMode,
|
||||||
} from '@o2o/shared';
|
} from '@o2o/shared';
|
||||||
import {FALLBACK_INDUSTRY, INDUSTRY_CONFIGS} from '@/data/industryData';
|
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 {COLOR_PALETTE_PRESETS} from '@/features/builder/colorPalettes';
|
||||||
import {createFactSaver, type FactSaver} from '@/features/builder/factSave';
|
import {createFactSaver, type FactSaver} from '@/features/builder/factSave';
|
||||||
import {
|
import {
|
||||||
@ -125,6 +126,10 @@ interface BuilderState {
|
|||||||
selectSection: (id: string | null) => void;
|
selectSection: (id: string | null) => void;
|
||||||
toggleSection: (id: string) => void;
|
toggleSection: (id: string) => void;
|
||||||
reorderSection: (fromIndex: number, toIndex: number) => 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;
|
updateSectionContent: (sectionId: string, patch: Pick<SectionItem, 'name' | 'description' | 'body'>) => void;
|
||||||
/** 붙여넣기 아이템의 원문 JSON. 깨져 있어도 그대로 담는다 — 판단은 렌더러가 한다. */
|
/** 붙여넣기 아이템의 원문 JSON. 깨져 있어도 그대로 담는다 — 판단은 렌더러가 한다. */
|
||||||
updateSectionData: (sectionId: string, data: string) => void;
|
updateSectionData: (sectionId: string, data: string) => void;
|
||||||
@ -460,8 +465,31 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
|||||||
setGatherStage: (gatherStage) => set({gatherStage}),
|
setGatherStage: (gatherStage) => set({gatherStage}),
|
||||||
finishGather: () => set({isGathering: false, gatherCompleted: true, gatherStage: 3}),
|
finishGather: () => set({isGathering: false, gatherCompleted: true, gatherStage: 3}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 템플릿을 고른다. 그 템플릿이 데리고 오는 섹션이 있으면 함께 들어온다.
|
||||||
|
*
|
||||||
|
* ★ 넣기만 하고 빼지 않는다. 템플릿을 눌러 보다가 넣어 둔 섹션이 사라지면
|
||||||
|
* 사장님은 그게 템플릿 때문인 줄 모르고 자기가 지웠다고 생각한다.
|
||||||
|
*/
|
||||||
selectTemplate: (templateId) => {
|
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();
|
persistTheme();
|
||||||
},
|
},
|
||||||
selectColorPalette: (colorPaletteId) => {
|
selectColorPalette: (colorPaletteId) => {
|
||||||
@ -514,6 +542,33 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
|||||||
persistTheme();
|
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) => {
|
updateSectionData: (sectionId, data) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
sections: state.sections.map((section) =>
|
sections: state.sections.map((section) =>
|
||||||
@ -566,17 +621,22 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
|||||||
// (SectionItem 에서는 선택 키다) 아래 filter 의 타입 술어가 컴파일되지 않는다.
|
// (SectionItem 에서는 선택 키다) 아래 filter 의 타입 술어가 컴파일되지 않는다.
|
||||||
.map((saved): SectionItem | null => {
|
.map((saved): SectionItem | null => {
|
||||||
const seed = seedById.get(saved.id);
|
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);
|
seedById.delete(saved.id);
|
||||||
return {
|
return {
|
||||||
...seed,
|
...revived,
|
||||||
name: saved.name || seed.name,
|
name: saved.name || revived.name,
|
||||||
description: saved.description ?? seed.description,
|
description: saved.description ?? revived.description,
|
||||||
body: saved.body ?? seed.body,
|
body: saved.body ?? revived.body,
|
||||||
data: saved.data ?? seed.data,
|
data: saved.data ?? revived.data,
|
||||||
// 잠긴 섹션은 시드의 판단이 이긴다 — 서버에 꺼진 채로 저장돼 있더라도
|
// 잠긴 섹션은 시드의 판단이 이긴다 — 서버에 꺼진 채로 저장돼 있더라도
|
||||||
// SEO·필수 마크업이 달린 섹션을 화면에서 꺼진 것처럼 보이게 하지 않는다.
|
// SEO·필수 마크업이 달린 섹션을 화면에서 꺼진 것처럼 보이게 하지 않는다.
|
||||||
isEnabled: seed.isLocked ? true : saved.enabled,
|
isEnabled: revived.isLocked ? true : saved.enabled,
|
||||||
...(saved.variantId ? {variantId: saved.variantId} : {}),
|
...(saved.variantId ? {variantId: saved.variantId} : {}),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|||||||
@ -68,6 +68,13 @@ export interface TemplateItem {
|
|||||||
accent: string;
|
accent: string;
|
||||||
};
|
};
|
||||||
fontStyle: string;
|
fontStyle: string;
|
||||||
|
/**
|
||||||
|
* 이 템플릿을 고르면 함께 들어오는 섹션(canvas/addable 의 타입).
|
||||||
|
*
|
||||||
|
* ★ 넣기만 하고 빼지 않는다. 템플릿을 이리저리 눌러보다 사장님이 넣어 둔 섹션이 사라지면
|
||||||
|
* 그게 템플릿 때문인 줄 모른다.
|
||||||
|
*/
|
||||||
|
defaultSectionTypes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PhotoItem {
|
export interface PhotoItem {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user