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건 확인
483 lines
14 KiB
TypeScript
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);
|
|
}
|