o2o-site-AEO/solution/frontend/src/features/builder/canvas/registry.ts
Mina Choi 23162783f9 [feat] solution/frontend: 붙여넣기 아이템 셋 — 가요 다방·오늘의 한 장·반나절 산책
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건 확인
2026-09-01 17:58:58 +09:00

483 lines
14 KiB
TypeScript

/**
* 배리에이션 레지스트리 — "이 섹션 타입에 어떤 레이아웃들이 있나"의 단일 출처.
*
* 캔버스 렌더러와 [디자인] 탭이 같은 표를 본다. 배리에이션을 하나 추가한다는 건
* 여기 한 줄을 더한다는 뜻이고, 그 순간 캔버스와 고르는 UI가 동시에 늘어난다.
*/
import type {IndustryType, SectionItem} from '@o2o/shared';
import type {SectionVariant} from './types';
import {HeroEditorial} from './variants/hero/HeroEditorial';
import {HeroSplit} from './variants/hero/HeroSplit';
import {HeroFullBleed} from './variants/hero/HeroFullBleed';
import {HeroTypeOnly} from './variants/hero/HeroTypeOnly';
import {IntroStory} from './variants/intro/IntroStory';
import {IntroCentered} from './variants/intro/IntroCentered';
import {IntroSideBySide} from './variants/intro/IntroSideBySide';
import {InfoTable} from './variants/info/InfoTable';
import {InfoCards} from './variants/info/InfoCards';
import {InfoInline} from './variants/info/InfoInline';
import {PhotosGrid} from './variants/photos/PhotosGrid';
import {PhotosMasonry} from './variants/photos/PhotosMasonry';
import {PhotosCarousel} from './variants/photos/PhotosCarousel';
import {PhotosWithVideos} from './variants/photos/PhotosWithVideos';
import {MapDetailed} from './variants/map/MapDetailed';
import {MapCompact} from './variants/map/MapCompact';
import {LocalFull} from './variants/local/LocalFull';
import {LocalTabs} from './variants/local/LocalTabs';
import {LocalCompact} from './variants/local/LocalCompact';
import {WeatherSection} from './variants/weather/WeatherSection';
import {FaqAccordion} from './variants/faq/FaqAccordion';
import {FaqOpenList} from './variants/faq/FaqOpenList';
import {FaqTwoColumn} from './variants/faq/FaqTwoColumn';
import {RoomsCarousel} from './variants/rooms/RoomsCarousel';
import {RoomsGrid} from './variants/rooms/RoomsGrid';
import {RoomsList} from './variants/rooms/RoomsList';
import {RulesList} from './variants/rules/RulesList';
import {RulesCards} from './variants/rules/RulesCards';
import {BookingCard} from './variants/booking/BookingCard';
import {BookingBanner} from './variants/booking/BookingBanner';
import {MenuList} from './variants/menu/MenuList';
import {MenuGrid} from './variants/menu/MenuGrid';
import {MenuPriceTable} from './variants/menu/MenuPriceTable';
import {SpaceZones} from './variants/space/SpaceZones';
import {SpaceList} from './variants/space/SpaceList';
import {InquiryCta} from './variants/inquiry/InquiryCta';
import {InquiryForm} from './variants/inquiry/InquiryForm';
import {ProgramsCards} from './variants/programs/ProgramsCards';
import {ProgramsTable} from './variants/programs/ProgramsTable';
import {ExhibitionGallery} from './variants/exhibition/ExhibitionGallery';
import {ExhibitionNotice} from './variants/exhibition/ExhibitionNotice';
// 붙여넣기 아이템 — 데이터가 fact 가 아니라 사장님이 넣은 JSON 에서 온다(dataSpec.ts).
import {SongsTurntable} from './variants/songs/SongsTurntable';
import {DailyCalendar} from './variants/daily/DailyCalendar';
import {CourseTickets} from './variants/course/CourseTickets';
export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
hero: [
{
id: 'hero.editorial',
name: '에디토리얼',
description: '상호 바 + 큰 사진 + 가운데 문구. 분위기부터 보여준다.',
thumb: 'fullbleed',
Component: HeroEditorial,
isDefault: true,
},
{
id: 'hero.split',
name: '좌우 분할',
description: '왼쪽 글 · 오른쪽 사진. 무엇을 파는 곳인지 글로 먼저 읽힌다.',
thumb: 'split',
Component: HeroSplit,
},
{
id: 'hero.full-bleed',
name: '풀스크린 사진',
description: '사진을 화면 끝까지. 사진 한 장의 힘이 강할 때.',
thumb: 'fullbleed',
Component: HeroFullBleed,
},
{
id: 'hero.type-only',
name: '타이포 중심',
description: '사진 없이 여백과 글자만. 쓸 사진이 없거나 조용한 인상을 원할 때.',
thumb: 'centered',
Component: HeroTypeOnly,
},
],
intro: [
{
id: 'intro.story',
name: '스토리',
description: '넓은 사진 아래로 문단이 흐르는 잡지식 구성.',
thumb: 'stack',
Component: IntroStory,
isDefault: true,
},
{
id: 'intro.centered',
name: '가운데 정렬',
description: '짧은 문단 하나. 가장 조용하고 짧다.',
thumb: 'centered',
Component: IntroCentered,
},
{
id: 'intro.side-by-side',
name: '사진 옆 글',
description: '세로 사진 옆에 문단과 특징 목록.',
thumb: 'split',
Component: IntroSideBySide,
},
],
info: [
{
id: 'info.table',
name: '표',
description: '라벨과 값을 선으로 나눈다. 항목이 많아도 안 흔들린다.',
thumb: 'table',
Component: InfoTable,
isDefault: true,
},
{
id: 'info.cards',
name: '카드',
description: '항목마다 한 장. 모바일에서 훑기 좋다.',
thumb: 'cards',
Component: InfoCards,
},
{
id: 'info.inline',
name: '한 줄 요약',
description: '값들을 이어 붙여 가장 좁게. 정보가 적을 때.',
thumb: 'compact',
Component: InfoInline,
},
],
photos: [
{
id: 'photos.grid',
name: '정사각 격자',
description: '같은 크기로 정렬. 장수가 많을수록 정돈돼 보인다.',
thumb: 'grid',
Component: PhotosGrid,
isDefault: true,
},
{
id: 'photos.masonry',
name: '벽돌쌓기',
description: '원본 비율 그대로 어긋나게. 사진을 잘라내지 않는다.',
thumb: 'masonry',
Component: PhotosMasonry,
},
{
id: 'photos.carousel',
name: '가로 슬라이드',
description: '큰 사진을 한 장씩. 장수가 적고 한 장이 좋을 때.',
thumb: 'carousel',
Component: PhotosCarousel,
},
{
id: 'photos.with-videos',
name: '사진 + 영상',
description: '격자 갤러리 아래 YouTube 영상까지. 영상이 더 잘 팔릴 때.',
thumb: 'stack',
Component: PhotosWithVideos,
},
],
map: [
{
id: 'map.detailed',
name: '상세 안내',
description: '주소 + 거점별 소요시간 표 + 주차/충전. 차로 오는 손님이 많을 때.',
thumb: 'table',
Component: MapDetailed,
isDefault: true,
},
{
id: 'map.compact',
name: '주소만',
description: '주소 한 줄과 길찾기 버튼. 도심처럼 찾기 쉬운 곳.',
thumb: 'compact',
Component: MapCompact,
},
],
local: [
{
id: 'local.full',
name: '전체',
description: '날씨 + 맛집 + 명소 + 축제를 전부 세로로.',
thumb: 'stack',
Component: LocalFull,
isDefault: true,
},
{
id: 'local.tabs',
name: '탭 전환',
description: '맛집/명소/축제를 탭으로. 화면 길이를 1/3 로 줄인다.',
thumb: 'accordion',
Component: LocalTabs,
},
{
id: 'local.compact',
name: '요약',
description: '날씨 한 줄과 추천 6곳만. 주인공이 아닐 때.',
thumb: 'compact',
Component: LocalCompact,
},
],
weather: [
{
id: 'weather.card',
name: '날씨 카드',
description: '현재 기온과 날씨 상태, 추천 문구를 한 장에 보여준다.',
thumb: 'compact',
Component: WeatherSection,
isDefault: true,
},
],
faq: [
{
id: 'faq.accordion',
name: '아코디언',
description: '질문만 보이고 누르면 답이 열린다. 화면이 짧다.',
thumb: 'accordion',
Component: FaqAccordion,
isDefault: true,
},
{
id: 'faq.open-list',
name: '펼친 목록',
description: '답까지 전부 열어 둔다. 훑고 지나가게 하고 싶을 때.',
thumb: 'list',
Component: FaqOpenList,
},
{
id: 'faq.two-column',
name: '2단',
description: '넓은 화면에서 두 줄씩. 세로 길이가 반으로 준다.',
thumb: 'two-column',
Component: FaqTwoColumn,
},
],
rooms: [
{
id: 'rooms.carousel',
name: '가로 슬라이드',
description: '모바일에서 한 칸씩 스와이프. 객실 2~4개일 때 자연스럽다.',
thumb: 'carousel',
Component: RoomsCarousel,
isDefault: true,
},
{
id: 'rooms.grid',
name: '격자',
description: '전부 한눈에. 객실이 많고 비교해서 고르게 할 때.',
thumb: 'cards',
Component: RoomsGrid,
},
{
id: 'rooms.list',
name: '가로 목록',
description: '사진 왼쪽 · 설명 오른쪽. 객실 설명이 길 때.',
thumb: 'list',
Component: RoomsList,
},
],
rules: [
{
id: 'rules.list',
name: '목록',
description: '한 덩어리로 짧게. 가장 눈에 덜 띈다.',
thumb: 'compact',
Component: RulesList,
isDefault: true,
},
{
id: 'rules.cards',
name: '번호 카드',
description: '규정 하나에 칸 하나. 클레임이 잦은 곳에.',
thumb: 'cards',
Component: RulesCards,
},
],
booking: [
{
id: 'booking.card',
name: '안내 카드',
description: '가운데 놓인 조용한 카드. 흐름을 끊지 않는다.',
thumb: 'centered',
Component: BookingCard,
isDefault: true,
},
{
id: 'booking.banner',
name: '띠배너',
description: '브랜드 색으로 화면을 가로지르는 강한 유도.',
thumb: 'banner',
Component: BookingBanner,
},
],
menu: [
{
id: 'menu.list',
name: '메뉴판',
description: '이름과 가격을 한 줄씩. 종이 메뉴판처럼.',
thumb: 'list',
Component: MenuList,
isDefault: true,
},
{
id: 'menu.grid',
name: '카드 격자',
description: '설명을 넉넉히. 시그니처가 몇 개뿐일 때.',
thumb: 'cards',
Component: MenuGrid,
},
{
id: 'menu.price-table',
name: '가격표',
description: '점선으로 이름과 가격만. 품목이 많을 때.',
thumb: 'table',
Component: MenuPriceTable,
},
],
space: [
{
id: 'space.zones',
name: '구역 카드',
description: '사진과 함께 자리별로. 좌석 구성이 궁금한 곳에.',
thumb: 'cards',
Component: SpaceZones,
isDefault: true,
},
{
id: 'space.list',
name: '목록',
description: '사진 없이 이름과 설명만. 사진이 부실할 때.',
thumb: 'list',
Component: SpaceList,
},
],
inquiry: [
{
id: 'inquiry.cta',
name: '연락 카드',
description: '전화 · 카카오로 바로. 폼을 받을 준비가 안 됐을 때.',
thumb: 'centered',
Component: InquiryCta,
isDefault: true,
},
{
id: 'inquiry.form',
name: '문의 양식',
description: '인원 · 일정 · 연락처를 받는 신청서.',
thumb: 'form',
Component: InquiryForm,
},
],
programs: [
{
id: 'programs.cards',
name: '카드',
description: '소요시간 · 대상까지 한 장씩. 처음 오는 손님이 고르기 쉽다.',
thumb: 'cards',
Component: ProgramsCards,
isDefault: true,
},
{
id: 'programs.table',
name: '비교표',
description: '시간 · 대상 · 가격을 열로. 조건을 견줘 고르는 곳에.',
thumb: 'table',
Component: ProgramsTable,
},
],
songs: [
{
id: 'songs.turntable',
name: '턴테이블',
description: '도넛판을 눌러 얹으면 판이 돈다. 곡 3~8개일 때 가장 좋다.',
thumb: 'carousel',
Component: SongsTurntable,
isDefault: true,
},
],
daily: [
{
id: 'daily.calendar',
name: '일력',
description: '오늘 날짜에 맞는 한 장이 자동으로 펼쳐진다. 매일 바뀌니 다시 올 이유가 된다.',
thumb: 'compact',
Component: DailyCalendar,
isDefault: true,
},
],
course: [
{
id: 'course.tickets',
name: '승차권',
description: '정거장 하나가 표 한 장. 마지막 표에 완주 도장이 찍힌다.',
thumb: 'carousel',
Component: CourseTickets,
isDefault: true,
},
],
exhibition: [
{
id: 'exhibition.gallery',
name: '전시 갤러리',
description: '전시 사진을 크게 깔고 관람 정보를 붙인다.',
thumb: 'grid',
Component: ExhibitionGallery,
isDefault: true,
},
{
id: 'exhibition.notice',
name: '관람 공지',
description: '사진 없이 안내 문구만. 일정이 자주 바뀌는 곳에.',
thumb: 'timeline',
Component: ExhibitionNotice,
},
],
};
/** 그 업종에서 고를 수 있는 배리에이션. 업종 제한이 걸린 건 걸러낸다. */
export function variantsFor(sectionType: string, industry: IndustryType): SectionVariant[] {
const all = SECTION_VARIANTS[sectionType] ?? [];
return all.filter((v) => !v.industries || v.industries.includes(industry));
}
/** 그 타입의 기본 배리에이션. isDefault 가 없으면 첫 번째. */
export function defaultVariant(sectionType: string): SectionVariant | undefined {
const all = SECTION_VARIANTS[sectionType] ?? [];
return all.find((v) => v.isDefault) ?? all[0];
}
/**
* 실제로 그릴 배리에이션.
*
* 저장된 variantId 가 지워졌거나(배리에이션을 뺐거나) 업종이 바뀌어 못 쓰게 됐어도
* 기본값으로 떨어진다 — 빈 화면 대신 무언가는 반드시 그린다.
*/
export function resolveVariant(
section: SectionItem,
industry: IndustryType,
): SectionVariant | undefined {
const available = variantsFor(section.type, industry);
const picked = available.find((v) => v.id === section.variantId);
return picked ?? defaultVariant(section.type);
}