o2o-site-AEO/solution/frontend/src/features/builder/canvas/registry.ts
Mina Choi 6b9e01d876 [feat] solution: 지역 읽기 섹션 · 엽서 공유를 풀고 · 기존 사이트는 자산 주소만 갈아 끼운다
세 가지가 한 줄기다 — 목업에만 있던 것을 제품으로 옮기면서, 그게 이미 나가 있는
사이트를 건드리지 않게 하는 데까지가 한 변경이다.

① 지역 읽기(mockup/README T7) — 목업은 주입 스크립트로 그렸고 렌더러엔 없었다.
   새로 발행한 업장에서는 영영 빈자리였다(`daily` 가 프롬프트를 빌더에만 둬서 서버가
   그 종류를 몰랐던 것과 같은 사고).
   · shared: `ReadingItem` · `SECTION_ITEM_REQUIRED_KEY.reading` · `reading` 프롬프트
     (프롬프트 단일 출처는 `section-prompts.ts` 하나다 — 코드에 문장을 박지 않는다)
   · backend: STORY_KINDS 등록. `_SEARCH_LINK_KINDS` — 이 종류는 모델의 URL 을 안 받고
     제목으로 만든 네이버 검색 링크를 코드가 붙인다(주소를 짐작해 적으면 없는 문서로 간다)
   · site: '지역 이야기' 여섯 번째 탭. 구운 HTML 은 앞에서 여섯 꼭지, 붙은 뒤 한 번 섞는다
   · 탭 이름은 `{지명} 읽기` — '군산' 을 코드에 박지 않는다

② 엽서 공유가 모든 발행 사이트에서 막혀 있던 것. 사진이 `*.pstatic.net` ·
   `tong.visitkorea.or.kr` 에 있고 그쪽이 `Access-Control-Allow-Origin` 을 안 준다
   (실측 세 곳 모두 없음) — 캔버스가 오염돼 `toBlob` 이 죽는다. 클라이언트에서는 못 넘는다.
   · `prerender.ts mirrorMedia`: 굽기 전에 `s/<slug>/img/<주소해시>.<확장자>` 로 받고
     payload 주소를 우리 오리진 절대주소로 바꾼다(og:image·JSON-LD 도 같은 값을 쓴다)
   · 못 받으면 원래 주소를 쓴다. 파일명이 주소 해시라 다시 구워도 안 받는다
   · `originUrl`·`sourceType` 은 그대로 — DECISIONS 1-2 가 "불가" 면 CRAWL 제외가 먹어야 한다
   · 엽서 미리보기를 240px 로 묶었다(대표: "엽서 ui 너무 큼")

③ **기동이 전부 다시 굽지 않는다** (대표: "전체 재굽기 할 필요가 없어, 사장님이
   재발행하면 끝인데 / css js만 안 깨지게 하란 말이야").
   렌더러를 한 줄 고칠 때마다 이미 나가 있는 사이트의 HTML 이 통째로 바뀌던 자리다.
   · `watch-payloads.mjs`: 기동 = `--refresh-assets` 하나. 한 번도 안 구워진 payload 만 굽는다
   · `prerender.ts refreshBakedAssets`: 구워진 HTML 의 `assets/index-<해시>.css|js` 파일명만
     새 번들로 바꾼다. 내용·payload·접두사는 그대로. 보호 슬러그는 건너뛴다
   · 그래서 ①②는 **다음 발행 때** 그 사이트에 들어간다

문서: AGENTS.md 함정 둘(사진 내려받기 · 기동은 안 굽는다) 추가, 전체 재굽기를 전제하던
옛 항목 둘을 고쳤다. mockup/README T7 은 "제품에 들어갔다" 로, DATA_MODEL 의 STORY kind 목록 갱신.

검증: tsc·eslint 통과(site·frontend), site 79 passed(읽기 4건 추가).
실측 — buru 굽기: 사진 10장 내려받고 og:image 가 우리 주소, 재굽기 때 0건;
`--refresh-assets`: 옛 해시로 바꿔 둔 index.html 1곳이 새 번들 주소로 바뀌고 내용은 그대로.
백엔드 테스트는 이 기계의 5432 가 다른 터널에 물려 있어 못 돌렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 09:31:19 +09:00

518 lines
15 KiB
TypeScript

/**
* 배리에이션 레지스트리 — "이 섹션 타입에 어떤 레이아웃들이 있나"의 단일 출처.
*
* 캔버스 렌더러와 [디자인] 탭이 같은 표를 본다. 배리에이션을 하나 추가한다는 건
* 여기 한 줄을 더한다는 뜻이고, 그 순간 캔버스와 고르는 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<string, SectionVariant[]> = {
/*
* 히어로.
*
* ★ 발행본이 실제로 해석하는 것과 **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);
}