/** * 배리에이션 레지스트리 — "이 섹션 타입에 어떤 레이아웃들이 있나"의 단일 출처. * * 캔버스 렌더러와 [디자인] 탭이 같은 표를 본다. 배리에이션을 하나 추가한다는 건 * 여기 한 줄을 더한다는 뜻이고, 그 순간 캔버스와 고르는 UI가 동시에 늘어난다. */ import type {IndustryType, SectionItem} from '@o2o/shared'; import type {SectionVariant} from './types'; import {HeroCover} from './variants/hero/HeroCover'; import {HeroSplit} from './variants/hero/HeroSplit'; 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 {ItineraryTickets} from './variants/itinerary/ItineraryTickets'; import {VideoFrame} from './variants/video/VideoFrame'; import {LocalGuide} from './variants/local/LocalGuide'; 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 {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 {PeopleFilmstrip} from './variants/people/PeopleFilmstrip'; import {ChronicleRail} from './variants/chronicle/ChronicleRail'; import {ReadingRail} from './variants/reading/ReadingRail'; import {PostcardStack} from './variants/postcard/PostcardStack'; import {QuizFlip} from './variants/quiz/QuizFlip'; export const SECTION_VARIANTS: Record = { /* * 히어로. * * ★ 발행본이 실제로 해석하는 것과 **1:1 이어야 한다**(`site/src/sections/HeroSection.tsx`). * 예전에는 에디토리얼·풀스크린·타이포 셋을 더 보여줬는데 발행본에 짝이 없어서, * 무엇을 골라도 발행본은 기본(표지)으로 나왔다 — 고르는 의미가 없는 선택지였다. * 여기 늘릴 때는 발행본 쪽 분기도 같이 늘린다. */ hero: [ { id: 'hero.cover', name: '표지', description: '사진 한 장 위, 상호는 좌하단. 사진의 피사체를 글자가 가리지 않는다.', thumb: 'fullbleed', Component: HeroCover, isDefault: true, }, { id: 'hero.split', name: '좌우 분할', description: '왼쪽 글 · 오른쪽 사진. 무엇을 파는 곳인지 글로 먼저 읽힌다.', thumb: 'split', Component: HeroSplit, }, ], 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, }, ], // ★ 전체/탭/요약 세 개를 하나로 통일했다(2026-09-07). 저장된 옛 id(local.tabs·local.compact)는 // resolveVariant 가 기본값으로 떨어뜨리므로 기존 사이트가 깨지지 않는다. local: [ { id: 'local.guide', name: '가이드', description: '맛집 · 명소 · 축제를 도보 시간으로 걸러 카드로 넘겨 본다.', thumb: 'stack', Component: LocalGuide, isDefault: true, }, ], 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, }, ], 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, }, ], people: [ { id: 'people.filmstrip', name: '필름 스트립', description: '프레임 하나가 인물 하나. 사진이 없으면 이름 활자가 대신 들어간다.', thumb: 'carousel', Component: PeopleFilmstrip, isDefault: true, }, ], chronicle: [ { id: 'chronicle.rail', name: '가로 연표', description: '연도가 큰 활자로 서고 사건이 붙는다. 붉은 점이 도시를 바꾼 해다.', thumb: 'timeline', Component: ChronicleRail, isDefault: true, }, ], reading: [ { id: 'reading.posts', name: '읽기 카드', description: '한 슬라이드에 한 꼭지. 발행본은 이 중 5~6개만 매번 무작위로 세운다.', thumb: 'carousel', Component: ReadingRail, isDefault: true, }, ], postcard: [ { id: 'postcard.stack', name: '엽서 뒷면', description: '우표 자리와 소인이 찍힌 뒷면 한 장. [복사] 로 손님이 그대로 퍼 간다.', thumb: 'carousel', Component: PostcardStack, isDefault: true, }, ], quiz: [ { id: 'quiz.flip', name: '시험지 플립', description: '앞면은 질문, 뒤집으면 힌트와 출처. 정답은 두지 않는다.', thumb: 'cards', Component: QuizFlip, isDefault: true, }, ], exhibition: [ { id: 'exhibition.gallery', name: '전시 갤러리', description: '전시 사진을 크게 깔고 관람 정보를 붙인다.', thumb: 'grid', Component: ExhibitionGallery, isDefault: true, }, { id: 'exhibition.notice', name: '관람 공지', description: '사진 없이 안내 문구만. 일정이 자주 바뀌는 곳에.', thumb: 'timeline', Component: ExhibitionNotice, }, ], video: [ { id: 'video.frame', name: '영상 한 편', description: '표지를 먼저 보여주고 누르면 재생한다. 세로 영상(쇼츠)은 세로 틀로 나간다.', thumb: 'fullbleed', Component: VideoFrame, isDefault: true, }, ], itinerary: [ { id: 'itinerary.tickets', name: '승차권', description: '절취선 뚫린 표가 줄줄이. 표 한 장이 정거장 하나이고 마지막에 완주 도장이 찍힌다.', thumb: 'carousel', Component: ItineraryTickets, isDefault: true, }, ], }; /** 그 업종에서 고를 수 있는 배리에이션. 업종 제한이 걸린 건 걸러낸다. */ 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); }