From 013cdcfc9f767cf0bf176bca85e1d7d035e5deaf Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 30 Sep 2026 13:45:10 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/site,shared:=20=EC=88=99?= =?UTF-8?q?=EB=B0=95=20=ED=85=9C=ED=94=8C=EB=A6=BF=208=EC=A2=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=E2=80=94=20=EB=9D=BC=EC=9A=B4=EB=93=9C?= =?UTF-8?q?=C2=B7=EC=8B=9C=EB=84=A4=EB=A7=88=C2=B7=EB=B9=85=ED=83=80?= =?UTF-8?q?=EC=9D=B4=ED=8F=AC=C2=B7=EB=B6=80=ED=8B=B0=ED=81=AC=C2=B7?= =?UTF-8?q?=EC=9D=BC=EB=9F=AC=EC=8A=A4=ED=8A=B8=C2=B7=EC=BD=94=EB=9E=84?= =?UTF-8?q?=C2=B7=EB=AF=B8=EB=8B=88=EB=A9=80=C2=B7=EC=86=94=EC=88=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사장님이 고를 수 있는 숙박 템플릿이 고택 하나뿐이었다. 모두 stay2 콘텐츠(날씨·미니 블로그· 지역 이야기·객실·축제·일정·후기·엽서·노래)를 그대로 싣고 모양만 다르다. - layouts//: 템플릿별 Frame·섹션·CSS. 데스크톱은 넓은 배치(1024px 이상), 모바일 우선 - layouts/kit/kit.css: 탭 전환·시트·예약 폼·접기 등 공통 구조 - shared templates.json · builder.ts · catalog.ts: 템플릿·LayoutId 등록 - seo/head.ts: 템플릿별 한글 웹폰트 — 서로 겹치지 않게 배정 - paper Frame·Intro: 같은 탭을 다시 눌러도 맨 위로, 미니 블로그를 글 여러 개로 - docs/TEMPLATES.md · TEMPLATE_DESIGN.md: 목록과 화면 규칙(간격·접기·✓ 표시·글꼴) eslint·tsc 통과, vitest 123 passed, 템플릿 9개 굽기 성공 · 간격 검사 0건 --- docs/DEVELOPMENT_DIRECTION.md | 214 --------- docs/TEMPLATES.md | 18 +- docs/TEMPLATE_DESIGN.md | 106 ++++ solution/shared/src/data/templates.json | 210 +++++++- solution/shared/src/lib/catalog.ts | 2 +- solution/shared/src/types/builder.ts | 2 +- solution/site/.impeccable/config.json | 14 + solution/site/src/layouts/bigtype/Around.tsx | 144 ++++++ solution/site/src/layouts/bigtype/Booking.tsx | 63 +++ solution/site/src/layouts/bigtype/Faq.tsx | 41 ++ .../site/src/layouts/bigtype/Festival.tsx | 72 +++ solution/site/src/layouts/bigtype/Frame.tsx | 276 +++++++++++ solution/site/src/layouts/bigtype/Gallery.tsx | 53 ++ solution/site/src/layouts/bigtype/Hero.tsx | 72 +++ solution/site/src/layouts/bigtype/Info.tsx | 96 ++++ solution/site/src/layouts/bigtype/Intro.tsx | 118 +++++ .../site/src/layouts/bigtype/Itinerary.tsx | 102 ++++ .../site/src/layouts/bigtype/Location.tsx | 74 +++ solution/site/src/layouts/bigtype/Rooms.tsx | 136 ++++++ .../site/src/layouts/bigtype/SectionHead.tsx | 23 + solution/site/src/layouts/bigtype/Story.tsx | 203 ++++++++ solution/site/src/layouts/bigtype/Tabs.tsx | 26 + solution/site/src/layouts/bigtype/Video.tsx | 35 ++ solution/site/src/layouts/bigtype/Weather.tsx | 60 +++ solution/site/src/layouts/bigtype/bigtype.css | 286 +++++++++++ solution/site/src/layouts/bigtype/index.ts | 45 ++ solution/site/src/layouts/bigtype/useWide.ts | 13 + solution/site/src/layouts/bigtype/wordmark.ts | 16 + solution/site/src/layouts/boutique/Around.tsx | 145 ++++++ .../site/src/layouts/boutique/Booking.tsx | 69 +++ solution/site/src/layouts/boutique/Faq.tsx | 35 ++ .../site/src/layouts/boutique/Festival.tsx | 75 +++ solution/site/src/layouts/boutique/Frame.tsx | 385 +++++++++++++++ .../site/src/layouts/boutique/Gallery.tsx | 46 ++ solution/site/src/layouts/boutique/Hero.tsx | 94 ++++ solution/site/src/layouts/boutique/Info.tsx | 82 ++++ solution/site/src/layouts/boutique/Intro.tsx | 123 +++++ .../site/src/layouts/boutique/Itinerary.tsx | 90 ++++ .../site/src/layouts/boutique/Location.tsx | 70 +++ solution/site/src/layouts/boutique/Rooms.tsx | 138 ++++++ .../site/src/layouts/boutique/SectionHead.tsx | 35 ++ solution/site/src/layouts/boutique/Story.tsx | 208 ++++++++ solution/site/src/layouts/boutique/Video.tsx | 33 ++ .../site/src/layouts/boutique/Weather.tsx | 70 +++ .../site/src/layouts/boutique/boutique.css | 347 ++++++++++++++ solution/site/src/layouts/boutique/icons.tsx | 50 ++ solution/site/src/layouts/boutique/index.ts | 45 ++ solution/site/src/layouts/boutique/ui.tsx | 154 ++++++ solution/site/src/layouts/cinema/Around.tsx | 154 ++++++ solution/site/src/layouts/cinema/Booking.tsx | 69 +++ solution/site/src/layouts/cinema/Faq.tsx | 35 ++ solution/site/src/layouts/cinema/Festival.tsx | 75 +++ solution/site/src/layouts/cinema/Frame.tsx | 332 +++++++++++++ solution/site/src/layouts/cinema/Gallery.tsx | 46 ++ solution/site/src/layouts/cinema/Hero.tsx | 89 ++++ solution/site/src/layouts/cinema/Info.tsx | 82 ++++ solution/site/src/layouts/cinema/Intro.tsx | 103 ++++ .../site/src/layouts/cinema/Itinerary.tsx | 90 ++++ solution/site/src/layouts/cinema/Location.tsx | 70 +++ solution/site/src/layouts/cinema/Rooms.tsx | 130 +++++ .../site/src/layouts/cinema/SectionHead.tsx | 39 ++ solution/site/src/layouts/cinema/Story.tsx | 201 ++++++++ solution/site/src/layouts/cinema/Video.tsx | 33 ++ solution/site/src/layouts/cinema/Weather.tsx | 65 +++ solution/site/src/layouts/cinema/cinema.css | 298 ++++++++++++ solution/site/src/layouts/cinema/icons.tsx | 50 ++ solution/site/src/layouts/cinema/index.ts | 45 ++ solution/site/src/layouts/cinema/useWide.ts | 13 + solution/site/src/layouts/coral/Around.tsx | 172 +++++++ solution/site/src/layouts/coral/Booking.tsx | 61 +++ solution/site/src/layouts/coral/Faq.tsx | 37 ++ solution/site/src/layouts/coral/Frame.tsx | 314 ++++++++++++ solution/site/src/layouts/coral/Gallery.tsx | 48 ++ solution/site/src/layouts/coral/Hero.tsx | 95 ++++ solution/site/src/layouts/coral/Intro.tsx | 81 ++++ solution/site/src/layouts/coral/Location.tsx | 75 +++ solution/site/src/layouts/coral/Rooms.tsx | 135 ++++++ .../site/src/layouts/coral/SectionHead.tsx | 39 ++ solution/site/src/layouts/coral/Weather.tsx | 78 +++ solution/site/src/layouts/coral/coral.css | 449 +++++++++++++++++ solution/site/src/layouts/coral/icons.tsx | 69 +++ solution/site/src/layouts/coral/index.ts | 45 ++ solution/site/src/layouts/coral/useWide.ts | 13 + solution/site/src/layouts/graphic/Around.tsx | 163 +++++++ solution/site/src/layouts/graphic/Art.tsx | 255 ++++++++++ solution/site/src/layouts/graphic/Booking.tsx | 61 +++ solution/site/src/layouts/graphic/Faq.tsx | 37 ++ .../site/src/layouts/graphic/Festival.tsx | 75 +++ solution/site/src/layouts/graphic/Frame.tsx | 286 +++++++++++ solution/site/src/layouts/graphic/Gallery.tsx | 47 ++ solution/site/src/layouts/graphic/Hero.tsx | 156 ++++++ solution/site/src/layouts/graphic/Info.tsx | 87 ++++ solution/site/src/layouts/graphic/Intro.tsx | 69 +++ .../site/src/layouts/graphic/Itinerary.tsx | 90 ++++ .../site/src/layouts/graphic/Location.tsx | 83 ++++ solution/site/src/layouts/graphic/Rooms.tsx | 152 ++++++ .../site/src/layouts/graphic/SectionHead.tsx | 21 + solution/site/src/layouts/graphic/Story.tsx | 198 ++++++++ solution/site/src/layouts/graphic/Video.tsx | 33 ++ solution/site/src/layouts/graphic/graphic.css | 336 +++++++++++++ solution/site/src/layouts/graphic/index.ts | 45 ++ solution/site/src/layouts/graphic/useWide.ts | 13 + solution/site/src/layouts/index.ts | 16 + solution/site/src/layouts/kit/kit.css | 102 ++++ solution/site/src/layouts/layouts.test.tsx | 26 + solution/site/src/layouts/minimal/Around.tsx | 172 +++++++ solution/site/src/layouts/minimal/Booking.tsx | 61 +++ solution/site/src/layouts/minimal/Faq.tsx | 37 ++ solution/site/src/layouts/minimal/Frame.tsx | 297 ++++++++++++ solution/site/src/layouts/minimal/Gallery.tsx | 48 ++ solution/site/src/layouts/minimal/Hero.tsx | 95 ++++ solution/site/src/layouts/minimal/Intro.tsx | 81 ++++ .../site/src/layouts/minimal/Location.tsx | 75 +++ solution/site/src/layouts/minimal/Rooms.tsx | 132 +++++ .../site/src/layouts/minimal/SectionHead.tsx | 39 ++ solution/site/src/layouts/minimal/Weather.tsx | 78 +++ solution/site/src/layouts/minimal/icons.tsx | 69 +++ solution/site/src/layouts/minimal/index.ts | 45 ++ solution/site/src/layouts/minimal/minimal.css | 452 ++++++++++++++++++ solution/site/src/layouts/minimal/useWide.ts | 13 + solution/site/src/layouts/paper/Frame.tsx | 13 +- solution/site/src/layouts/paper/Intro.tsx | 39 +- solution/site/src/layouts/pine/Around.tsx | 167 +++++++ solution/site/src/layouts/pine/Booking.tsx | 61 +++ solution/site/src/layouts/pine/Faq.tsx | 37 ++ solution/site/src/layouts/pine/Frame.tsx | 284 +++++++++++ solution/site/src/layouts/pine/Gallery.tsx | 48 ++ solution/site/src/layouts/pine/Hero.tsx | 99 ++++ solution/site/src/layouts/pine/Intro.tsx | 81 ++++ solution/site/src/layouts/pine/Location.tsx | 75 +++ solution/site/src/layouts/pine/Rooms.tsx | 132 +++++ .../site/src/layouts/pine/SectionHead.tsx | 39 ++ solution/site/src/layouts/pine/Weather.tsx | 78 +++ solution/site/src/layouts/pine/icons.tsx | 69 +++ solution/site/src/layouts/pine/index.ts | 45 ++ solution/site/src/layouts/pine/pine.css | 452 ++++++++++++++++++ solution/site/src/layouts/pine/useWide.ts | 13 + solution/site/src/layouts/round/Around.tsx | 172 +++++++ solution/site/src/layouts/round/Booking.tsx | 61 +++ solution/site/src/layouts/round/Faq.tsx | 37 ++ solution/site/src/layouts/round/Frame.tsx | 289 +++++++++++ solution/site/src/layouts/round/Gallery.tsx | 48 ++ solution/site/src/layouts/round/Hero.tsx | 95 ++++ solution/site/src/layouts/round/Intro.tsx | 81 ++++ solution/site/src/layouts/round/Location.tsx | 75 +++ solution/site/src/layouts/round/Rooms.tsx | 132 +++++ .../site/src/layouts/round/SectionHead.tsx | 39 ++ solution/site/src/layouts/round/Weather.tsx | 78 +++ solution/site/src/layouts/round/icons.tsx | 69 +++ solution/site/src/layouts/round/index.ts | 45 ++ solution/site/src/layouts/round/round.css | 339 +++++++++++++ solution/site/src/layouts/round/useWide.ts | 13 + solution/site/src/seo/head.ts | 18 +- 153 files changed, 15672 insertions(+), 240 deletions(-) delete mode 100644 docs/DEVELOPMENT_DIRECTION.md create mode 100644 docs/TEMPLATE_DESIGN.md create mode 100644 solution/site/.impeccable/config.json create mode 100644 solution/site/src/layouts/bigtype/Around.tsx create mode 100644 solution/site/src/layouts/bigtype/Booking.tsx create mode 100644 solution/site/src/layouts/bigtype/Faq.tsx create mode 100644 solution/site/src/layouts/bigtype/Festival.tsx create mode 100644 solution/site/src/layouts/bigtype/Frame.tsx create mode 100644 solution/site/src/layouts/bigtype/Gallery.tsx create mode 100644 solution/site/src/layouts/bigtype/Hero.tsx create mode 100644 solution/site/src/layouts/bigtype/Info.tsx create mode 100644 solution/site/src/layouts/bigtype/Intro.tsx create mode 100644 solution/site/src/layouts/bigtype/Itinerary.tsx create mode 100644 solution/site/src/layouts/bigtype/Location.tsx create mode 100644 solution/site/src/layouts/bigtype/Rooms.tsx create mode 100644 solution/site/src/layouts/bigtype/SectionHead.tsx create mode 100644 solution/site/src/layouts/bigtype/Story.tsx create mode 100644 solution/site/src/layouts/bigtype/Tabs.tsx create mode 100644 solution/site/src/layouts/bigtype/Video.tsx create mode 100644 solution/site/src/layouts/bigtype/Weather.tsx create mode 100644 solution/site/src/layouts/bigtype/bigtype.css create mode 100644 solution/site/src/layouts/bigtype/index.ts create mode 100644 solution/site/src/layouts/bigtype/useWide.ts create mode 100644 solution/site/src/layouts/bigtype/wordmark.ts create mode 100644 solution/site/src/layouts/boutique/Around.tsx create mode 100644 solution/site/src/layouts/boutique/Booking.tsx create mode 100644 solution/site/src/layouts/boutique/Faq.tsx create mode 100644 solution/site/src/layouts/boutique/Festival.tsx create mode 100644 solution/site/src/layouts/boutique/Frame.tsx create mode 100644 solution/site/src/layouts/boutique/Gallery.tsx create mode 100644 solution/site/src/layouts/boutique/Hero.tsx create mode 100644 solution/site/src/layouts/boutique/Info.tsx create mode 100644 solution/site/src/layouts/boutique/Intro.tsx create mode 100644 solution/site/src/layouts/boutique/Itinerary.tsx create mode 100644 solution/site/src/layouts/boutique/Location.tsx create mode 100644 solution/site/src/layouts/boutique/Rooms.tsx create mode 100644 solution/site/src/layouts/boutique/SectionHead.tsx create mode 100644 solution/site/src/layouts/boutique/Story.tsx create mode 100644 solution/site/src/layouts/boutique/Video.tsx create mode 100644 solution/site/src/layouts/boutique/Weather.tsx create mode 100644 solution/site/src/layouts/boutique/boutique.css create mode 100644 solution/site/src/layouts/boutique/icons.tsx create mode 100644 solution/site/src/layouts/boutique/index.ts create mode 100644 solution/site/src/layouts/boutique/ui.tsx create mode 100644 solution/site/src/layouts/cinema/Around.tsx create mode 100644 solution/site/src/layouts/cinema/Booking.tsx create mode 100644 solution/site/src/layouts/cinema/Faq.tsx create mode 100644 solution/site/src/layouts/cinema/Festival.tsx create mode 100644 solution/site/src/layouts/cinema/Frame.tsx create mode 100644 solution/site/src/layouts/cinema/Gallery.tsx create mode 100644 solution/site/src/layouts/cinema/Hero.tsx create mode 100644 solution/site/src/layouts/cinema/Info.tsx create mode 100644 solution/site/src/layouts/cinema/Intro.tsx create mode 100644 solution/site/src/layouts/cinema/Itinerary.tsx create mode 100644 solution/site/src/layouts/cinema/Location.tsx create mode 100644 solution/site/src/layouts/cinema/Rooms.tsx create mode 100644 solution/site/src/layouts/cinema/SectionHead.tsx create mode 100644 solution/site/src/layouts/cinema/Story.tsx create mode 100644 solution/site/src/layouts/cinema/Video.tsx create mode 100644 solution/site/src/layouts/cinema/Weather.tsx create mode 100644 solution/site/src/layouts/cinema/cinema.css create mode 100644 solution/site/src/layouts/cinema/icons.tsx create mode 100644 solution/site/src/layouts/cinema/index.ts create mode 100644 solution/site/src/layouts/cinema/useWide.ts create mode 100644 solution/site/src/layouts/coral/Around.tsx create mode 100644 solution/site/src/layouts/coral/Booking.tsx create mode 100644 solution/site/src/layouts/coral/Faq.tsx create mode 100644 solution/site/src/layouts/coral/Frame.tsx create mode 100644 solution/site/src/layouts/coral/Gallery.tsx create mode 100644 solution/site/src/layouts/coral/Hero.tsx create mode 100644 solution/site/src/layouts/coral/Intro.tsx create mode 100644 solution/site/src/layouts/coral/Location.tsx create mode 100644 solution/site/src/layouts/coral/Rooms.tsx create mode 100644 solution/site/src/layouts/coral/SectionHead.tsx create mode 100644 solution/site/src/layouts/coral/Weather.tsx create mode 100644 solution/site/src/layouts/coral/coral.css create mode 100644 solution/site/src/layouts/coral/icons.tsx create mode 100644 solution/site/src/layouts/coral/index.ts create mode 100644 solution/site/src/layouts/coral/useWide.ts create mode 100644 solution/site/src/layouts/graphic/Around.tsx create mode 100644 solution/site/src/layouts/graphic/Art.tsx create mode 100644 solution/site/src/layouts/graphic/Booking.tsx create mode 100644 solution/site/src/layouts/graphic/Faq.tsx create mode 100644 solution/site/src/layouts/graphic/Festival.tsx create mode 100644 solution/site/src/layouts/graphic/Frame.tsx create mode 100644 solution/site/src/layouts/graphic/Gallery.tsx create mode 100644 solution/site/src/layouts/graphic/Hero.tsx create mode 100644 solution/site/src/layouts/graphic/Info.tsx create mode 100644 solution/site/src/layouts/graphic/Intro.tsx create mode 100644 solution/site/src/layouts/graphic/Itinerary.tsx create mode 100644 solution/site/src/layouts/graphic/Location.tsx create mode 100644 solution/site/src/layouts/graphic/Rooms.tsx create mode 100644 solution/site/src/layouts/graphic/SectionHead.tsx create mode 100644 solution/site/src/layouts/graphic/Story.tsx create mode 100644 solution/site/src/layouts/graphic/Video.tsx create mode 100644 solution/site/src/layouts/graphic/graphic.css create mode 100644 solution/site/src/layouts/graphic/index.ts create mode 100644 solution/site/src/layouts/graphic/useWide.ts create mode 100644 solution/site/src/layouts/kit/kit.css create mode 100644 solution/site/src/layouts/layouts.test.tsx create mode 100644 solution/site/src/layouts/minimal/Around.tsx create mode 100644 solution/site/src/layouts/minimal/Booking.tsx create mode 100644 solution/site/src/layouts/minimal/Faq.tsx create mode 100644 solution/site/src/layouts/minimal/Frame.tsx create mode 100644 solution/site/src/layouts/minimal/Gallery.tsx create mode 100644 solution/site/src/layouts/minimal/Hero.tsx create mode 100644 solution/site/src/layouts/minimal/Intro.tsx create mode 100644 solution/site/src/layouts/minimal/Location.tsx create mode 100644 solution/site/src/layouts/minimal/Rooms.tsx create mode 100644 solution/site/src/layouts/minimal/SectionHead.tsx create mode 100644 solution/site/src/layouts/minimal/Weather.tsx create mode 100644 solution/site/src/layouts/minimal/icons.tsx create mode 100644 solution/site/src/layouts/minimal/index.ts create mode 100644 solution/site/src/layouts/minimal/minimal.css create mode 100644 solution/site/src/layouts/minimal/useWide.ts create mode 100644 solution/site/src/layouts/pine/Around.tsx create mode 100644 solution/site/src/layouts/pine/Booking.tsx create mode 100644 solution/site/src/layouts/pine/Faq.tsx create mode 100644 solution/site/src/layouts/pine/Frame.tsx create mode 100644 solution/site/src/layouts/pine/Gallery.tsx create mode 100644 solution/site/src/layouts/pine/Hero.tsx create mode 100644 solution/site/src/layouts/pine/Intro.tsx create mode 100644 solution/site/src/layouts/pine/Location.tsx create mode 100644 solution/site/src/layouts/pine/Rooms.tsx create mode 100644 solution/site/src/layouts/pine/SectionHead.tsx create mode 100644 solution/site/src/layouts/pine/Weather.tsx create mode 100644 solution/site/src/layouts/pine/icons.tsx create mode 100644 solution/site/src/layouts/pine/index.ts create mode 100644 solution/site/src/layouts/pine/pine.css create mode 100644 solution/site/src/layouts/pine/useWide.ts create mode 100644 solution/site/src/layouts/round/Around.tsx create mode 100644 solution/site/src/layouts/round/Booking.tsx create mode 100644 solution/site/src/layouts/round/Faq.tsx create mode 100644 solution/site/src/layouts/round/Frame.tsx create mode 100644 solution/site/src/layouts/round/Gallery.tsx create mode 100644 solution/site/src/layouts/round/Hero.tsx create mode 100644 solution/site/src/layouts/round/Intro.tsx create mode 100644 solution/site/src/layouts/round/Location.tsx create mode 100644 solution/site/src/layouts/round/Rooms.tsx create mode 100644 solution/site/src/layouts/round/SectionHead.tsx create mode 100644 solution/site/src/layouts/round/Weather.tsx create mode 100644 solution/site/src/layouts/round/icons.tsx create mode 100644 solution/site/src/layouts/round/index.ts create mode 100644 solution/site/src/layouts/round/round.css create mode 100644 solution/site/src/layouts/round/useWide.ts diff --git a/docs/DEVELOPMENT_DIRECTION.md b/docs/DEVELOPMENT_DIRECTION.md deleted file mode 100644 index 04c1a09..0000000 --- a/docs/DEVELOPMENT_DIRECTION.md +++ /dev/null @@ -1,214 +0,0 @@ -# Web4AI 개발 방향 — v19 설계서와 현재 구현 비교 - -> 기준일: 2026-08-31 -> 비교 대상: `Web4AI_SW설계서_및_개발일정_v19.pdf`(22쪽)와 이 저장소의 현재 코드·문서 -> 목적: 설계서를 그대로 복제하는 것이 아니라, 현재 제품에서 **유지할 결정**, **방향을 다시 정할 결정**, **추가할 개발 항목**을 구분한다. - -설계서 표지는 파일명과 달리 `v18 · 2026.08.30`으로 표기되어 있다. 아래에서는 전달받은 파일을 편의상 “v19 설계서”라고 부르되, 계약·일정 확정 전 문서 버전부터 확인해야 한다. - ---- - -## 1. 결론 - -현재 프로젝트는 설계서 전체의 축소판이 아니라, 설계서의 **Site AEO(A1~A8) 일부를 소상공인용 제품으로 먼저 구현한 별도 MVP**에 가깝다. - -- 현재 강점은 `사업장 확인 → 허용된 소스 수집 → fact 승인 → 근거 기반 문구 생성 → 정적 HTML 발행 → IndexNow`가 실제 코드와 테스트로 연결되어 있다는 점이다. -- 가장 큰 공백은 **Brand AEO 전체(B1~B9)**, **규제 검사(A4)**, **소유권 검증(A1)**, **원본 변경·AI 크롤러 재방문 추적(A9)**이다. -- 가장 큰 방향 충돌은 **타겟 업종**, **Playwright 크롤링**, **배포 도메인**, **마이크로서비스·공통 인프라**다. 이 항목은 “미구현”으로 보고 바로 만들면 안 되고 제품·법무·운영 결정을 먼저 내려야 한다. -- 권장 방향은 현 구조를 버리고 5계층/2엔진으로 즉시 재작성하는 것이 아니다. 현재 시스템을 **Site AEO MVP 기준선**으로 유지하고, Brand AEO를 경계가 분명한 모듈로 붙인 뒤 부하와 조직 규모가 실제 분리를 요구할 때 서비스로 분리한다. - -### 현재 범위의 대략적인 위치 - -| 설계서 영역 | 현재 판단 | -|---|---| -| Site AEO A1~A9 | **부분 구현** — A3·A5·A6·A7 일부와 A8 중심 | -| Brand AEO B1~B9 | **미구현** — 준비도 자체 점수만 있으며 실제 AI 응답 측정은 없음 | -| 운영 콘솔 15개 화면 | **부분 구현** — 사업장·빌더·지역정보·SEO/AEO 준비도 중심 | -| 계약 A~G / BFF | **미구현** — 화면이 FastAPI 를 직접 호출 (BFF 없음) | -| 25테이블 append-only Fact Graph | **다른 모델로 구현** — 승인 후보/노출값 중심의 key-value fact 모델 | -| 8개 스프린트 일정 | **현재 코드에 바로 적용 불가** — 이미 구현된 것과 방향 충돌 항목이 섞여 있어 재산정 필요 | - ---- - -## 2. 방향이 다른 부분 - -아래는 단순히 덜 만든 기능이 아니라, 설계서와 현재 프로젝트가 서로 다른 결정을 내린 항목이다. - -| 항목 | v19 설계서 | 현재 프로젝트 | 권장 판단 | -|---|---|---|---| -| 제품 범위 | Site AEO + Brand AEO 이원 플랫폼 | 상호명 기반 소상공인 정적 홈페이지 생성·발행 | 현재 제품을 Site AEO MVP로 명시하고 Brand AEO 확장 여부를 별도 마일스톤으로 승인 | -| 1차 업종 | 숙박, 법무법인, 성형외과 | 숙박, 카페, 음식점, 관광체험 | **반드시 사업 결정 필요.** 법률·의료를 넣으면 데이터 스키마뿐 아니라 규제·승인·감사 체계가 선행되어야 함 | -| 입력 방식 | 고객 원본 사이트를 depth 3·최대 200페이지 크롤 | 공식 API, 사용자 확정 URL, 정적 HTML; 플랫폼 우회 수집 금지 | 현재의 출처·동의·robots 원칙 유지. 대규모 원본 사이트 수집은 별도 제품 모드로 분리 | -| 동적 크롤링 | 정적 우선, Playwright 폴백 | 봇 탐지 우회로 변질될 수 있어 HeadlessAdapter 미등록·금지 | 포괄적인 Playwright 폴백은 채택하지 않음. 소유권이 검증된 고객 도메인에만 허용할지 법무·보안 결정 후 제한적으로 검토 | -| 발행 도메인 | 고객 도메인 서브패스 권장, 서브도메인 차선 | 기본 `web4ai.o2osolution.ai/s/`, custom domain 경로 미완성 | 설계서 방향이 검색 권위 측면에서 더 적합. 고객 도메인 연결·소유권 검증을 우선 과제로 추가 | -| 백엔드 구조 | Site/Brand 엔진, BFF, 엔진별 DB 분리 | 코드 한 벌 + 진입점 둘(:9800 사장님 / :9801 내부), 단일 PostgreSQL | 청중별 분리는 포트로 끝냈다. 엔진별 물리 분리는 트래픽·팀 소유권 근거가 생긴 뒤 수행 | -| 작업 인프라 | Temporal, Redis, Celery 등 공통 인프라 | PostgreSQL 잡 큐 + lease + dead-letter | 현재 DB 큐 유지. 동일 책임의 인프라를 중복 도입하지 않음. 장기 워크플로 보상·분산 추적 요구가 확인될 때 Temporal 재평가 | -| Fact Graph | 엔티티·predicate·snapshot, append-only | 업종 스키마 기반 key-value fact, 후보/노출/이력 상태 | 현재 모델은 발행 안전성에 적합. Brand 측정 재현성에 필요한 snapshot과 entity 관계만 점진적으로 확장 | -| 점수 | Site AEO Score + 실제 4개 AI 엔진 기반 AVS | 내부 데이터 기반 SEO/AEO **준비도** 점수 | 이름과 의미를 분리 유지. 실제 측정 전 현재 점수를 AVS/가시성 점수라고 부르지 않음 | -| 운영 사용자 | Reviewer/Owner 권한과 15개 통합 화면 | USER/OWNER/DEVELOPER, 사장님 앱(:3000)과 내부 콘솔(:3002)이 갈려 있다 | 규제 기능 도입 전 Reviewer 역할·서버 계산 `allowed_actions` 추가 | -| 이미지 | 원본 사진·영상이 EEAT 근거 | 이미지 호스팅을 현재 non-goal로 두고 외부 URL 사용 | 저작권 결론과 소유자 업로드 저장소가 먼저. 크롤 이미지 재게시를 전제로 개발하지 않음 | - ---- - -## 3. 설계서 항목별 구현 차이 - -### 3-1. Site AEO A1~A9 - -| 단계 | 현재 상태 | 코드 근거 | 추가할 것 | -|---|---|---|---| -| A1 사이트 진단·소유권 검증 | **일부** | 사업장 동일 업소 확인과 `verified_at`, SEO 진단은 있으나 DNS TXT/meta/well-known 검증은 없음 | 도메인 소유권 challenge, 만료·재검증, 발행 차단 정책 | -| A2 크롤·추출 | **일부** | collector registry, `StaticHtmlAdapter`, TourAPI, 네이버 장소 조회, 사용자 확정 링크 | 허용 도메인용 crawl run/document 기록, 원본 hash, 제한·재시도·수집 보고서 | -| A3 Fact Graph | **부분 구현** | `facts`, 업종 스키마, 출처·신뢰도·상태 전이, 승인 후보 모델 | source URL의 selector/snippet, entity 관계, 측정용 불변 snapshot | -| A4 규제·과장 검사 | **기초만 존재** | 생성 문구의 과장·근거 없는 숫자/시설 검사는 있으나 업종별 법규 3단 분류와 승인 감사는 없음 | 외부화된 규칙, SAFE/REVIEW/BLOCK 판정, 규칙 버전, 근거 snippet, Reviewer 승인 로그 | -| A5 AEO 콘텐츠 생성 | **구현** | Gemini Text, 소개문·meta·FAQ, 근거 fact key, ground check | 질문은행과 생성 페이지의 연결, 질문형 콘텐츠 단위의 버저닝 | -| A6 JSON-LD | **구현** | 업종별 JSON-LD, FAQPage, Breadcrumb, WebPage, sameAs | 법률·의료 도입 시 타입·자격·저자 스키마 확장 | -| A7 3-way 일치성 | **부분 구현** | JSON-LD↔표시 텍스트 검증, 생성문↔fact 근거 검사, publish gate | 원본↔발행본 SimHash 중복도, 모든 사실 문장의 fact ID 역참조 보고서 | -| A8 배포 | **대부분 구현** | 프리렌더 정적 HTML, canonical, sitemap, robots, llms.txt, IndexNow, nginx/Azure 경로 | 고객 도메인 서브패스·서브도메인 연결, TLS/DNS 자동화, Search Console 제출 자동화 여부 | -| A9 모니터링·변경 감지 | **미구현** | `AI_CHECK` enum 만 있고 worker handler·보고 모듈 없음. 표(`ai_check_results`)는 한 번도 안 써서 마이그레이션 0006 이 뗐다 — 붙일 때 다시 만든다 | CDN 크롤러 로그 수집, 원본 hash 비교, fact 만료/재검토, 재생성 큐 | - -### 3-2. Brand AEO B1~B9 - -현재 `seo_audit.py`의 점수는 “발행 준비도”다. 설계서의 Brand AEO처럼 ChatGPT·Gemini·Claude·Perplexity 응답을 정기 호출하여 브랜드 언급·인용·순위·사실성을 측정하지 않는다. - -필요한 기능은 다음 순서가 적절하다. - -1. **B1 질문은행**: 업종×지역×의도 질문, 버전, 활성 세트, 고객 편집 이력 -2. **B2 측정 스케줄**: 주기, 엔진, 반복 횟수, 비용 상한, 중지 조건 -3. **B3~B4 엔진 어댑터와 원문 보존**: 모델·버전·프롬프트·응답·citation 정규화 -4. **B5 분석**: 브랜드 alias 언급, 인용 URL, 추천 위치, 감성, fact snapshot 대조 -5. **B8 이원 점수**: 원계열, 반복 평균, 신뢰구간, MA4를 모두 보존하고 준비도 점수와 별도 표시 -6. **B6~B7 개선 루프**: EEAT 결손과 외부 인용원을 제안하되, 자동 발행하지 않고 fact 확인/A4/A7을 재통과 -7. **B9 리포트**: 주간·월간 리포트, 모델 교체 마커, 비용과 데이터 결손 표시 - -100문항×4엔진×3회라는 설계서 기본값은 테넌트당 주 1,200회 호출이다. 현재 제품 원가 상한인 **사이트당 약 $1**과 충돌할 가능성이 높으므로, 구현 전 모델별 실측 단가와 파일럿 질문 수를 다시 계산해야 한다. 초기에는 10~20개 핵심 질문, 1~2개 엔진, 반복 3회로 시작하고 통계적 유효성과 비용을 함께 측정하는 편이 안전하다. - -### 3-3. 콘솔·계약·데이터 - -- 내부 콘솔(`admin/frontend`)에는 사업장 목록/상세, 지역 콘텐츠, SEO 진단이 있고 빌더는 사장님 앱(`solution/frontend`)에 있다. 설계서의 질문 빌더·규제 승인·AI 퍼포먼스·랭킹·인용출처·정기 리포트 화면은 없다. -- 화면이 API 를 직접 호출한다. 설계서의 BFF, 서비스 JWT 교환, 섹션별 부분 실패, 계약 A~G는 없다. -- DB에는 현재 17개 ORM 모델이 있으며 설계서의 `document`, `predicate_def`, `entity`, `fact_snapshot`, `compliance_rule`, `review`, `publication_question`, `index_state`, `regeneration_request`, `event_outbox`, `audit_log` 등에 해당하는 완성 모델은 없다. -- 현재 fact는 수정 잠금과 후보 이력을 보존하지만, 설계서가 요구하는 전체 append-only 불변식·스냅샷 재현성 모델과 같지는 않다. - -계약 A~G를 한 번에 33개 REST/7개 이벤트로 만드는 것보다, 먼저 아래 세 계약만 버전 고정하는 것을 권장한다. - -1. `FactSnapshot`: 측정 당시의 발행 사실을 재현할 수 있는 읽기 전용 계약 -2. `QuestionSet`: 생성 대상과 측정 대상을 같은 question ID로 연결하는 계약 -3. `Publication`: 발행 URL과 question ID를 연결해 인용 성과를 귀속하는 계약 - -이 세 계약이 있어야 Brand AEO 결과가 단순한 “브랜드가 나왔다”를 넘어 “어떤 질문을 겨냥한 어떤 페이지가 인용됐다”까지 설명할 수 있다. - ---- - -## 4. 권장 개발 우선순위 - -### P0 — 개발 전에 확정할 결정 - -- **제품 범위**: Site AEO 소상공인 MVP를 유지할지, 법률·의료와 Brand AEO를 이번 제품 범위에 포함할지 -- **1차 파일럿**: Stay 머뭄 1곳 우선인지, 3업종 동시인지 -- **도메인 전략**: 고객 서브패스 / 고객 서브도메인 / 플랫폼 공용 경로의 지원 우선순위 -- **크롤 정책**: 소유권 검증 고객 도메인의 JS 렌더링 허용 범위. 플랫폼 robots·봇 차단 우회 금지는 유지 -- **이미지 권리**: 소유자 업로드만 허용할지, 기존 플랫폼 사진 재게시를 허용할지 -- **비용 예산**: Brand 측정의 테넌트당 주간 호출·금액 상한 -- **문서 버전**: 전달 파일의 파일명 v19와 표지 v18 불일치 해소 - -### P1 — 현재 Site AEO를 설계서 수준으로 닫기 - -1. 도메인 소유권 검증과 만료 시 발행 차단 -2. crawl run/document와 원본 hash 저장 -3. A7 SimHash 중복도 검사 및 fact 역참조 리포트 -4. A9 원본 변경 감지, fact 만료/검토, AI 크롤러 방문 로그 -5. 고객 도메인 연결, TLS/DNS 운영 절차 -6. 서버 계산 `allowed_actions` (앱 경계 분리는 2026-08-31 완료) - -완료 기준은 “페이지가 만들어진다”가 아니라, **소유권이 확인된 원본에서 출처가 남는 fact를 만들고 두 게이트를 통과해 고객 도메인에 발행되며, 변경과 재방문을 관측할 수 있다**는 것이다. - -### P2 — 규제 업종을 넣는 경우에만 선행 - -1. Reviewer 역할과 승인 워크벤치 -2. 외부화된 업종별 규칙과 버전 관리 -3. SAFE / NEEDS_EVIDENCE / PROHIBITED 3단 판정 -4. 규칙 ID·근거 snippet·판정 모델·승인자·시각을 남기는 감사 로그 -5. 성형외과 사전심의 상태와 자격/면허 fact 모델 -6. 법률·의료 전문가의 규칙 승인 및 변경 절차 - -A4가 완성되기 전에는 법무법인·성형외과 콘텐츠 자동 발행을 열지 않는다. - -### P3 — Brand AEO 최소 측정 루프 - -1. 질문은행과 publication-question 연결 -2. fact snapshot -3. 1~2개 AI 엔진 어댑터와 응답 원문·모델 버전 저장 -4. 언급·인용 URL·추천 위치·사실성 분석 -5. 반복 측정, 신뢰구간, MA4, 비용 집계 -6. 읽기 전용 퍼포먼스·인용출처 화면 - -처음부터 자동 EEAT 재생성까지 닫지 말고, 먼저 **같은 질문을 반복 측정했을 때 지표가 의사결정에 쓸 만큼 안정적인지** 검증한다. - -### P4 — 개선 폐루프와 운영 확장 - -- EEAT 결손 → 고객 확인 요청 / 재생성 요청 분기 -- 재생성 요청의 A4·A7 재통과 -- 정기 리포트와 외부 채널 전략 -- BFF의 섹션별 부분 실패와 서비스 JWT -- 데이터·트래픽·팀 소유권이 임계에 도달하면 Site/Brand 저장소 및 배포 단위 분리 - ---- - -## 5. 재작성하지 않고 유지할 현재 구현 - -설계서와 다르더라도 아래는 현재 제품에 맞고 이미 안전장치가 있으므로 유지하는 편이 낫다. - -- PostgreSQL 기반 잡 큐의 원자적 claim, lease, dedupe, dead-letter -- `VERIFIED`/`CORRECTED`만 발행하고 재수집 후보가 정정값을 덮지 않는 상태 모델 -- 백엔드는 payload만 만들고 프론트 프리렌더러가 정적 HTML을 생성하는 경계 -- JSON-LD와 화면값 불일치 시 발행을 막는 게이트 -- 외부 API 키가 없어도 해당 어댑터만 비활성화하는 구성 -- robots.txt와 약관을 우회하지 않는 수집 원칙 -- 현재 SEO/AEO 점수를 “준비도”로 명시하는 정직한 표현 - ---- - -## 6. 일정 재구성 제안 - -설계서의 S1~S8은 신규 구축 기준이라 현재 저장소에 그대로 적용하면 이미 끝난 기반 작업을 반복하고, 미결 정책을 코드로 먼저 굳히게 된다. 다음과 같이 게이트 중심으로 다시 잡는다. - -| 마일스톤 | 목표 | 종료 조건 | -|---|---|---| -| M0 방향 확정 | 범위·업종·도메인·크롤·비용 결정 | P0 결정 기록과 승인 | -| M1 Site 완결 | A1/A7/A9 공백과 고객 도메인 보완 | 소유권→발행→변경감지 E2E 통과 | -| M2 규제 게이트 | 법률·의료를 할 경우 A4 구축 | 전문가 승인 룰셋과 감사 가능한 차단/승인 | -| M3 Visibility 파일럿 | 질문은행·snapshot·최소 엔진 측정 | 반복 측정의 비용·분산·인용 검출 정확도 보고 | -| M4 개선 루프 | 측정 결과를 안전한 재생성 요청으로 연결 | 고객 확인 또는 A4/A7 재통과 후 발행 | -| M5 플랫폼화 | 콘솔/BFF/서비스 분리 | 실제 트래픽·팀 소유권 기준 충족 시에만 수행 | - -주차 추정치는 P0의 업종 수, AI 엔진 수, 외부 전문가 검토 가능일이 정해진 뒤 산정한다. 특히 3개 업종 동시 개발과 4개 엔진×3회 측정을 전제로 한 기존 8스프린트 일정은 현재 인력·비용 정보 없이 확정 일정으로 취급하면 안 된다. - ---- - -## 7. 바로 만들 백로그 - -| 우선순위 | 에픽 | 대표 산출물 | -|---|---|---| -| 1 | 소유권 검증 | challenge 테이블/API, DNS/meta/well-known 검증기, 만료 정책, 발행 게이트 | -| 2 | 수집 재현성 | crawl run, document hash, source selector/snippet, 변경 비교 | -| 3 | 일치성 강화 | SimHash, fact 역참조 커버리지, 실패 사유 UI | -| 4 | 발행 관측 | crawler visit/index 상태, CDN 로그 적재, 재수집/재생성 조건 | -| 5 | 고객 도메인 | 서브패스/서브도메인 연결, canonical·sitemap 검증, TLS/DNS 운영 | -| 6 | 측정 계약 | question set, fact snapshot, publication-question 연결 | -| 7 | Visibility 파일럿 | 엔진 어댑터, 원문 로그, mention/citation/position/factuality 분석, 비용 상한 | -| 8 | 운영 화면 | 소유권·수집·게이트·발행 상태부터 추가, 이후 질문/성과/인용 화면 | -| 조건부 | 규제 업종 | 규칙 저장소, Reviewer, 승인 워크벤치, 감사 로그, 사전심의 | - ---- - -## 8. 관련 현재 문서 - -이 문서는 비교와 향후 방향만 다룬다. 현재 제품 원칙과 구현 상세는 중복해서 관리하지 않는다. - -- 제품 범위와 non-goal: [PRODUCT.md](PRODUCT.md) -- 현재 수집·생성·발행 흐름: [COLLECTION_SEO_AEO_FLOW.md](COLLECTION_SEO_AEO_FLOW.md) -- 법무·데이터·작업 큐 결정: [DECISIONS.md](DECISIONS.md) -- 데이터 소스 실측: [DATA_SOURCE_RESEARCH.md](DATA_SOURCE_RESEARCH.md) -- 배포와 도메인 운영: [DEPLOY.md](DEPLOY.md) -- 외부 API 비용: [API_USAGE.md](API_USAGE.md) - diff --git a/docs/TEMPLATES.md b/docs/TEMPLATES.md index ed54e74..2bb8f8e 100644 --- a/docs/TEMPLATES.md +++ b/docs/TEMPLATES.md @@ -1,5 +1,7 @@ # 템플릿과 렌더링 +화면 규칙(글자 크기 · 간격 · 접기 · ✓ 표시)은 [TEMPLATE_DESIGN.md](TEMPLATE_DESIGN.md). + 사장님이 고르는 "템플릿"이 어디에 정의돼 있고, 화면에 어떤 순서로 그려지는지 적은 문서다. 2026-09-28에 구조를 한 번 갈아엎었고, 이 문서는 그 뒤의 모습이다. @@ -32,12 +34,12 @@ | 칸 | 뜻 | |---|---| | `name` · `tag` · `description` | 빌더에서 사장님이 보는 이름과 설명 | -| `layout` | 어떤 뼈대로 그릴지. 지금은 `basic`과 `paper` 두 개 | +| `layout` | 어떤 뼈대로 그릴지. `basic` · `paper` · `round` · `cinema` · `bigtype` · `boutique` · `graphic` · `coral` · `minimal` · `pine` | | `colors` | 기본 색. 사장님이 팔레트를 고르면 그 색이 위에 덮인다 | | `look` | 서체, 모서리, 그림자, 섹션 간격. 사장님이 못 바꾼다 | | `addSections` | 이 템플릿을 고르면 새로 켜지는 섹션. 레트로의 일력·영상 같은 것 | -지금 템플릿은 네 개다. +지금 템플릿은 아홉 개다. | id | 이름 | 뼈대 | |---|---|---| @@ -45,12 +47,24 @@ | `magazine` | 매거진 | basic | | `retro` | 레트로 | basic | | `paper` | 고택 | paper | +| `round` | 라운드 | round | +| `cinema` | 시네마 | cinema | +| `bigtype` | 빅타이포 | bigtype | +| `boutique` | 부티크 | boutique | +| `graphic` | 일러스트 | graphic | +| `coral` | 코랄 | coral | +| `minimal` | 미니멀 | minimal | +| `pine` | 솔숲 | pine | 심플·매거진·레트로는 뼈대가 같고 색과 서체만 다르다. 고택은 손으로 만든 시안 `/s/stay2`를 그대로 옮긴 것이라 뼈대부터 다르다. 폭 640px짜리 한 단에, 위쪽 탭 네 개(소개 · 지역 소개 · 이용안내·예약 · 이야기)로 화면을 나눈다. 탭을 눌러도 페이지를 새로 받지 않는다. 내용은 전부 한 HTML 안에 있고 보이는 묶음만 바뀐다. 검색엔진은 네 묶음을 다 읽는다. +라운드·시네마·빅타이포·부티크·일러스트·코랄·미니멀·솔숲은 고택과 같은 섹션과 탭 네 개를 그대로 받고 모양만 다르다. +예약 시트와 예약 폼은 고택 부품을 가져다 쓴다. 모바일 390px에서 먼저 맞췄다. 일러스트는 사진이 +거의 없는 집을 위한 것이라, 첫 화면과 객실 카드를 그림으로 채운다. + 업종마다 쓸 수 있는 템플릿과 기본 템플릿은 같은 파일의 `industries`에 적는다. 숙박은 레트로가 기본이고, 병원은 심플과 매거진만 쓸 수 있다. diff --git a/docs/TEMPLATE_DESIGN.md b/docs/TEMPLATE_DESIGN.md new file mode 100644 index 0000000..b722e69 --- /dev/null +++ b/docs/TEMPLATE_DESIGN.md @@ -0,0 +1,106 @@ +# 템플릿 디자인 규칙 + +새 템플릿을 만들거나 기존 템플릿을 고칠 때 지키는 화면 규칙이다. 기준은 고택(`layouts/paper/`)이다. +고택은 손으로 만든 시안 `/s/stay2`(`solution/site/scripts/mockup/king-stay2/`)를 옮긴 것이라 +스크롤 길이·접기·글자 크기가 이미 검증돼 있다. 수치가 애매하면 `layouts/paper/paper.css` 를 연다. + +템플릿 목록과 등록 방법은 [TEMPLATES.md](TEMPLATES.md), 렌더링 흐름은 [RENDERING.md](RENDERING.md). + +## 1. 폭 + +| 화면 | 규칙 | +|---|---| +| 모바일 390px | 먼저 맞춘다. 가로 넘침 0 | +| 700~1023px | 640px 한 단, 가운데 | +| 1024px 이상 | 템플릿마다 정한다. 넓은 배치면 콘텐츠 최대 1080px, 첫 화면은 가로 전체 | + +부티크처럼 모바일 한 단을 데스크톱에서도 그대로 쓰는 템플릿이 있다. 정한 방식은 [TEMPLATES.md](TEMPLATES.md) 에 적는다. + +## 2. 글자 + +- 본문 16~17px, 줄 간격 1.7 이상. 고택은 17px/1.9. **본문 16px 미만 금지** +- 보조 글씨 14~15px. 기능 글씨(버튼·메뉴) 12px 미만 금지 +- 섹션 제목 22~30px. 고택은 19px 명조 +- 첫 화면 문구: 모바일 28px 안팎, 데스크톱 48px 이하 +- 상호를 크게 쓰는 템플릿(빅타이포)도 64px 이하 +- `vw` 로 커지는 글자 크기 금지. 넓은 화면에서 끝없이 커진다 +- 보조 글씨 대비 4.5:1 이상. `#8b95a1` 같은 연회색은 흰 바탕에서 2.9:1 이라 탈락한다 + +### 템플릿별 한글 글꼴 — 겹치지 않게 배정했다 + +| 템플릿 | 제목 | 본문 | +|---|---|---| +| 고택 | Noto Serif KR | 시스템 고딕 | +| 라운드 | Noto Sans KR | Noto Sans KR | +| 시네마 | 송명 | Noto Sans KR | +| 빅타이포 | 함렛 | 함렛 · IBM Plex Mono | +| 부티크 | Cormorant · 나눔명조 | 나눔명조 | +| 일러스트 | 주아 | 고운돋움 | +| 코랄 | Gothic A1 · Aboreto | Gothic A1 | +| 미니멀 | Diphylleia · Cinzel | Questrial · 나눔고딕 | +| 솔숲 | IBM Plex Sans KR | IBM Plex Sans KR | + +새 템플릿은 이 표에 없는 글꼴을 쓴다. 웹폰트는 `seo/head.ts` `WEB_FONTS` 에 등록해야 받아 온다. + +## 3. 간격 + +- 제목 위 여백 32px 이상. 제목 위가 아래보다 넓다 +- 카드형 목록 사이 10px 이상(선으로 나누는 목록은 예외) +- 같은 목록의 카드는 같은 크기: 사진은 고정 비율 + `object-fit:cover`, 글은 줄 수 말줄임 +- 칸 수는 항목 수에 맞춘다(`repeat(auto-fit, minmax(…))`). 3개인데 4칸을 잡아 빈칸을 남기지 않는다 + +## 4. 섹션 머리 + +- **제목은 위, 내용은 아래.** 참고한 펜션 사이트들이 모두 이 방식이다 +- 제목을 왼쪽, 내용을 오른쪽에 두는 좌우 분할은 쓰지 않는다. 내용이 한두 줄이면 왼쪽이 텅 빈다 + (예외: 소개 글과 사진이 둘 다 충분할 때의 소개 섹션) +- 제목 위에 같은 뜻의 작은 라벨(‘객실’ 위 ‘객실 안내’)을 달지 않는다. + 참고 사이트의 정체성인 영문 제목(코랄 `Room View`, 미니멀 `Stay`)은 예외 + +## 5. 스크롤 줄이기 — 몇 개 보이고 접나 + +`
` 로 접는다. 내용은 HTML 에 남아 크롤러가 읽는다. 빼지 않는다. + +| 목록 | 처음 보이는 수 | 근거 | +|---|---|---| +| 첫 화면 사진 | 5장 | `paper/Hero.tsx` `MAX_SLIDES` | +| 홈 객실 | 4개(2열) | 나머지는 이용안내 탭 | +| 홈 사진 갤러리 | 큰 1 + 4장 | `paper/Gallery.tsx` `FIRST` | +| 홈 주변 안내 | 3~4곳 | 나머지는 지역 탭 | +| 명소 · 맛집 | 8곳 | `paper/Around.tsx` `FIRST_ROWS` (데스크톱 넓은 배치는 8, 모바일 6도 허용) | +| 축제 | 가로 캐러셀 | 세로로 펼치지 않는다 | +| 추천 일정 | 4개 | `paper/Itinerary.tsx` `FIRST` | +| 노래 | 6곡 | `paper/Story.tsx` | +| 인물 | 4명 | `paper/Story.tsx` | +| 지역 읽기 | 3편, 본문 3줄 말줄임 | `paper/Story.tsx` | +| 자주 묻는 질문 | 8개, 질문은 접힌 상태 | `paper/Faq.tsx` `FIRST` | + +## 6. 값 보여주기 + +- **‘가능 · 있음’은 ✓, ‘불가 · 없음’은 ✕ 목록으로 쓴다.** `주차 / 가능` 같은 표로 쓰지 않는다 + - 마크업: `
  • ✓주차 가능
  • ` + - 색: ✓ `#15803d`, ✕ `#c2410c`. 가능한 것을 먼저 놓는다 + - 판정: `sections/EssentialInfoSection.tsx` `ON_VALUES`(가능·있음) +- 사진 없는 항목(인물 등)에 빈 사진 칸을 두지 않는다. 글만 있는 카드로 줄인다 +- 거리·이름처럼 붙어 나오는 글은 띄우거나 줄을 나눈다 + +## 7. 탭 · 버튼 + +- 페이지 탭은 **밑줄**로 현재 위치를 표시한다. 회색 알약 배경 금지 +- 탭·링크를 누르면 **같은 탭이어도 맨 위로** 바로 올라간다(`behavior:'instant'`, 전역 `scroll-behavior:smooth` 를 이긴다) +- 같은 동작은 한 이름: 예약 시트를 여는 버튼은 모두 **‘예약하기’**, 이용안내 탭으로 가는 버튼은 **‘이용안내 보기’** +- 버튼 글자가 두 줄로 접히면 안 된다. 모바일에서 버튼이 3개 이상이면 2열 격자로, 남는 하나는 한 줄 전체 +- 키보드 포커스와 글자 선택색은 템플릿 색으로(`kit/kit.css`) + +## 8. 하지 않는 것 + +- 영어 해외 사이트를 레퍼런스로 가져오지 않는다. 한국 사이트에서 UI(글자 크기·배치)로 고른다 +- 디자인을 처음부터 손으로 짓지 않는다. 실물 사이트·시안을 받아 우리 콘텐츠에 맞게 옮긴다 +- 섹션마다 똑같이 떠오르는 등장 효과, 모든 카드에 같은 그림자 — 생성형 기본값으로 읽힌다 + +## 9. 검수 순서 + +1. 해든스테이 테스트 데이터로 굽는다 (굽기 방법은 [RENDERING.md](RENDERING.md)) +2. 1440px · 390px 에서 네 탭(홈 · 지역 · 이용안내 · 이야기)을 캡처해 **눈으로** 본다. lazy 이미지는 스크롤해서 띄운 뒤 찍는다 +3. 제목 위 여백 32px 미만, 가로 넘침, 대비 부족을 잰다 +4. `cd solution/site && npx eslint src/layouts && npx vitest run src/layouts` diff --git a/solution/shared/src/data/templates.json b/solution/shared/src/data/templates.json index 930c45b..d6e7cc2 100644 --- a/solution/shared/src/data/templates.json +++ b/solution/shared/src/data/templates.json @@ -106,6 +106,206 @@ "sectionSpace": "2.5rem" }, "addSections": [] + }, + "round": { + "name": "라운드", + "tag": "둥근 카드", + "description": "흰 바탕에 둥근 사진 카드와 굵고 큰 고딕. 가장 무난하고 사진 품질을 덜 탑니다.", + "layout": "round", + "colors": { + "primary": "#3182f6", + "secondary": "#4e5968", + "bg": "#ffffff", + "card": "#f2f4f6", + "text": "#191f28", + "accent": "#3182f6" + }, + "look": { + "fontHeading": "'Noto Sans KR', system-ui, sans-serif", + "fontBody": "'Noto Sans KR', system-ui, sans-serif", + "radius": "22px", + "borderWidth": "0px", + "shadow": "0 8px 28px rgb(0 0 0 / 0.12)", + "headingTracking": "-0.02em", + "headingWeight": "700", + "sectionSpace": "4.5rem" + }, + "addSections": [] + }, + "cinema": { + "name": "시네마", + "tag": "풀스크린", + "description": "사진을 화면 가득 깔고 큰 문구 한 줄. 어두운 바탕에 반투명 유리 카드를 씁니다.", + "layout": "cinema", + "colors": { + "primary": "#f4efe6", + "secondary": "#b8b0a3", + "bg": "#0f0e0c", + "card": "#1b1916", + "text": "#f4efe6", + "accent": "#e7b77a" + }, + "look": { + "fontHeading": "'Song Myung', 'Noto Serif KR', serif", + "fontBody": "'Noto Sans KR', system-ui, sans-serif", + "radius": "18px", + "borderWidth": "1px", + "shadow": "none", + "headingTracking": "-0.02em", + "headingWeight": "800", + "sectionSpace": "6rem" + }, + "addSections": [] + }, + "bigtype": { + "name": "빅타이포", + "tag": "큰 글자", + "description": "상호를 화면 폭 가득 명조로 쓰고 가는 선 격자로 짠 지면. 모서리가 없습니다.", + "layout": "bigtype", + "colors": { + "primary": "#141412", + "secondary": "#55544f", + "bg": "#ecebe6", + "card": "#ecebe6", + "text": "#141412", + "accent": "#141412" + }, + "look": { + "fontHeading": "'Hahmlet', 'Cormorant Garamond', serif", + "fontBody": "'Hahmlet', 'IBM Plex Mono', serif", + "radius": "0px", + "borderWidth": "1px", + "shadow": "none", + "headingTracking": "-0.05em", + "headingWeight": "900", + "sectionSpace": "2.75rem" + }, + "addSections": [] + }, + "boutique": { + "name": "부티크", + "tag": "아치 · 세리프", + "description": "따뜻한 베이지 바탕에 가는 세리프와 아치형 사진. 감성 스테이 느낌입니다.", + "layout": "boutique", + "colors": { + "primary": "#6f5847", + "secondary": "#8a7a6d", + "bg": "#f1ede9", + "card": "#e8e1da", + "text": "#6f5847", + "accent": "#9f7666" + }, + "look": { + "fontHeading": "'Cormorant Garamond', 'Marcellus', 'Nanum Myeongjo', serif", + "fontBody": "'Nanum Myeongjo', serif", + "radius": "0px", + "borderWidth": "1px", + "shadow": "none", + "headingTracking": "0.02em", + "headingWeight": "300", + "sectionSpace": "4.5rem" + }, + "addSections": [] + }, + "graphic": { + "name": "일러스트", + "tag": "사진 없이", + "description": "사진이 적어도 되는 그림 풍경과 움직임. 첫 화면을 일러스트 장면으로 채웁니다.", + "layout": "graphic", + "colors": { + "primary": "#ff7a45", + "secondary": "#51605a", + "bg": "#fff7ec", + "card": "#ffffff", + "text": "#1d2b24", + "accent": "#ff7a45" + }, + "look": { + "fontHeading": "'Jua', 'Gowun Dodum', sans-serif", + "fontBody": "'Gowun Dodum', 'Noto Sans KR', sans-serif", + "radius": "24px", + "borderWidth": "0px", + "shadow": "0 4px 0 rgb(29 43 36 / 0.12)", + "headingTracking": "-0.01em", + "headingWeight": "400", + "sectionSpace": "5rem" + }, + "addSections": [] + }, + "coral": { + "name": "코랄", + "tag": "청록 리조트", + "description": "맑은 청록과 흰 바탕, 가는 영문 제목. 바다·풀빌라처럼 사진이 시원한 곳에 어울립니다.", + "layout": "coral", + "colors": { + "primary": "#6fa8a8", + "secondary": "#5f6b73", + "bg": "#f7f9fb", + "card": "#eaf2f2", + "text": "#26323a", + "accent": "#6fa8a8" + }, + "look": { + "fontHeading": "'Gothic A1', sans-serif, Aboreto", + "fontBody": "'Gothic A1', 'Noto Sans KR', sans-serif", + "radius": "0px", + "borderWidth": "0px", + "shadow": "none", + "headingTracking": "-0.01em", + "headingWeight": "300", + "sectionSpace": "4rem" + }, + "addSections": [] + }, + "minimal": { + "name": "미니멀", + "tag": "여백 호텔", + "description": "연회색 바탕에 가는 영문 제목과 작은 글씨. 여백으로 사진을 돋보이게 하는 호텔식 구성입니다.", + "layout": "minimal", + "colors": { + "primary": "#363636", + "secondary": "#757575", + "bg": "#f8f8f8", + "card": "#ffffff", + "text": "#363636", + "accent": "#363636" + }, + "look": { + "fontHeading": "'Diphylleia', serif, Cinzel", + "fontBody": "Questrial, 'Nanum Gothic', sans-serif", + "radius": "0px", + "borderWidth": "0px", + "shadow": "none", + "headingTracking": "0", + "headingWeight": "400", + "sectionSpace": "4rem" + }, + "addSections": [] + }, + "pine": { + "name": "솔숲", + "tag": "사진 중심", + "description": "흰 바탕에 솔숲 초록 한 색. 전면 사진과 절제된 고딕으로 바다 앞 숙소의 창과 테라스를 보여 줍니다.", + "layout": "pine", + "colors": { + "primary": "#2f5d50", + "secondary": "#4d5a5c", + "bg": "#ffffff", + "card": "#f3f5f4", + "text": "#172021", + "accent": "#2f5d50" + }, + "look": { + "fontHeading": "'IBM Plex Sans KR', system-ui, sans-serif", + "fontBody": "'IBM Plex Sans KR', system-ui, sans-serif", + "radius": "14px", + "borderWidth": "0px", + "shadow": "none", + "headingTracking": "-0.01em", + "headingWeight": "600", + "sectionSpace": "4rem" + }, + "addSections": [] } }, "industries": { @@ -135,7 +335,15 @@ "retro", "simple", "magazine", - "paper" + "paper", + "round", + "cinema", + "bigtype", + "boutique", + "graphic", + "coral", + "minimal", + "pine" ], "sections": [ { diff --git a/solution/shared/src/lib/catalog.ts b/solution/shared/src/lib/catalog.ts index 3e729f0..f1b713e 100644 --- a/solution/shared/src/lib/catalog.ts +++ b/solution/shared/src/lib/catalog.ts @@ -3,7 +3,7 @@ import type {IndustryDef, IndustryType, LayoutId, TemplateDef} from '../types'; export type TemplateId = keyof typeof catalog.templates; -export const LAYOUT_IDS: readonly LayoutId[] = ['basic', 'paper']; +export const LAYOUT_IDS: readonly LayoutId[] = ['basic', 'paper', 'round', 'cinema', 'bigtype', 'boutique', 'graphic', 'coral', 'minimal', 'pine']; export const TEMPLATES = catalog.templates as Record; diff --git a/solution/shared/src/types/builder.ts b/solution/shared/src/types/builder.ts index 78bdbc7..09566c4 100644 --- a/solution/shared/src/types/builder.ts +++ b/solution/shared/src/types/builder.ts @@ -63,7 +63,7 @@ export interface TemplateLook { texture?: string; } -export type LayoutId = 'basic' | 'paper'; +export type LayoutId = 'basic' | 'paper' | 'round' | 'cinema' | 'bigtype' | 'boutique' | 'graphic' | 'coral' | 'minimal' | 'pine'; export interface TemplateDef { name: string; diff --git a/solution/site/.impeccable/config.json b/solution/site/.impeccable/config.json new file mode 100644 index 0000000..bba4d3f --- /dev/null +++ b/solution/site/.impeccable/config.json @@ -0,0 +1,14 @@ +{ + "detector": { + "ignoreRules": [], + "ignoreFiles": [], + "ignoreValues": [ + { + "rule": "bounce-easing", + "value": "cubic-bezier(.3, 1.6, .5, 1)", + "createdAt": "2026-09-29T06:54:24.590Z", + "reason": "Claude design choice: 일러스트 템플릿의 글자 팝·지도 핀 낙하는 장난스러운 캐릭터 모션으로 의도함(목업 6-graphic 과 동일)" + } + ] + } +} diff --git a/solution/site/src/layouts/bigtype/Around.tsx b/solution/site/src/layouts/bigtype/Around.tsx new file mode 100644 index 0000000..7d80daf --- /dev/null +++ b/solution/site/src/layouts/bigtype/Around.tsx @@ -0,0 +1,144 @@ +import {useState} from 'react'; +import type {LocalPlace} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import {naverMapSearchUrl} from '@site/lib/format'; +import {Sheet} from '../paper/Sheet'; +import {Idx} from './SectionHead'; +import {Tabs} from './Tabs'; +import {useWide} from './useWide'; + +const WALK = 80; +const RANGES = [ + {id: 'all', label: '전체', test: () => true}, + {id: 'w5', label: '걸어서 5분 이내', test: (m: number) => m <= 5 * WALK}, + {id: 'w10', label: '걸어서 10분 이내', test: (m: number) => m <= 10 * WALK}, + {id: 'far', label: '걸어서 10분 이상', test: (m: number) => m > 10 * WALK}, +] as const; +const HOME_ROWS = 3; +const FIRST_ROWS = 5; + +function meters(place: LocalPlace): number { + if (place.distanceMeters != null) return place.distanceMeters; + const m = /^([\d.]+)\s*(km|m)$/i.exec((place.distanceText ?? '').trim()); + return m ? Number(m[1]) * (m[2].toLowerCase() === 'km' ? 1000 : 1) : Infinity; +} + +const byDistance = (a: LocalPlace, b: LocalPlace) => meters(a) - meters(b); +const inRange = (id: string) => RANGES.find((r) => r.id === id)?.test ?? (() => true); + +function rangeTabs(places: LocalPlace[]) { + return RANGES.filter((r, i) => { + const n = places.filter((p) => r.test(meters(p))).length; + return i === 0 || (n > 0 && n < places.length); + }).map((r) => ({id: r.id as string, label: `${r.label} ${places.filter((p) => r.test(meters(p))).length}`})); +} + +export function Around() { + const payload = useSite(); + const {local} = payload; + const spots = [...local.attractions].sort(byDistance); + const eats = [...local.restaurants].sort(byDistance); + const rail = [...spots, ...eats].filter((place) => place.imageUrl).sort(byDistance); + const [open, setOpen] = useState(null); + const [spotRange, setSpotRange] = useState('all'); + const [eatRange, setEatRange] = useState('all'); + const firstSpots = useWide() ? 8 : 6; + if (rail.length === 0 && eats.length === 0 && spots.length === 0) return null; + + const shownSpots = spots.filter((p) => inRange(spotRange)(meters(p))); + const shownEats = eats.filter((p) => inRange(eatRange)(meters(p))); + const item = (place: LocalPlace, i: number, thumb = true) => ( +
  • + +
  • + ); + + return ( + <> + {rail.length > 0 && ( +
    +
    + +
      {rail.slice(0, HOME_ROWS).map((place, i) => item(place, i))}
    + + 지역 소개에서 모두 보기 + → + +
    +
    + )} + +
    + {spots.length > 0 && ( +
    + + +
      {shownSpots.slice(0, firstSpots).map((place, i) => item(place, i))}
    + {shownSpots.length > firstSpots && ( +
    + + {shownSpots.length - firstSpots}곳 더 보기 + + + +
      + {shownSpots.slice(firstSpots).map((place, i) => item(place, i + firstSpots))} +
    +
    + )} +
    + )} + {eats.length > 0 && ( +
    + + +
      {shownEats.slice(0, FIRST_ROWS).map((place, i) => item(place, i, false))}
    + {shownEats.length > FIRST_ROWS && ( +
    + + {shownEats.length - FIRST_ROWS}곳 더 보기 + + + +
      + {shownEats.slice(FIRST_ROWS).map((place, i) => item(place, i + FIRST_ROWS, false))} +
    +
    + )} +
    + )} +
    + + setOpen(null)} label={open?.name ?? ''}> + {open && ( + <> + {open.imageUrl && {open.name}} +

    {open.name}

    +

    {[open.distanceText, open.category].filter(Boolean).join(' · ')}

    + {open.description &&

    {open.description}

    } + + 네이버에서 보기 + ↗ + + + )} +
    + + ); +} diff --git a/solution/site/src/layouts/bigtype/Booking.tsx b/solution/site/src/layouts/bigtype/Booking.tsx new file mode 100644 index 0000000..28681c1 --- /dev/null +++ b/solution/site/src/layouts/bigtype/Booking.tsx @@ -0,0 +1,63 @@ +import {PlaceCategory} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import {bookingActionLabel, bookingLinks, stayBookingView} from '@site/lib/derive'; +import {BOOK_EVENT} from '../paper/Rooms'; + +function Block({id, full}: {id: string; full?: boolean}) { + const payload = useSite(); + const links = bookingLinks(payload); + const stay = payload.place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null; + const phone = payload.place.phone; + + return ( +
    +

    + Reservation +

    +

    + 이용안내 +
    및 예약 +

    +

    요금과 예약 방법을 안내합니다.

    +
    + {(full ? links : links.slice(0, 1)).map((link) => ( + + {bookingActionLabel(link)} + ↗ + + ))} + {full && phone && ( + + {phone} + → + + )} + {full && stay && ( + + )} + {!full && ( + + 이용안내 보기 + → + + )} +
    +
    + ); +} + +export function Booking() { + return ( + <> +
    + +
    +
    + +
    + + ); +} diff --git a/solution/site/src/layouts/bigtype/Faq.tsx b/solution/site/src/layouts/bigtype/Faq.tsx new file mode 100644 index 0000000..a9347bc --- /dev/null +++ b/solution/site/src/layouts/bigtype/Faq.tsx @@ -0,0 +1,41 @@ +import {SourceType} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import {faqList} from '@site/lib/derive'; +import {Idx} from './SectionHead'; + +const FIRST = 6; + +export function Faq() { + const payload = useSite(); + const faqs = faqList(payload); + if (faqs.length === 0) return null; + const verified = !faqs.some((faq) => faq.sourceType === SourceType.TEMPLATE); + const item = (faq: (typeof faqs)[number], i: number) => ( +
  • +
    + + Q{i + 1} + {faq.question} + +

    {faq.answer}

    +
    +
  • + ); + + return ( +
    + + {verified &&

    아래 답변은 모두 사업자가 확인한 내용입니다.

    } +
      {faqs.slice(0, FIRST).map(item)}
    + {faqs.length > FIRST && ( +
    + + {faqs.length - FIRST}개 더 보기 + + + +
      {faqs.slice(FIRST).map((faq, i) => item(faq, i + FIRST))}
    +
    + )} +
    + ); +} diff --git a/solution/site/src/layouts/bigtype/Festival.tsx b/solution/site/src/layouts/bigtype/Festival.tsx new file mode 100644 index 0000000..30c33ef --- /dev/null +++ b/solution/site/src/layouts/bigtype/Festival.tsx @@ -0,0 +1,72 @@ +import {useState} from 'react'; +import type {FestivalEntry} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import {naverSearchUrl} from '@site/lib/format'; +import {Car} from '../paper/Car'; +import {Sheet} from '../paper/Sheet'; +import {Idx} from './SectionHead'; +import {Tabs} from './Tabs'; + +const SEASONS = ['봄', '여름', '가을', '겨울']; + +export function Festival() { + const payload = useSite(); + const festivals = payload.local.festivals; + const [season, setSeason] = useState(''); + const [open, setOpen] = useState(null); + if (festivals.length === 0) return null; + const tabs = ['', ...SEASONS.filter((s) => festivals.some((f) => f.season === s))].map((s) => ({ + id: s, + label: `${s || '전체'} ${festivals.filter((f) => !s || f.season === s).length}`, + })); + const shown = festivals.filter((f) => !season || f.season === season); + + return ( +
    + +

    {payload.place.addressLocality ?? '이 지역'}의 축제와 행사를 계절로 묶었습니다.

    + {tabs.length > 2 && } +
    + ( + + ))} + /> +
    + setOpen(null)} label={open?.name ?? ''}> + {open && ( + <> + {open.imageUrl && {open.name}} +

    {open.name}

    +

    {[open.period ?? open.month, open.location].filter(Boolean).join(' · ')}

    + {open.description &&

    {open.description}

    } + + 자세히 보기 + ↗ + + + )} +
    +
    + ); +} diff --git a/solution/site/src/layouts/bigtype/Frame.tsx b/solution/site/src/layouts/bigtype/Frame.tsx new file mode 100644 index 0000000..785c14f --- /dev/null +++ b/solution/site/src/layouts/bigtype/Frame.tsx @@ -0,0 +1,276 @@ +import {useEffect, useState, type CSSProperties, type ReactNode} from 'react'; +import {PlaceCategory, selectPublishable} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import { + bookingActionLabel, + bookingLinks, + channelLabel, + isSectionEnabled, + publicLinks, + stayBookingView, +} from '@site/lib/derive'; +import {isoDate, naverDirectionsUrl} from '@site/lib/format'; +import {SongPlayer} from '@site/sections/SongPlayer'; +import {BookingForm} from '../paper/BookingForm'; +import {BOOK_EVENT} from '../paper/Rooms'; +import {Sheet} from '../paper/Sheet'; +import {Idx} from './SectionHead'; +import {wordmark} from './wordmark'; +import '../kit/kit.css'; +import './bigtype.css'; + +const HOUSE_WORD: Record = { + [PlaceCategory.LODGING]: '펜션', + [PlaceCategory.CAFE]: '카페', + [PlaceCategory.RESTAURANT]: '가게', + [PlaceCategory.CLINIC]: '병원', +}; + +type PageId = 'home' | 'area' | 'stay' | 'story'; + +const PAGE_HEAD: Record, {title: string; note: string}> = { + area: {title: '지역', note: '대문에서 걸어 닿는 자리와, 이 도시가 남긴 이야기입니다.'}, + stay: {title: '이용안내', note: '객실 상세와 요금, 예약하는 방법입니다.'}, + story: {title: '이야기', note: '사장님이 남기는 글과 다녀가신 분들의 후기, 그리고 엽서 한 장입니다.'}, +}; + +function pageOf(hash: string): PageId { + const id = hash.replace(/^#\/?/, ''); + return id === 'area' || id === 'stay' || id === 'story' ? id : 'home'; +} + +function usePage(): PageId { + const [page, setPage] = useState('home'); + useEffect(() => { + const sync = () => { + const {hash} = window.location; + if (hash && !hash.startsWith('#/')) return; + setPage(pageOf(hash)); + window.scrollTo({top: 0, behavior: 'instant'}); + }; + sync(); + window.addEventListener('hashchange', sync); + const again = (event: MouseEvent) => { + const link = event.target instanceof Element ? event.target.closest('a[href^="#/"]') : null; + const href = link?.getAttribute('href'); + if (href && pageOf(href) === pageOf(window.location.hash)) window.scrollTo({top: 0, behavior: 'instant'}); + }; + document.addEventListener('click', again); + return () => { + window.removeEventListener('hashchange', sync); + document.removeEventListener('click', again); + }; + }, []); + return page; +} + +export function Frame({children}: {children: ReactNode}) { + const payload = useSite(); + const {place, site} = payload; + const page = usePage(); + const [bookingOpen, setBookingOpen] = useState(false); + useEffect(() => { + const open = () => setBookingOpen(true); + window.addEventListener(BOOK_EVENT, open); + return () => window.removeEventListener(BOOK_EVENT, open); + }, []); + + const tabs: {label: string; page: PageId}[] = [ + {label: `${HOUSE_WORD[place.category] ?? '가게'} 소개`, page: 'home'}, + ]; + if (isSectionEnabled(payload, 'local')) tabs.push({label: '지역', page: 'area'}); + tabs.push({label: '이용안내', page: 'stay'}); + tabs.push({label: '이야기', page: 'story'}); + + const mark = wordmark(place.name); + const markStyle = {'--wm': `calc(${Math.min(41, 86 / mark.units)} * var(--u))`} as CSSProperties; + const booking = bookingLinks(payload)[0]; + const stayBooking = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null; + const kakao = payload.links.find((link) => link.confirmed && /kakao/i.test(link.url)); + const address = place.roadAddress ?? place.address; + const hasSongs = (payload.songs ?? []).some((song) => song.audioUrl); + const facts = selectPublishable(payload.facts); + const fact = (key: string) => facts.find((f) => f.key === key)?.value ?? undefined; + const hours = [ + fact('check_in_time') && `체크인 ${fact('check_in_time')}`, + fact('check_out_time') && `체크아웃 ${fact('check_out_time')}`, + fact('smoking') === 'false' && '전 구역 금연', + ].filter(Boolean); + const channels = publicLinks(payload); + const instagram = channels.find((link) => /instagram\.com/i.test(link.url)); + const locality = place.addressLocality; + const areaPills = ( + [ + ['주변 명소', '#walk', payload.local.attractions.length > 0], + ['주변 맛집', '#eat', payload.local.restaurants.length > 0], + ['엽서', '#/story', true], + ['축제', '#festival', isSectionEnabled(payload, 'festival') && payload.local.festivals.length > 0], + ['일정', '#itinerary', isSectionEnabled(payload, 'itinerary')], + ['이야기', '#story', isSectionEnabled(payload, 'story')], + ] as [string, string, boolean][] + ).filter(([, , on]) => on); + + const barItems = [address ? 'map' : null, kakao ? 'talk' : null, place.phone ? 'tel' : null].filter(Boolean); + const hasCta = Boolean(stayBooking || booking); + const showBar = barItems.length > 0 || hasCta; + const columns = [...barItems.map(() => '1fr'), ...(hasCta ? ['1.4fr'] : [])].join(' '); + const cta = stayBooking ? ( + + ) : ( + booking && ( + + {bookingActionLabel(booking)} + + ) + ); + + return ( +
    +
    + {place.name} + {place.phone ? Call (+) : Menu (+)} +
    + +
    +

    + {mark.lines.map((line) => ( + {line} + ))} +

    +
    + {locality && {locality}} + {place.latitude != null && place.longitude != null && ( + + {place.latitude.toFixed(1)}°N {place.longitude.toFixed(1)}°E + + )} + {HOUSE_WORD[place.category] ?? '가게'} +
    +
    + + + + {(Object.keys(PAGE_HEAD) as Exclude[]).map((id) => ( +
    +

    + {id === 'area' ? (place.addressLocality?.replace(/(시|군)$/, '') ?? PAGE_HEAD.area.title) : PAGE_HEAD[id].title} +

    +

    {PAGE_HEAD[id].note}

    + {id === 'area' && areaPills.length > 0 && ( +
    + {areaPills.map(([label, href]) => ( + + {label} + + ))} +
    + )} +
    + ))} + + {children} + + {channels.length > 0 && ( +
    +
    + + {channels.map((link) => ( + + {channelLabel(link)} + Open ↗ + + ))} +
    +
    + )} + +
    +

    + {place.name} + {address && ` — ${address}`} +

    + {hours.length > 0 &&

    {hours.join(' · ')}

    } + {(place.phone || instagram) && ( +

    + {place.phone && {place.phone}} + {place.phone && instagram && ' · '} + {instagram && ( + + instagram + + )} +

    + )} + {place.legal?.businessRegistrationNumber && ( +

    + {place.legal.representative && `대표 ${place.legal.representative} · `} + 사업자등록번호 {place.legal.businessRegistrationNumber} + {place.legal.mailOrderNumber && ` · 통신판매업신고 ${place.legal.mailOrderNumber}`} +

    + )} +

    + 작성·운영 {place.name} · 최종 업데이트{' '} + ·{' '} + + AI O2O + + 의 Web4Ai로 만든 사이트 +

    +

    + {mark.lines[0]}. +

    +
    + + {hasSongs && ( +
    + +
    + )} + + {showBar && ( + + )} + + {stayBooking && ( + setBookingOpen(false)} label="예약 요청" plain> +
    +

    예약 요청

    + +
    +
    + )} +
    + ); +} diff --git a/solution/site/src/layouts/bigtype/Gallery.tsx b/solution/site/src/layouts/bigtype/Gallery.tsx new file mode 100644 index 0000000..05de778 --- /dev/null +++ b/solution/site/src/layouts/bigtype/Gallery.tsx @@ -0,0 +1,53 @@ +import {useState} from 'react'; +import type {MediaItem} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import {galleryImages, sectionName} from '@site/lib/derive'; +import {Sheet} from '../paper/Sheet'; +import {Idx} from './SectionHead'; + +const FIRST = 5; + +export function Gallery() { + const payload = useSite(); + const images = galleryImages(payload); + const [open, setOpen] = useState(null); + if (images.length === 0) return null; + + const cell = (image: MediaItem) => ( + + ); + + return ( +
    + + + setOpen(null)} label={open?.alt ?? ''}> + {open && ( + <> + {open.alt} +

    {open.alt}

    + {open.category &&

    {open.category}

    } + + )} +
    +
    + ); +} diff --git a/solution/site/src/layouts/bigtype/Hero.tsx b/solution/site/src/layouts/bigtype/Hero.tsx new file mode 100644 index 0000000..fda639b --- /dev/null +++ b/solution/site/src/layouts/bigtype/Hero.tsx @@ -0,0 +1,72 @@ +import {PlaceCategory} from '@o2o/shared'; +import {HeroCatchphrase} from '@site/sections/HeroCatchphrase'; +import {useSite} from '@site/lib/site-context'; +import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive'; +import {BOOK_EVENT} from '../paper/Rooms'; +import {Weather} from './Weather'; + +export function Hero() { + const payload = useSite(); + const {place, narrative} = payload; + const [first, second] = [...payload.media] + .filter((image) => image.alt?.trim()) + .sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary)); + const fixed = narrative.tagline ?? narrative.heroSubline; + const lodging = place.category === PlaceCategory.LODGING; + const stay = lodging ? stayBookingView(payload) : null; + const link = bookingLinks(payload)[0]; + + return ( + <> +
    + {first && ( +
    + {first.alt} +
    + )} +
    +

    + 오늘의 문장 +

    + {(lodging || fixed) &&

    {lodging ? : fixed}

    } + {lodging && fixed &&

    {fixed}

    } +
    + {stay ? ( + + ) : ( + link && ( + + {bookingActionLabel(link)} + ↗ + + ) + )} + {place.phone && ( + + {place.phone} + Call + + )} + + 이용안내 + → + +
    +
    + {second && ( +
    + {second.alt} +
    + )} +
    + {isSectionEnabled(payload, 'weather') && ( +
    + +
    + )} + + ); +} diff --git a/solution/site/src/layouts/bigtype/Info.tsx b/solution/site/src/layouts/bigtype/Info.tsx new file mode 100644 index 0000000..a22a436 --- /dev/null +++ b/solution/site/src/layouts/bigtype/Info.tsx @@ -0,0 +1,96 @@ +import {useSite} from '@site/lib/site-context'; +import type {InfoRow} from '@site/lib/derive'; +import {infoView, ON_VALUES} from '@site/sections/EssentialInfoSection'; +import {Idx} from './SectionHead'; + +function Row({row}: {row: InfoRow}) { + const long = Boolean(row.note) || row.value.length > 24; + return ( +
    + {row.label} + + {row.value} + {row.note && ` — ${row.note}`} + +
    + ); +} + +function Mark({on}: {on: boolean}) { + return ( + + {on ? : } + + ); +} + +export function Info() { + const payload = useSite(); + const view = infoView(payload); + if (!view) return null; + const {ruleRows, detailRows, amenities, notices} = view; + const short = ruleRows + .filter((row) => !row.note && row.value.length <= 24) + .sort((a, b) => Number(b.key === 'check_in_time') - Number(a.key === 'check_in_time')); + const long = ruleRows.filter((row) => row.note || row.value.length > 24); + const sorted = [...amenities].sort((a, b) => Number(ON_VALUES.has(b.value)) - Number(ON_VALUES.has(a.value))); + + return ( +
    + +

    방문 전 확인이 필요한 운영 규정과 시설 안내입니다.

    + {ruleRows.length > 0 && ( + <> +

    예약 전 확인

    +
    + {[...short, ...long].map((row) => ( + + ))} +
    + + )} + {detailRows.length + sorted.length > 0 && ( + <> +

    시설 · 편의

    + {detailRows.length > 0 && ( +
    + {detailRows.map((row) => ( + + ))} +
    + )} + {sorted.length > 0 && ( +
      + {sorted.map((row) => { + const on = ON_VALUES.has(row.value); + return ( +
    • + + + {row.label} {row.value} + +
    • + ); + })} +
    + )} + + )} + {notices.length > 0 && ( +
      + {notices.map((text) => ( +
    • +
      + + ! + 예약 공지 + +

      {text}

      +
      +
    • + ))} +
    + )} +
    + ); +} diff --git a/solution/site/src/layouts/bigtype/Intro.tsx b/solution/site/src/layouts/bigtype/Intro.tsx new file mode 100644 index 0000000..1effad4 --- /dev/null +++ b/solution/site/src/layouts/bigtype/Intro.tsx @@ -0,0 +1,118 @@ +import {useSite} from '@site/lib/site-context'; +import {Idx} from './SectionHead'; +import {essentialRows, galleryImages, sectionBody} from '@site/lib/derive'; + +const SPEC_KEYS = ['check_in_time', 'check_out_time', 'standard_capacity', 'max_capacity', 'parking']; + +function dotDate(iso: string): string { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso); + return m ? `${m[1]}. ${m[2]}. ${m[3]}` : iso; +} + +function Post({date, body}: {date: string; body: string}) { + return ( +
    +

    + +

    +

    {body}

    +
    + ); +} + +const HOME_MAX = 3; +const FULL_FIRST = 5; + +export function Blog({full}: {full?: boolean}) { + const payload = useSite(); + const posts = [...(payload.posts ?? [])].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)); + if (posts.length === 0) return null; + const shown = posts.slice(0, full ? FULL_FIRST : HOME_MAX); + const older = full ? posts.slice(FULL_FIRST) : []; + const more = !full && posts.length > HOME_MAX; + + return ( +
    + +
    + {shown.map((item) => ( + + ))} +
    + {more && ( + + 이야기에서 지난 소식 더 보기 + → + + )} + {older.length > 0 && ( +
    + + 지난 소식 {older.length}개 + + + + {older.map((item) => ( + + ))} +
    + )} +
    + ); +} + +export function Intro() { + const payload = useSite(); + const {narrative, place} = payload; + const own = sectionBody(payload, 'intro'); + const about = own.length > 0 ? own : narrative.about; + const image = galleryImages(payload).find((m) => !m.isPrimary); + const spec = essentialRows(payload) + .filter((row) => row.key && SPEC_KEYS.includes(row.key)) + .sort((a, b) => SPEC_KEYS.indexOf(a.key!) - SPEC_KEYS.indexOf(b.key!)); + + return ( + <> +
    + +
    +
    + + {about.length > 0 && ( +
    + +
    + {narrative.summary &&

    {narrative.summary}

    } +

    {about[0]}

    + {about.length > 1 && ( +
    + + 더 읽기 + + + + {about.slice(1).map((paragraph, i) => ( +

    {paragraph}

    + ))} +
    + )} + {spec.length > 0 && ( +
    + {spec.map((row) => ( +
    + {row.label} + {row.value} +
    + ))} +
    + )} +
    +
    + )} + {image && ( +
    + {image.alt} +
    + )} +
    + + ); +} diff --git a/solution/site/src/layouts/bigtype/Itinerary.tsx b/solution/site/src/layouts/bigtype/Itinerary.tsx new file mode 100644 index 0000000..1b7bbd0 --- /dev/null +++ b/solution/site/src/layouts/bigtype/Itinerary.tsx @@ -0,0 +1,102 @@ +import {useState} from 'react'; +import type {ItineraryItem, PlannerStop} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import {sectionItems, sectionName} from '@site/lib/derive'; +import {Sheet} from '../paper/Sheet'; +import {Idx} from './SectionHead'; +import {Tabs} from './Tabs'; + +const FIRST = 4; + +function stopsOf(item: ItineraryItem): PlannerStop[] { + return item.days?.[0]?.stops ?? item.stops ?? []; +} + +export function Itinerary() { + const payload = useSite(); + const parsed = sectionItems(payload, 'itinerary'); + const [tag, setTag] = useState(''); + const [open, setOpen] = useState(null); + const items = parsed.items; + if (items.length === 0) return null; + const tags = [...new Set(items.map((item) => item.duration).filter((d): d is string => Boolean(d)))]; + const shown = items.filter((item) => !tag || item.duration === tag); + + const card = (item: ItineraryItem, i: number) => ( +
  • + +
  • + ); + + return ( +
    + +

    {parsed.subtitle || '하루를 어떻게 쓸지 미리 짜 두었습니다.'}

    + {tags.length > 1 && ( + ({ + id: t, + label: `${t || '전체'} ${items.filter((item) => !t || item.duration === t).length}`, + }))} + value={tag} + onChange={setTag} + /> + )} +
      {shown.slice(0, FIRST).map(card)}
    + {shown.length > FIRST && ( +
    + + {shown.length - FIRST}개 더 보기 + + + +
      {shown.slice(FIRST).map((item, i) => card(item, i + FIRST))}
    +
    + )} + setOpen(null)} label={open?.name ?? ''}> + {open && ( + <> +

    {open.name}

    +

    {[open.duration, open.audience].filter(Boolean).join(' · ')}

    + {open.why &&

    {open.why}

    } + {(open.days?.length ? open.days : [{label: undefined, stops: open.stops}]).map((day, i) => ( +
    + {day.label &&

    {day.label}

    } +
      + {(day.stops ?? []).map((stop, j) => ( +
    • + {stop.name} + {(stop.note || stop.minutes) && ( + + {[stop.minutes && `${stop.minutes}분 머뭅니다`, stop.note].filter(Boolean).join(' · ')} + + )} +
    • + ))} +
    +
    + ))} + + )} +
    +
    + ); +} diff --git a/solution/site/src/layouts/bigtype/Location.tsx b/solution/site/src/layouts/bigtype/Location.tsx new file mode 100644 index 0000000..05ea8c1 --- /dev/null +++ b/solution/site/src/layouts/bigtype/Location.tsx @@ -0,0 +1,74 @@ +import {useState} from 'react'; +import {useSite} from '@site/lib/site-context'; +import {essentialRows} from '@site/lib/derive'; +import {kakaoDirectionsUrl, naverDirectionsUrl, osmEmbedUrl} from '@site/lib/format'; +import {Idx} from './SectionHead'; + +export function Location() { + const payload = useSite(); + const {place, routes} = payload; + const [copied, setCopied] = useState(false); + const address = place.roadAddress ?? place.address; + if (!address) return null; + const {latitude: lat, longitude: lng} = place; + const parking = essentialRows(payload).find((row) => row.key === 'parking'); + const lot = place.roadAddress && place.address && place.address !== place.roadAddress ? place.address : undefined; + + const copy = () => { + navigator.clipboard.writeText(address).then( + () => { + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + }, + () => setCopied(false), + ); + }; + + return ( +
    + + {lat != null && lng != null ? ( +