[feat] solution/site,shared: 숙박 템플릿 8종 추가 — 라운드·시네마·빅타이포·부티크·일러스트·코랄·미니멀·솔숲

사장님이 고를 수 있는 숙박 템플릿이 고택 하나뿐이었다. 모두 stay2 콘텐츠(날씨·미니 블로그·
지역 이야기·객실·축제·일정·후기·엽서·노래)를 그대로 싣고 모양만 다르다.

- layouts/<id>/: 템플릿별 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건
This commit is contained in:
Mina Choi 2026-09-30 13:45:10 +09:00
parent 7f6f16f307
commit 013cdcfc9f
153 changed files with 15672 additions and 240 deletions

View File

@ -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/<slug>`, 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)

View File

@ -1,5 +1,7 @@
# 템플릿과 렌더링 # 템플릿과 렌더링
화면 규칙(글자 크기 · 간격 · 접기 · ✓ 표시)은 [TEMPLATE_DESIGN.md](TEMPLATE_DESIGN.md).
사장님이 고르는 "템플릿"이 어디에 정의돼 있고, 화면에 어떤 순서로 그려지는지 적은 문서다. 사장님이 고르는 "템플릿"이 어디에 정의돼 있고, 화면에 어떤 순서로 그려지는지 적은 문서다.
2026-09-28에 구조를 한 번 갈아엎었고, 이 문서는 그 뒤의 모습이다. 2026-09-28에 구조를 한 번 갈아엎었고, 이 문서는 그 뒤의 모습이다.
@ -32,12 +34,12 @@
| 칸 | 뜻 | | 칸 | 뜻 |
|---|---| |---|---|
| `name` · `tag` · `description` | 빌더에서 사장님이 보는 이름과 설명 | | `name` · `tag` · `description` | 빌더에서 사장님이 보는 이름과 설명 |
| `layout` | 어떤 뼈대로 그릴지. 지금은 `basic`과 `paper` 두 개 | | `layout` | 어떤 뼈대로 그릴지. `basic` · `paper` · `round` · `cinema` · `bigtype` · `boutique` · `graphic` · `coral` · `minimal` · `pine` |
| `colors` | 기본 색. 사장님이 팔레트를 고르면 그 색이 위에 덮인다 | | `colors` | 기본 색. 사장님이 팔레트를 고르면 그 색이 위에 덮인다 |
| `look` | 서체, 모서리, 그림자, 섹션 간격. 사장님이 못 바꾼다 | | `look` | 서체, 모서리, 그림자, 섹션 간격. 사장님이 못 바꾼다 |
| `addSections` | 이 템플릿을 고르면 새로 켜지는 섹션. 레트로의 일력·영상 같은 것 | | `addSections` | 이 템플릿을 고르면 새로 켜지는 섹션. 레트로의 일력·영상 같은 것 |
지금 템플릿은 네 개다. 지금 템플릿은 아홉 개다.
| id | 이름 | 뼈대 | | id | 이름 | 뼈대 |
|---|---|---| |---|---|---|
@ -45,12 +47,24 @@
| `magazine` | 매거진 | basic | | `magazine` | 매거진 | basic |
| `retro` | 레트로 | basic | | `retro` | 레트로 | basic |
| `paper` | 고택 | paper | | `paper` | 고택 | paper |
| `round` | 라운드 | round |
| `cinema` | 시네마 | cinema |
| `bigtype` | 빅타이포 | bigtype |
| `boutique` | 부티크 | boutique |
| `graphic` | 일러스트 | graphic |
| `coral` | 코랄 | coral |
| `minimal` | 미니멀 | minimal |
| `pine` | 솔숲 | pine |
심플·매거진·레트로는 뼈대가 같고 색과 서체만 다르다. 고택은 손으로 만든 시안 `/s/stay2`를 심플·매거진·레트로는 뼈대가 같고 색과 서체만 다르다. 고택은 손으로 만든 시안 `/s/stay2`를
그대로 옮긴 것이라 뼈대부터 다르다. 폭 640px짜리 한 단에, 위쪽 탭 네 개(소개 · 지역 소개 · 그대로 옮긴 것이라 뼈대부터 다르다. 폭 640px짜리 한 단에, 위쪽 탭 네 개(소개 · 지역 소개 ·
이용안내·예약 · 이야기)로 화면을 나눈다. 탭을 눌러도 페이지를 새로 받지 않는다. 내용은 전부 한 이용안내·예약 · 이야기)로 화면을 나눈다. 탭을 눌러도 페이지를 새로 받지 않는다. 내용은 전부 한
HTML 안에 있고 보이는 묶음만 바뀐다. 검색엔진은 네 묶음을 다 읽는다. HTML 안에 있고 보이는 묶음만 바뀐다. 검색엔진은 네 묶음을 다 읽는다.
라운드·시네마·빅타이포·부티크·일러스트·코랄·미니멀·솔숲은 고택과 같은 섹션과 탭 네 개를 그대로 받고 모양만 다르다.
예약 시트와 예약 폼은 고택 부품을 가져다 쓴다. 모바일 390px에서 먼저 맞췄다. 일러스트는 사진이
거의 없는 집을 위한 것이라, 첫 화면과 객실 카드를 그림으로 채운다.
업종마다 쓸 수 있는 템플릿과 기본 템플릿은 같은 파일의 `industries`에 적는다. 숙박은 레트로가 업종마다 쓸 수 있는 템플릿과 기본 템플릿은 같은 파일의 `industries`에 적는다. 숙박은 레트로가
기본이고, 병원은 심플과 매거진만 쓸 수 있다. 기본이고, 병원은 심플과 매거진만 쓸 수 있다.

106
docs/TEMPLATE_DESIGN.md Normal file
View File

@ -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. 스크롤 줄이기 — 몇 개 보이고 접나
`<details>` 로 접는다. 내용은 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. 값 보여주기
- **‘가능 · 있음’은 ✓, ‘불가 · 없음’은 ✕ 목록으로 쓴다.** `주차 / 가능` 같은 표로 쓰지 않는다
- 마크업: `<ul class="amen"><li class="on"><i class="ic">✓</i><span><b>주차</b> 가능</span></li>`
- 색: ✓ `#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`

View File

@ -106,6 +106,206 @@
"sectionSpace": "2.5rem" "sectionSpace": "2.5rem"
}, },
"addSections": [] "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": { "industries": {
@ -135,7 +335,15 @@
"retro", "retro",
"simple", "simple",
"magazine", "magazine",
"paper" "paper",
"round",
"cinema",
"bigtype",
"boutique",
"graphic",
"coral",
"minimal",
"pine"
], ],
"sections": [ "sections": [
{ {

View File

@ -3,7 +3,7 @@ import type {IndustryDef, IndustryType, LayoutId, TemplateDef} from '../types';
export type TemplateId = keyof typeof catalog.templates; 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<TemplateId, TemplateDef>; export const TEMPLATES = catalog.templates as Record<TemplateId, TemplateDef>;

View File

@ -63,7 +63,7 @@ export interface TemplateLook {
texture?: string; texture?: string;
} }
export type LayoutId = 'basic' | 'paper'; export type LayoutId = 'basic' | 'paper' | 'round' | 'cinema' | 'bigtype' | 'boutique' | 'graphic' | 'coral' | 'minimal' | 'pine';
export interface TemplateDef { export interface TemplateDef {
name: string; name: string;

View File

@ -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 과 동일)"
}
]
}
}

View File

@ -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<LocalPlace | null>(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) => (
<li key={place.name}>
<button type="button" className={thumb && place.imageUrl ? 'g' : 'g g--flat'} onClick={() => setOpen(place)}>
<span className="m">{String(i + 1).padStart(2, '0')}</span>
<span>
<span className="t">{place.name}</span>
{(place.distanceText || place.description) && (
<span className="d">{[place.distanceText, place.description].filter(Boolean).join(' · ')}</span>
)}
</span>
{thumb && place.imageUrl && (
<span className="ph">
<img src={place.imageUrl} alt={place.name} loading="lazy" />
</span>
)}
</button>
</li>
);
return (
<>
{rail.length > 0 && (
<div data-pg="home">
<section id="around" aria-labelledby="around-heading">
<Idx id="around-heading" title="주변" aside={`${rail.length} Places`} />
<ul className="guide guide--sm">{rail.slice(0, HOME_ROWS).map((place, i) => item(place, i))}</ul>
<a href="#/area" className="line">
<span>지역 소개에서 모두 보기</span>
<span>→</span>
</a>
</section>
</div>
)}
<div data-pg="area" id="guide">
{spots.length > 0 && (
<section id="walk" aria-labelledby="walk-heading">
<Idx id="walk-heading" title="주변 명소" aside={`${spots.length} Spots`} />
<Tabs items={rangeTabs(spots)} value={spotRange} onChange={setSpotRange} />
<ul className="guide">{shownSpots.slice(0, firstSpots).map((place, i) => item(place, i))}</ul>
{shownSpots.length > firstSpots && (
<details>
<summary className="line">
<span>{shownSpots.length - firstSpots}곳 더 보기</span>
<span>+</span>
</summary>
<ul className="guide">
{shownSpots.slice(firstSpots).map((place, i) => item(place, i + firstSpots))}
</ul>
</details>
)}
</section>
)}
{eats.length > 0 && (
<section id="eat" aria-labelledby="eat-heading">
<Idx id="eat-heading" title="주변 맛집" aside={`${eats.length} Tables`} />
<Tabs items={rangeTabs(eats)} value={eatRange} onChange={setEatRange} />
<ul className="guide">{shownEats.slice(0, FIRST_ROWS).map((place, i) => item(place, i, false))}</ul>
{shownEats.length > FIRST_ROWS && (
<details>
<summary className="line">
<span>{shownEats.length - FIRST_ROWS}곳 더 보기</span>
<span>+</span>
</summary>
<ul className="guide">
{shownEats.slice(FIRST_ROWS).map((place, i) => item(place, i + FIRST_ROWS, false))}
</ul>
</details>
)}
</section>
)}
</div>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
{open && (
<>
{open.imageUrl && <img src={open.imageUrl} alt={open.name} className="r32" />}
<h3>{open.name}</h3>
<p className="meta">{[open.distanceText, open.category].filter(Boolean).join(' · ')}</p>
{open.description && <p>{open.description}</p>}
<a
className="out"
href={naverMapSearchUrl(open.searchQuery ?? open.name)}
target="_blank"
rel="noopener noreferrer"
>
<span>네이버에서 보기</span>
<span>↗</span>
</a>
</>
)}
</Sheet>
</>
);
}

View File

@ -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 (
<section className="book" id={full ? 'booking' : undefined} aria-labelledby={id}>
<p className="m">
<span className="no" /> Reservation
</p>
<h2 id={id}>
이용안내
<br />및 예약
</h2>
<p className="bl">요금과 예약 방법을 안내합니다.</p>
<div className="acts">
{(full ? links : links.slice(0, 1)).map((link) => (
<a key={link.url} href={link.url} target="_blank" rel="noopener noreferrer">
<span>{bookingActionLabel(link)}</span>
<span>↗</span>
</a>
))}
{full && phone && (
<a href={`tel:${phone}`}>
<span>{phone}</span>
<span>→</span>
</a>
)}
{full && stay && (
<button type="button" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
<span>예약하기</span>
<span>→</span>
</button>
)}
{!full && (
<a href="#/stay">
<span>이용안내 보기</span>
<span>→</span>
</a>
)}
</div>
</section>
);
}
export function Booking() {
return (
<>
<div data-pg="home">
<Block id="booking-home-heading" />
</div>
<div data-pg="stay">
<Block id="booking-heading" full />
</div>
</>
);
}

View File

@ -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) => (
<li key={faq.faqId}>
<details>
<summary>
<span className="m">Q{i + 1}</span>
<span>{faq.question}</span>
</summary>
<p>{faq.answer}</p>
</details>
</li>
);
return (
<section id="faq" aria-labelledby="faq-heading">
<Idx id="faq-heading" title="자주 묻는 질문" aside={`${faqs.length} Q`} />
{verified && <p className="lead">아래 답변은 모두 사업자가 확인한 내용입니다.</p>}
<ul className="faq">{faqs.slice(0, FIRST).map(item)}</ul>
{faqs.length > FIRST && (
<details>
<summary className="line">
<span>{faqs.length - FIRST}개 더 보기</span>
<span>+</span>
</summary>
<ul className="faq">{faqs.slice(FIRST).map((faq, i) => item(faq, i + FIRST))}</ul>
</details>
)}
</section>
);
}

View File

@ -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<FestivalEntry | null>(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 (
<section id="festival" aria-labelledby="festival-heading">
<Idx id="festival-heading" title="계절별 축제" aside={`${festivals.length} Events`} />
<p className="lead">{payload.place.addressLocality ?? '이 지역'}의 축제와 행사를 계절로 묶었습니다.</p>
{tabs.length > 2 && <Tabs items={tabs} value={season} onChange={setSeason} />}
<div className="rail">
<Car
key={season}
multi
width={200}
arrows
label="계절별 축제"
slides={shown.map((f) => (
<button type="button" className="fc" onClick={() => setOpen(f)}>
{f.imageUrl ? (
<span className="ph">
<img src={f.imageUrl} alt={f.name} loading="lazy" />
</span>
) : (
<span className="noimg">{f.name}</span>
)}
<span className="t">{f.name}</span>
<span className="d">{[f.month, f.location].filter(Boolean).join(' · ')}</span>
</button>
))}
/>
</div>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
{open && (
<>
{open.imageUrl && <img src={open.imageUrl} alt={open.name} className="r32" />}
<h3>{open.name}</h3>
<p className="meta">{[open.period ?? open.month, open.location].filter(Boolean).join(' · ')}</p>
{open.description && <p>{open.description}</p>}
<a
className="out"
href={open.officialUrl ?? naverSearchUrl(open.searchQuery)}
target="_blank"
rel="noopener noreferrer"
>
<span>자세히 보기</span>
<span>↗</span>
</a>
</>
)}
</Sheet>
</section>
);
}

View File

@ -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<number, string> = {
[PlaceCategory.LODGING]: '펜션',
[PlaceCategory.CAFE]: '카페',
[PlaceCategory.RESTAURANT]: '가게',
[PlaceCategory.CLINIC]: '병원',
};
type PageId = 'home' | 'area' | 'stay' | 'story';
const PAGE_HEAD: Record<Exclude<PageId, 'home'>, {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<PageId>('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 ? (
<button type="button" className="cta" onClick={() => setBookingOpen(true)}>
예약
</button>
) : (
booking && (
<a className="cta" href={booking.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(booking)}
</a>
)
);
return (
<div className="w4-bigtype" data-page={page}>
<div className="bar m">
<a href="#/">{place.name}</a>
{place.phone ? <a href={`tel:${place.phone}`}>Call (+)</a> : <span>Menu (+)</span>}
</div>
<div className="word" data-pg="home">
<p className="wm" style={markStyle}>
{mark.lines.map((line) => (
<span key={line}>{line}</span>
))}
</p>
<div className="row m">
{locality && <span>{locality}</span>}
{place.latitude != null && place.longitude != null && (
<span>
{place.latitude.toFixed(1)}°N {place.longitude.toFixed(1)}°E
</span>
)}
<span>{HOUSE_WORD[place.category] ?? '가게'}</span>
</div>
</div>
<nav className="tabs m" aria-label="페이지">
{(place.phone || hasCta) && (
<span className="hx">
{place.phone && <a href={`tel:${place.phone}`}>Call {place.phone}</a>}
{cta}
</span>
)}
{tabs.map((tab) => (
<a
key={tab.page}
href={tab.page === 'home' ? '#/' : `#/${tab.page}`}
className={tab.page === page ? 'on' : undefined}
aria-current={tab.page === page ? 'page' : undefined}
>
{tab.label}
</a>
))}
</nav>
{(Object.keys(PAGE_HEAD) as Exclude<PageId, 'home'>[]).map((id) => (
<div key={id} data-pg={id} className="pagehead">
<p className="wm" style={{'--wm': 'calc(24 * var(--u))'} as CSSProperties}>
{id === 'area' ? (place.addressLocality?.replace(/(시|군)$/, '') ?? PAGE_HEAD.area.title) : PAGE_HEAD[id].title}
</p>
<p className="lead">{PAGE_HEAD[id].note}</p>
{id === 'area' && areaPills.length > 0 && (
<div className="pills m">
{areaPills.map(([label, href]) => (
<a key={href} href={href}>
{label}
</a>
))}
</div>
)}
</div>
))}
{children}
{channels.length > 0 && (
<div data-pg="stay">
<section id="channels">
<Idx title="채널" aside="Official" />
{channels.map((link) => (
<a key={link.url} className="line" href={link.url} target="_blank" rel="noopener noreferrer">
<span>{channelLabel(link)}</span>
<span className="m">Open ↗</span>
</a>
))}
</section>
</div>
)}
<footer>
<p className="m">
{place.name}
{address && ` — ${address}`}
</p>
{hours.length > 0 && <p className="m">{hours.join(' · ')}</p>}
{(place.phone || instagram) && (
<p className="m">
{place.phone && <a href={`tel:${place.phone}`}>{place.phone}</a>}
{place.phone && instagram && ' · '}
{instagram && (
<a href={instagram.url} target="_blank" rel="noopener noreferrer">
instagram
</a>
)}
</p>
)}
{place.legal?.businessRegistrationNumber && (
<p className="m">
{place.legal.representative && `대표 ${place.legal.representative} · `}
사업자등록번호 {place.legal.businessRegistrationNumber}
{place.legal.mailOrderNumber && ` · 통신판매업신고 ${place.legal.mailOrderNumber}`}
</p>
)}
<p className="byline credit m">
<span className="author">작성·운영 {place.name}</span> · 최종 업데이트{' '}
<time dateTime={site.updatedAt}>{isoDate(site.updatedAt)}</time> ·{' '}
<a href="https://www.o2osolution.ai/" target="_blank" rel="noopener noreferrer">
AI O2O
</a>
의 Web4Ai로 만든 사이트
</p>
<p className="big" style={markStyle}>
{mark.lines[0]}.
</p>
</footer>
{hasSongs && (
<div className={showBar ? 'player' : 'player low'}>
<SongPlayer />
</div>
)}
{showBar && (
<nav className="tabbar" aria-label="연락 · 예약" style={{gridTemplateColumns: columns}}>
{address && (
<a href={naverDirectionsUrl(place.name, place.latitude, place.longitude)} target="_blank" rel="noopener noreferrer nofollow">
MAP
</a>
)}
{kakao && (
<a href={kakao.url} target="_blank" rel="noopener noreferrer">
TALK
</a>
)}
{place.phone && <a href={`tel:${place.phone}`}>CALL</a>}
{cta}
</nav>
)}
{stayBooking && (
<Sheet open={bookingOpen} onClose={() => setBookingOpen(false)} label="예약 요청" plain>
<div className="sform">
<h3>예약 요청</h3>
<BookingForm />
</div>
</Sheet>
)}
</div>
);
}

View File

@ -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<MediaItem | null>(null);
if (images.length === 0) return null;
const cell = (image: MediaItem) => (
<button key={image.mediaId} type="button" onClick={() => setOpen(image)} aria-label={image.alt}>
<img src={image.url} alt={image.alt} loading="lazy" />
</button>
);
return (
<div data-pg="home">
<section id="gallery" aria-labelledby="gallery-heading">
<Idx
id="gallery-heading"
title={sectionName(payload, 'photos', '사진')}
aside={`${String(images.length).padStart(2, '0')} Frames`}
/>
<div className="gal gal--lead">{images.slice(0, FIRST).map(cell)}</div>
{images.length > FIRST && (
<details>
<summary className="line">
<span>사진 {images.length - FIRST}장 더 보기</span>
<span>+</span>
</summary>
<div className="gal">{images.slice(FIRST).map(cell)}</div>
</details>
)}
</section>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.alt ?? ''}>
{open && (
<>
<img src={open.url} alt={open.alt} className="r32" />
<h3>{open.alt}</h3>
{open.category && <p className="meta">{open.category}</p>}
</>
)}
</Sheet>
</div>
);
}

View File

@ -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 (
<>
<section className={first ? 'hero' : 'hero hero--text'} id="top" data-pg="home">
{first && (
<div className="ph">
<img src={first.url} alt={first.alt} fetchPriority="high" decoding="async" />
</div>
)}
<div className="txt">
<p className="m">
<span className="no" /> 오늘의 문장
</p>
{(lodging || fixed) && <p className="rot">{lodging ? <HeroCatchphrase /> : fixed}</p>}
{lodging && fixed && <p className="m sub">{fixed}</p>}
<div className="hx">
{stay ? (
<button type="button" className="line line--ink" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
<span>예약하기</span>
<span>→</span>
</button>
) : (
link && (
<a className="line line--ink" href={link.url} target="_blank" rel="noopener noreferrer">
<span>{bookingActionLabel(link)}</span>
<span>↗</span>
</a>
)
)}
{place.phone && (
<a className="line" href={`tel:${place.phone}`}>
<span>{place.phone}</span>
<span>Call</span>
</a>
)}
<a className="line" href="#/stay">
<span>이용안내</span>
<span>→</span>
</a>
</div>
</div>
{second && (
<div className="wide">
<img src={second.url} alt={second.alt} loading="lazy" decoding="async" />
</div>
)}
</section>
{isSectionEnabled(payload, 'weather') && (
<div data-editor-id="weather" style={{display: 'contents'}}>
<Weather />
</div>
)}
</>
);
}

View File

@ -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 (
<div className={long ? 'long' : undefined}>
<span>{row.label}</span>
<b>
{row.value}
{row.note && ` — ${row.note}`}
</b>
</div>
);
}
function Mark({on}: {on: boolean}) {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden>
{on ? <path d="M3 8.5l3 3 7-7" /> : <path d="M4 4l8 8M12 4l-8 8" />}
</svg>
);
}
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 (
<section id="info" aria-labelledby="info-heading">
<Idx id="info-heading" title="이용안내" />
<p className="lead">방문 전 확인이 필요한 운영 규정과 시설 안내입니다.</p>
{ruleRows.length > 0 && (
<>
<p className="m band">예약 전 확인</p>
<div className="spec spec--flush m">
{[...short, ...long].map((row) => (
<Row key={row.key ?? row.label} row={row} />
))}
</div>
</>
)}
{detailRows.length + sorted.length > 0 && (
<>
<p className="m band">시설 · 편의</p>
{detailRows.length > 0 && (
<div className="spec spec--flush m">
{detailRows.map((row) => (
<Row key={row.key ?? row.label} row={row} />
))}
</div>
)}
{sorted.length > 0 && (
<ul className="amen">
{sorted.map((row) => {
const on = ON_VALUES.has(row.value);
return (
<li key={row.key ?? row.label} className={on ? 'on' : 'off'}>
<Mark on={on} />
<span>
<b>{row.label}</b> {row.value}
</span>
</li>
);
})}
</ul>
)}
</>
)}
{notices.length > 0 && (
<ul className="faq">
{notices.map((text) => (
<li key={text.slice(0, 32)}>
<details>
<summary>
<span className="m">!</span>
<span>예약 공지</span>
</summary>
<p style={{whiteSpace: 'pre-line'}}>{text}</p>
</details>
</li>
))}
</ul>
)}
</section>
);
}

View File

@ -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 (
<div className="post">
<p className="m">
<time dateTime={date}>{dotDate(date)}</time>
</p>
<p className="pb">{body}</p>
</div>
);
}
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 (
<section id={full ? 'blog' : undefined} aria-labelledby={full ? 'blog-heading' : undefined}>
<Idx id={full ? 'blog-heading' : undefined} title="미니 블로그" aside={`${(payload.posts ?? []).length} Posts`} />
<div data-blog={full ? 'full' : 'home'}>
{shown.map((item) => (
<Post key={item.postId} date={item.publishedAt} body={item.body} />
))}
</div>
{more && (
<a href="#/story" className="line">
<span>이야기에서 지난 소식 더 보기</span>
<span>→</span>
</a>
)}
{older.length > 0 && (
<details>
<summary className="line">
<span>지난 소식 {older.length}개</span>
<span>+</span>
</summary>
{older.map((item) => (
<Post key={item.postId} date={item.publishedAt} body={item.body} />
))}
</details>
)}
</section>
);
}
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 (
<>
<div data-pg="story">
<Blog full />
</div>
<div data-pg="home">
<Blog />
{about.length > 0 && (
<section id="about" aria-labelledby="about-heading">
<Idx id="about-heading" title={`${place.name} 소개`} />
<div className="body">
{narrative.summary && <p className="sum">{narrative.summary}</p>}
<p>{about[0]}</p>
{about.length > 1 && (
<details>
<summary className="line">
<span>더 읽기</span>
<span>+</span>
</summary>
{about.slice(1).map((paragraph, i) => (
<p key={i}>{paragraph}</p>
))}
</details>
)}
{spec.length > 0 && (
<div className="spec m">
{spec.map((row) => (
<div key={row.key}>
<span>{row.label}</span>
<b>{row.value}</b>
</div>
))}
</div>
)}
</div>
</section>
)}
{image && (
<figure className="fig">
<img src={image.url} alt={image.alt} loading="lazy" />
</figure>
)}
</div>
</>
);
}

View File

@ -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<ItineraryItem>(payload, 'itinerary');
const [tag, setTag] = useState('');
const [open, setOpen] = useState<ItineraryItem | null>(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) => (
<li key={item.name}>
<button type="button" className="g g--flat" onClick={() => setOpen(item)}>
<span className="m">{String(i + 1).padStart(2, '0')}</span>
<span>
<span className="t">{item.name}</span>
<span className="d">{[item.duration, item.audience].filter(Boolean).join(' · ')}</span>
{item.why && <span className="w">{item.why}</span>}
<span className="d m">
{stopsOf(item)
.slice(0, 4)
.map((stop) =>
[stop.name.split(' · ').pop(), stop.minutes && `${stop.minutes}분`].filter(Boolean).join(' '),
)
.join(' → ')}
</span>
</span>
</button>
</li>
);
return (
<section id="itinerary" aria-labelledby="itinerary-heading">
<Idx
id="itinerary-heading"
title={parsed.title || sectionName(payload, 'itinerary', '추천 일정')}
aside={`${items.length} Courses`}
/>
<p className="lead">{parsed.subtitle || '하루를 어떻게 쓸지 미리 짜 두었습니다.'}</p>
{tags.length > 1 && (
<Tabs
items={['', ...tags].map((t) => ({
id: t,
label: `${t || '전체'} ${items.filter((item) => !t || item.duration === t).length}`,
}))}
value={tag}
onChange={setTag}
/>
)}
<ul className="guide">{shown.slice(0, FIRST).map(card)}</ul>
{shown.length > FIRST && (
<details>
<summary className="line">
<span>{shown.length - FIRST}개 더 보기</span>
<span>+</span>
</summary>
<ul className="guide">{shown.slice(FIRST).map((item, i) => card(item, i + FIRST))}</ul>
</details>
)}
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
{open && (
<>
<h3>{open.name}</h3>
<p className="meta">{[open.duration, open.audience].filter(Boolean).join(' · ')}</p>
{open.why && <p>{open.why}</p>}
{(open.days?.length ? open.days : [{label: undefined, stops: open.stops}]).map((day, i) => (
<div key={i}>
{day.label && <p className="meta">{day.label}</p>}
<ul className="stops">
{(day.stops ?? []).map((stop, j) => (
<li key={j}>
<b>{stop.name}</b>
{(stop.note || stop.minutes) && (
<span>
{[stop.minutes && `${stop.minutes}분 머뭅니다`, stop.note].filter(Boolean).join(' · ')}
</span>
)}
</li>
))}
</ul>
</div>
))}
</>
)}
</Sheet>
</section>
);
}

View File

@ -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 (
<section className="loc" id="location" aria-labelledby="location-heading">
<Idx id="location-heading" title="오시는 길" />
{lat != null && lng != null ? (
<iframe
className="map"
title={`${place.name} 위치 지도`}
src={osmEmbedUrl(lat, lng, 1)}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
) : (
<div className="map map--grid" aria-hidden />
)}
<div className="t">
<address>
<b>{address}</b>
</address>
{lot && <p className="m">지번 {lot}</p>}
{parking && <p className="m">주차 {[parking.value, parking.note].filter(Boolean).join(' — ')}</p>}
</div>
{routes.length > 0 && (
<div className="spec spec--flush m">
{routes.map((route) => (
<div key={route.destination}>
<span>{route.destination}</span>
<b>
{[route.byCar && `차 ${route.byCar}`, route.byTransit, route.distanceText].filter(Boolean).join(' · ')}
</b>
</div>
))}
</div>
)}
<button type="button" className="line" onClick={copy}>
<span>{copied ? '복사했습니다' : '주소 복사'}</span>
<span>+</span>
</button>
<a className="line" href={naverDirectionsUrl(place.name, lat, lng)} target="_blank" rel="noopener noreferrer nofollow">
<span>네이버 지도 길찾기</span>
<span>↗</span>
</a>
<a className="line" href={kakaoDirectionsUrl(place.name, lat, lng)} target="_blank" rel="noopener noreferrer nofollow">
<span>카카오맵 길찾기</span>
<span>↗</span>
</a>
</section>
);
}

View File

@ -0,0 +1,136 @@
import {useState} from 'react';
import {factText, sanitizeUnits} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {sectionName, unitSpec, unitViews, type UnitView} from '@site/lib/derive';
import {Car} from '../paper/Car';
import {BOOK_EVENT} from '../paper/Rooms';
import {Sheet} from '../paper/Sheet';
import {Idx} from './SectionHead';
function splitName(name: string): [string, string | undefined] {
const m = /^(.+?)\s*\((.+)\)\s*$/.exec(name);
return m ? [m[1], m[2]] : [name, undefined];
}
const HOME_MAX = 4;
const letter = (i: number) => String.fromCharCode(65 + (i % 26));
function specRows(unit: UnitView, facts: Parameters<typeof factText>[0]) {
const bed = [factText(facts, 'bed_type'), factText(facts, 'room_size')].filter(Boolean).join(' · ');
const std = factText(facts, 'standard_capacity');
const max = factText(facts, 'max_capacity');
const people = [std && `기준 ${std.replace(/명$/, '인')}`, max && `최대 ${max.replace(/명$/, '인')}`]
.filter(Boolean)
.join(' · ');
const price = unit.priceText ?? unit.prices.map((p) => `${p.label} ${p.value}`).join(' · ');
return [
['침대', bed],
['정원', people],
['요금', price],
].filter(([, value]) => value) as [string, string][];
}
export function Rooms() {
const payload = useSite();
const units = unitViews(payload);
const spec = unitSpec(payload);
const raw = new Map(sanitizeUnits(payload.units).map((unit) => [unit.unitId, unit]));
const [open, setOpen] = useState<UnitView | null>(null);
if (units.length === 0) return null;
const rowsOf = (unit: UnitView) => specRows(unit, raw.get(unit.unitId)?.facts ?? []);
const title = sectionName(payload, spec.path, `${spec.label} 안내`);
return (
<>
<div data-pg="home">
<section id="units" aria-labelledby="units-heading">
<Idx id="units-heading" title={title} aside={`${units.length} Units`} />
<div className="rooms2">
{units.slice(0, HOME_MAX).map((unit, i) => {
const people = rowsOf(unit).find(([label]) => label === '정원')?.[1];
return (
<a key={unit.unitId} href="#/stay">
<div className="ph">
{unit.images[0] ? (
<img src={unit.images[0].url} alt={unit.images[0].alt} loading="lazy" />
) : (
<span className="noimg" style={{height: '100%'}} />
)}
</div>
<div className="cap">
<span className="m">{letter(i)}</span>
<h3>{unit.name}</h3>
{people && <p>{people}</p>}
</div>
</a>
);
})}
</div>
<a href="#/stay" className="line">
<span>이용안내에서 전체 보기</span>
<span>→</span>
</a>
</section>
</div>
<div data-pg="stay">
{units.map((unit, i) => {
const [main, sub] = splitName(unit.name);
return (
<section key={unit.unitId} id={`unit-${unit.slug}`}>
<Idx title={main} aside={`Room ${letter(i)}${sub ? ` · ${sub}` : ''}`} />
{unit.images.length > 0 && (
<Car
label={`${main} 사진`}
arrows
count
slides={unit.images.map((image) => (
<img key={image.mediaId} src={image.url} alt={image.alt} loading="lazy" className="r45" />
))}
/>
)}
<div className="body">
{unit.intro && <p>{unit.intro}</p>}
<div className="spec m">
{rowsOf(unit).map(([label, value]) => (
<div key={label}>
<span>{label}</span>
<b>{value}</b>
</div>
))}
</div>
</div>
<button type="button" className="line" onClick={() => setOpen(unit)}>
<span>{main} 자세히</span>
<span>+</span>
</button>
<button
type="button"
className="line line--ink"
onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}
>
<span>{main} 예약 요청</span>
<span>→</span>
</button>
</section>
);
})}
</div>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
{open && (
<>
{open.images[0] && <img src={open.images[0].url} alt={open.images[0].alt} className="r32" />}
<h3>{open.name}</h3>
{open.intro && <p>{open.intro}</p>}
{open.rows.map((row) => (
<p key={row.label}>
{row.label} — {row.value}
</p>
))}
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,23 @@
import type {ReactNode} from 'react';
import type {SectionHeadProps} from '@site/lib/layout';
export function Idx({title, id, aside}: {title: ReactNode; id?: string; aside?: ReactNode}) {
return (
<div className="idx">
<h2 id={id}>{title}</h2>
<span className="m">
<span className="no" />
{aside && <span className="as"> {aside}</span>}
</span>
</div>
);
}
export function SectionHead({id, title, lead, aside}: SectionHeadProps) {
return (
<>
<Idx id={`${id}-heading`} title={title} aside={aside} />
{lead && <p className="lead">{lead}</p>}
</>
);
}

View File

@ -0,0 +1,203 @@
import {useState, type ReactNode} from 'react';
import type {ChronicleItem, PeopleItem, ReadingItem, SongItem} 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 {useWide} from './useWide';
const COUNT_WORD: Record<number, string> = {1: '한', 2: '두', 3: '세', 4: '네'};
function More({rest, unit, children}: {rest: number; unit: string; children: ReactNode}) {
if (rest <= 0) return null;
return (
<details>
<summary className="line">
<span>
{rest}
{unit} 더 보기
</span>
<span>+</span>
</summary>
{children}
</details>
);
}
interface Open {
title: string;
meta?: string;
body?: string;
image?: string;
}
function Row({no, title, sub, right, onClick}: {no: string; title: string; sub?: string; right?: ReactNode; onClick: () => void}) {
return (
<li>
<button type="button" className="g g--row" onClick={onClick}>
<span className="m">{no}</span>
<span>
<span className="t">{title}</span>
{sub && <span className="d">{sub}</span>}
</span>
{right != null && <span className="m">{right}</span>}
</button>
</li>
);
}
const pad = (i: number) => String(i + 1).padStart(2, '0');
export function Story() {
const payload = useSite();
const region = payload.place.addressLocality?.replace(/시$|군$/, '') ?? '지역';
const songs = sectionItems<SongItem>(payload, 'songs');
const people = sectionItems<PeopleItem>(payload, 'people');
const chronicle = sectionItems<ChronicleItem>(payload, 'chronicle');
const reading = sectionItems<ReadingItem>(payload, 'reading');
const [open, setOpen] = useState<Open | null>(null);
const firstPeople = useWide() ? 8 : 6;
const parts = [
songs.items.length > 0 && '노래',
people.items.length > 0 && '인물',
chronicle.items.length > 0 && '연표',
reading.items.length > 0 && '읽기',
].filter(Boolean) as string[];
if (parts.length === 0) return null;
const peopleSorted = [...people.items].sort((a, b) => Number(Boolean(b.imageUrl)) - Number(Boolean(a.imageUrl)));
const withPhoto = people.items.filter((p) => p.imageUrl).length;
const song = (item: SongItem, i: number) => (
<Row
key={item.title + item.artist}
no={pad(i)}
title={item.title}
sub={item.artist}
right={item.year}
onClick={() =>
setOpen({title: item.title, meta: [item.artist, item.year].filter(Boolean).join(' · '), body: item.story})
}
/>
);
const person = (item: PeopleItem) => (
<button
key={item.name}
type="button"
className={item.imageUrl ? undefined : 'txt'}
onClick={() =>
setOpen({
title: item.name,
meta: [item.role, item.years].filter(Boolean).join(' · '),
body: item.oneLine,
image: item.imageUrl,
})
}
>
{item.imageUrl && <img src={item.imageUrl} alt={item.name} loading="lazy" />}
<span className="t">{item.name}</span>
{item.role && <span className="m">{item.role}</span>}
</button>
);
const event = (item: ChronicleItem, i: number) => (
<Row
key={`${item.year}-${item.title}`}
no={item.year ? String(item.year) : pad(i)}
title={item.title}
sub={item.place}
onClick={() =>
setOpen({
title: item.title,
meta: [item.year, item.place].filter(Boolean).join(' · '),
body: item.summary,
image: item.imageUrl,
})
}
/>
);
const book = (item: ReadingItem, i: number) => (
<li key={item.title}>
<button
type="button"
className="g g--flat"
onClick={() => setOpen({title: item.title, meta: item.year ? String(item.year) : item.group, body: item.body})}
>
<span className="m">{pad(i)}</span>
<span>
<span className="t">{item.title}</span>
{item.year && <span className="d m">{item.year}</span>}
<span className="w clamp3">{item.body}</span>
</span>
</button>
</li>
);
return (
<>
<section id="story" aria-labelledby="story-heading">
<Idx id="story-heading" title={sectionName(payload, 'story', `${region} 이야기`)} aside={parts.join(' · ')} />
<p className="lead">
이 도시를 {COUNT_WORD[parts.length] ?? parts.length} 갈래로 봅니다. 하나씩 골라 보세요.
</p>
</section>
{songs.items.length > 0 && (
<section id="songs" aria-labelledby="songs-heading">
<Idx id="songs-heading" title={songs.title || '가요 다방'} aside={`${songs.items.length} Songs`} />
<p className="lead">{region}을 노래한 곡들입니다.</p>
<ul className="guide">{songs.items.slice(0, 6).map(song)}</ul>
<More rest={songs.items.length - 6} unit="곡">
<ul className="guide">{songs.items.slice(6).map((item, i) => song(item, i + 6))}</ul>
</More>
</section>
)}
{people.items.length > 0 && (
<section id="people" aria-labelledby="people-heading">
<Idx id="people-heading" title={people.title || '인물 열전'} aside={`${people.items.length} People`} />
<p className="lead">{region}과 얽힌 사람들입니다.</p>
<div className="ppl">{peopleSorted.slice(0, firstPeople).map(person)}</div>
<More rest={people.items.length - firstPeople} unit="명">
<div className="ppl">{peopleSorted.slice(firstPeople).map(person)}</div>
</More>
{withPhoto > 0 && withPhoto < people.items.length && (
<p className="lead">사진이 있는 {withPhoto}명을 먼저 뒀습니다.</p>
)}
</section>
)}
{chronicle.items.length > 0 && (
<section id="chronicle" aria-labelledby="chronicle-heading">
<Idx id="chronicle-heading" title={chronicle.title || '시간의 골목'} aside={`${chronicle.items.length} Years`} />
<p className="lead">개항부터 지금까지, 이 도시의 연표입니다.</p>
<ul className="guide">{chronicle.items.slice(0, 5).map(event)}</ul>
<More rest={chronicle.items.length - 5} unit="줄">
<ul className="guide">{chronicle.items.slice(5).map((item, i) => event(item, i + 5))}</ul>
</More>
</section>
)}
{reading.items.length > 0 && (
<section id="reading" aria-labelledby="reading-heading">
<Idx id="reading-heading" title={reading.title || `${region} 읽기`} aside={`${reading.items.length} Texts`} />
<p className="lead">{region}을 배경으로 한 문학입니다.</p>
<ul className="guide">{reading.items.slice(0, 3).map(book)}</ul>
<More rest={reading.items.length - 3} unit="편">
<ul className="guide">{reading.items.slice(3).map((item, i) => book(item, i + 3))}</ul>
</More>
</section>
)}
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.title ?? ''}>
{open && (
<>
{open.image && <img src={open.image} alt={open.title} className="r11" />}
<h3>{open.title}</h3>
{open.meta && <p className="meta">{open.meta}</p>}
{open.body && <p>{open.body}</p>}
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,26 @@
export function Tabs<T extends string>({
items,
value,
onChange,
}: {
items: {id: T; label: string}[];
value: T;
onChange: (id: T) => void;
}) {
if (items.length < 2) return null;
return (
<div className="chips" role="tablist">
{items.map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-selected={item.id === value}
onClick={() => onChange(item.id)}
>
{item.label}
</button>
))}
</div>
);
}

View File

@ -0,0 +1,35 @@
import {youtubeId, youtubePoster, type VideoItem} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {Idx} from './SectionHead';
export function Video() {
const payload = useSite();
const parsed = sectionItems<VideoItem>(payload, 'video');
const items = parsed.items.filter((item) => youtubeId(item.url));
if (items.length === 0) return null;
return (
<div data-pg="home">
<section id="video" aria-labelledby="video-heading">
<Idx
id="video-heading"
title={parsed.title || sectionName(payload, 'video', '영상')}
aside={`${items.length} Films`}
/>
{parsed.subtitle && <p className="lead">{parsed.subtitle}</p>}
<div className="films">
{items.map((item) => (
<a key={item.url} href={item.url} target="_blank" rel="noopener noreferrer">
<span className="film">
<img src={youtubePoster(youtubeId(item.url)!)} alt="" loading="lazy" />
<span className="play" />
</span>
{item.caption && <span className="m">{item.caption}</span>}
</a>
))}
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,60 @@
import type {WeatherSky} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {useLiveWeather} from '@site/lib/use-live-weather';
import {useWeatherNotes} from '@site/lib/use-weather-note';
import {Idx} from './SectionHead';
import {weatherBand, weatherMood} from '@site/lib/derive';
function clockOf(iso: string | undefined): string | undefined {
const m = /T(\d{2}):(\d{2})/.exec(iso ?? '');
return m ? `${m[1]}:${m[2]}` : undefined;
}
export function Weather() {
const payload = useSite();
const weather = useLiveWeather({
initial: payload.local.weather,
regionCode: payload.place.regionCode,
latitude: payload.place.latitude,
longitude: payload.place.longitude,
});
const mood = weatherMood(weather?.condition ?? '');
const band = weatherBand(weather?.temperature ?? 0);
const notes = payload.local.weather;
const condition = (weather?.condition ?? '') as WeatherSky;
const skyKey: WeatherSky = notes?.noteSets?.[condition] ? condition : mood;
const [line, bandLine] = useWeatherNotes(
notes?.noteSets?.[skyKey],
notes?.notes?.[mood] ?? weather?.note,
notes?.tempNoteSets?.[band],
notes?.tempNotes?.[band],
);
if (!weather) return null;
const at = clockOf(weather.observedAt);
return (
<div id="weather" data-pg="home" data-band={band}>
<Idx title="날씨" aside={at && `${at} 관측`} />
<div className="wx">
<div className="deg">
{Math.round(weather.temperature)}°<span>{weather.condition}</span>
</div>
{line && (
<div className="note" key={line}>
{line}
</div>
)}
<div className={bandLine ? undefined : 'span2'}>
<p className="m">기온대</p>
<b>{band}</b>
</div>
{bandLine && (
<div key={bandLine}>
<p className="m">한 줄</p>
{bandLine}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,286 @@
.w4-bigtype{--u:min(1vw,4.4px);--paper:var(--tpl-bg,#ecebe6);--ink:var(--tpl-text,#141412);--sub:var(--tpl-secondary,#55544f);--line:var(--ink);--g:16px;--mono:'IBM Plex Mono',ui-monospace,monospace;--serif:'Hahmlet',serif;--k-bg:var(--paper);--k-ink:var(--ink);--k-sub:var(--sub);--k-line:rgba(20,20,18,.35);--k-soft:rgba(20,20,18,.05);--k-r:0px;--k-head:var(--serif);background:var(--paper);color:var(--ink);font-family:var(--serif);font-weight:300;font-size:17px;line-height:1.75;word-break:keep-all;padding-bottom:92px;-webkit-font-smoothing:antialiased;overflow-x:clip;counter-reset:idx}
.w4-bigtype img{display:block;width:100%;height:100%;object-fit:cover;filter:grayscale(.15) contrast(1.03)}
.w4-bigtype a{color:inherit;text-decoration:none}
.w4-bigtype button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer;text-align:left}
.w4-bigtype h2,.w4-bigtype h3,.w4-bigtype p,.w4-bigtype figure,.w4-bigtype ul{margin:0;padding:0}
.w4-bigtype li{list-style:none}
.w4-bigtype .m{font-family:var(--mono);font-size:11px;letter-spacing:.06em;text-transform:uppercase;font-weight:400;line-height:1.5}
.w4-bigtype .bar{display:flex;justify-content:space-between;align-items:center;padding:12px var(--g);border-bottom:1px solid var(--line)}
.w4-bigtype .word{padding:6px var(--g) 0;border-bottom:1px solid var(--line)}
.w4-bigtype .wm{font-weight:900;font-size:min(var(--wm,calc(41 * var(--u))),60px);line-height:.86;letter-spacing:-.07em;margin-left:-.04em}
.w4-bigtype .wm span{display:block}
.w4-bigtype .word .row{display:flex;justify-content:space-between;gap:8px;padding:10px 0 12px}
.w4-bigtype .tabs{position:sticky;top:0;z-index:20;display:flex;background:var(--paper);border-bottom:1px solid var(--line);overflow-x:auto;scrollbar-width:none}
.w4-bigtype .tabs::-webkit-scrollbar{display:none}
.w4-bigtype .tabs a{flex:1 0 auto;padding:12px 14px;border-right:1px solid var(--line);text-align:center;white-space:nowrap}
.w4-bigtype .tabs a:last-child{border-right:0}
.w4-bigtype .tabs a.on{background:var(--ink);color:var(--paper)}
.w4-bigtype .pagehead{padding:10px var(--g) 22px;border-bottom:1px solid var(--line)}
.w4-bigtype .pagehead .lead{padding:14px 0 0}
.w4-bigtype .pills{display:flex;flex-wrap:wrap;gap:0;margin-top:16px;border-top:1px solid var(--line);border-left:1px solid var(--line)}
.w4-bigtype .pills a{padding:10px 12px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}
.w4-bigtype .hero{display:grid;grid-template-columns:1fr 1fr;border-bottom:1px solid var(--line)}
.w4-bigtype .hero .ph{aspect-ratio:3/4;border-right:1px solid var(--line)}
.w4-bigtype .hero .txt{padding:14px 12px;display:flex;flex-direction:column;justify-content:space-between;gap:16px;min-width:0}
.w4-bigtype .hero--text{grid-template-columns:1fr}
.w4-bigtype .hero--text .txt{padding:28px var(--g)}
.w4-bigtype .hero .no:before{content:'(01)'}
.w4-bigtype .rot{font-size:20px;font-weight:500;line-height:1.45;letter-spacing:-.03em}
.w4-bigtype .rot .w4d-line{margin:0}
.w4-bigtype .hero .wide{grid-column:span 2;aspect-ratio:16/10;border-top:1px solid var(--line)}
.w4-bigtype .idx{counter-increment:idx;display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:44px var(--g) 12px;border-bottom:1px solid var(--line)}
.w4-bigtype .idx h2{font-size:28px;font-weight:900;letter-spacing:-.05em;line-height:1}
.w4-bigtype .idx h2 .as,.w4-bigtype .idx h2 span{font-family:var(--mono);font-size:11px;letter-spacing:.06em;font-weight:400;margin-left:8px;vertical-align:middle}
.w4-bigtype .idx .no:before{content:'(' counter(idx,decimal-leading-zero) ')'}
.w4-bigtype .lead{padding:18px var(--g) 0;font-size:18px;line-height:1.85}
.w4-bigtype section{border-bottom:0}
.w4-bigtype .fig{border-bottom:1px solid var(--line);aspect-ratio:4/3;overflow:hidden}
.w4-bigtype #about p:not([class]),.w4-bigtype #about .body p{padding:0 var(--g);margin-top:14px;font-size:18px;line-height:1.85}
.w4-bigtype .spec{margin:24px var(--g) 0;border-top:1px solid var(--line)}
.w4-bigtype .spec div{display:grid;grid-template-columns:110px 1fr;gap:10px;padding:11px 0;border-bottom:1px solid var(--line);align-items:baseline}
.w4-bigtype .spec b,.w4-bigtype .spec dd{margin:0;font-family:var(--serif);font-weight:500;font-size:17px;text-transform:none;letter-spacing:0}
.w4-bigtype .spec--flush{margin-top:18px}
.w4-bigtype .body{padding-bottom:4px}
.w4-bigtype .room{display:block;border-bottom:1px solid var(--line)}
.w4-bigtype .room .ph{aspect-ratio:4/5;overflow:hidden}
.w4-bigtype .room .cap{display:grid;grid-template-columns:44px 1fr auto;gap:4px 10px;padding:14px var(--g);align-items:baseline;border-top:1px solid var(--line)}
.w4-bigtype .room h3{font-size:24px;font-weight:900;letter-spacing:-.04em;line-height:1.1}
.w4-bigtype .room .cap p{grid-column:2/4;font-size:15px;color:var(--sub)}
.w4-bigtype .car{border-bottom:1px solid var(--line)}
.w4-bigtype .car .slide img{aspect-ratio:4/5;height:auto}
.w4-bigtype .car .arrow{position:absolute;top:50%;transform:translateY(-50%);width:40px;height:40px;background:var(--paper);border:1px solid var(--line);display:grid;place-items:center;font-family:var(--mono);text-align:center}
.w4-bigtype .car .prev{left:0;border-left:0}
.w4-bigtype .car .next{right:0;border-right:0}
.w4-bigtype .car .count{position:absolute;right:0;bottom:0;padding:4px 10px;background:var(--paper);border-top:1px solid var(--line);border-left:1px solid var(--line);font-family:var(--mono);font-size:11px}
.w4-bigtype .line{display:flex;justify-content:space-between;align-items:center;gap:12px;width:100%;padding:16px var(--g);border-bottom:1px solid var(--line);font-family:var(--mono);font-size:13px;letter-spacing:.06em;text-transform:uppercase}
.w4-bigtype .line--ink{background:var(--ink);color:var(--paper)}
.w4-bigtype details>summary{list-style:none;cursor:pointer}
.w4-bigtype details>summary::-webkit-details-marker{display:none}
.w4-bigtype details[open]>summary:after{content:'−'}
.w4-bigtype .band{padding:14px var(--g) 0}
.w4-bigtype .amen{display:grid;grid-template-columns:1fr 1fr;margin-top:18px;border-top:1px solid var(--line)}
.w4-bigtype .amen li{display:flex;gap:10px;align-items:center;padding:12px var(--g);border-bottom:1px solid var(--line);font-size:15px;color:var(--sub)}
.w4-bigtype .amen li:nth-child(odd){border-right:1px solid var(--line)}
.w4-bigtype .amen li.on{color:var(--ink);font-weight:500}
.w4-bigtype .amen svg{width:18px;height:18px;flex:none}
.w4-bigtype .book{margin-top:44px;padding:40px var(--g);background:var(--ink);color:var(--paper)}
.w4-bigtype .book h2{margin-top:14px;font-size:32px;font-weight:900;letter-spacing:-.06em;line-height:.95}
.w4-bigtype .book .bl{margin-top:16px;font-size:16px;opacity:.75}
.w4-bigtype .acts{margin-top:28px;border-top:1px solid var(--paper)}
.w4-bigtype .acts>*{display:flex;justify-content:space-between;width:100%;padding:16px 0;border-bottom:1px solid var(--paper);font-family:var(--mono);font-size:14px;letter-spacing:.06em;color:var(--paper)}
.w4-bigtype .gal{display:grid;grid-template-columns:repeat(3,1fr);border-bottom:1px solid var(--line)}
.w4-bigtype .gal>*{aspect-ratio:3/4;border-right:1px solid var(--line);border-bottom:1px solid var(--line);overflow:hidden}
.w4-bigtype .gal>*:nth-child(3n){border-right:0}
.w4-bigtype .loc .map{display:block;width:100%;aspect-ratio:4/3;border:0;border-bottom:1px solid var(--line);filter:grayscale(1) contrast(1.05)}
.w4-bigtype .loc .t{padding:14px var(--g);border-bottom:1px solid var(--line)}
.w4-bigtype .loc address{font-style:normal;font-weight:500;font-size:19px}
.w4-bigtype .chips{display:flex;border-bottom:1px solid var(--line);overflow-x:auto;scrollbar-width:none}
.w4-bigtype .chips::-webkit-scrollbar{display:none}
.w4-bigtype .chips button{flex:none;padding:10px 14px;border-right:1px solid var(--line);font-family:var(--mono);font-size:12px;letter-spacing:.06em}
.w4-bigtype .chips button[aria-selected=true],.w4-bigtype .chips button[aria-pressed=true],.w4-bigtype .chips button[aria-current=true],.w4-bigtype .chips button[aria-current=true]{background:var(--ink);color:var(--paper)}
.w4-bigtype .guide{counter-reset:g}
.w4-bigtype .guide>li{border-bottom:1px solid var(--line)}
.w4-bigtype .g{display:grid;grid-template-columns:36px 1fr 88px;gap:12px;width:100%;padding:14px var(--g);align-items:center}
.w4-bigtype .g .t{display:block;font-family:var(--serif);font-size:21px;font-weight:500;letter-spacing:-.03em;line-height:1.3;text-transform:none}
.w4-bigtype .g .d{display:block;font-family:var(--serif);font-size:14px;color:var(--sub);line-height:1.5;text-transform:none;letter-spacing:0}
.w4-bigtype .g .m{text-transform:none;letter-spacing:0}
.w4-bigtype .g .ph{display:block;aspect-ratio:1;overflow:hidden}
.w4-bigtype .g--flat,.w4-bigtype .g--row{grid-template-columns:36px 1fr}
.w4-bigtype .g .w{display:block;font-size:15px;color:var(--sub)}
.w4-bigtype .ppl{display:grid;grid-template-columns:repeat(3,1fr);border-bottom:1px solid var(--line)}
.w4-bigtype .ppl>*{padding:10px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:8px}
.w4-bigtype .ppl>*:nth-child(3n){border-right:0}
.w4-bigtype .ppl .t{font-size:14px;font-weight:500}
.w4-bigtype .noimg{display:block;aspect-ratio:1;background:repeating-linear-gradient(45deg,rgba(20,20,18,.08) 0 1px,transparent 1px 8px)}
.w4-bigtype .clamp{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.w4-bigtype .wx{display:grid;grid-template-columns:1fr 1fr;border-bottom:1px solid var(--line)}
.w4-bigtype .wx>div{padding:16px var(--g);border-right:1px solid var(--line);min-width:0}
.w4-bigtype .wx>div:nth-child(2n){border-right:0}
.w4-bigtype .wx .deg{grid-column:span 2;border-right:0;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:flex-end;font-family:'Cormorant Garamond',serif;font-size:72px;line-height:.85;letter-spacing:-.04em}
.w4-bigtype .wx .deg span{font-family:var(--mono);text-transform:uppercase;font-size:12px;line-height:1.4;text-align:right;letter-spacing:.06em}
.w4-bigtype .wx .note{grid-column:span 2;border-right:0;border-bottom:1px solid var(--line);font-size:19px;font-weight:500;line-height:1.7}
.w4-bigtype .wx b{font-weight:500}
.w4-bigtype .faq details{border-bottom:1px solid var(--line)}
.w4-bigtype .faq summary{display:grid;grid-template-columns:36px 1fr 20px;gap:4px;padding:16px var(--g);font-size:18px;font-weight:500}
.w4-bigtype .faq summary:after{content:'+';font-family:var(--mono)}
.w4-bigtype .faq details p{padding:0 var(--g) 18px 52px;color:var(--sub);font-size:16px}
.w4-bigtype .post,.w4-bigtype .films{display:grid}
.w4-bigtype .post{padding:14px var(--g);border-bottom:1px solid var(--line);gap:6px}
.w4-bigtype .post .pb{font-size:16px;line-height:1.7}
.w4-bigtype .films>a{display:block;border-bottom:1px solid var(--line)}
.w4-bigtype .films>a>.m{display:block;padding:10px var(--g)}
.w4-bigtype .film{display:block;position:relative;aspect-ratio:16/9;overflow:hidden;border-bottom:1px solid var(--line)}
.w4-bigtype .film img{object-fit:cover}
.w4-bigtype .stops li{padding:10px var(--g);border-bottom:1px solid var(--line)}
.w4-bigtype #postcard-maker,.w4-bigtype #reviews{border-color:var(--line);background:transparent}
.w4-bigtype #postcard-maker .rounded,.w4-bigtype #reviews .rounded-lg{border-radius:0}
.w4-bigtype footer{padding:28px var(--g) 40px;display:grid;gap:4px}
.w4-bigtype footer .byline a{text-decoration:underline}
.w4-bigtype footer .big{margin-top:20px;font-weight:900;font-size:min(calc(var(--wm,calc(41 * var(--u)))*.6),36px);letter-spacing:-.07em;line-height:.8}
.w4-bigtype .player{position:fixed;left:0;right:0;bottom:56px;z-index:30;height:36px;display:flex;align-items:center;background:var(--paper);border-top:1px solid var(--line);padding:0 8px 0 var(--g)}
.w4-bigtype .player.low{bottom:0}
.w4-bigtype #w4d-mini{display:flex;align-items:center;gap:2px;width:100%}
.w4-bigtype #w4d-tape{width:20px;height:20px;margin-right:8px}
.w4-bigtype #w4d-tape svg{width:100%;height:100%}
.w4-bigtype #w4d-now{flex:1;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-family:var(--mono);font-size:11px;letter-spacing:.06em;text-transform:uppercase}
.w4-bigtype #w4d-mini button{width:34px;height:34px;padding:8px}
.w4-bigtype #w4d-mini button svg{width:100%;height:100%}
.w4-bigtype #w4d-panel{background:var(--paper);border:1px solid var(--line);border-radius:0;color:var(--ink)}
.w4-bigtype .tabbar{position:fixed;left:0;right:0;bottom:0;z-index:30;display:grid;height:56px;background:var(--paper);border-top:1px solid var(--line)}
.w4-bigtype .tabbar>a,.w4-bigtype .tabbar>button{display:flex;align-items:center;justify-content:center;border-right:1px solid var(--line);font-family:var(--mono);font-size:12px;letter-spacing:.06em;text-align:center}
.w4-bigtype .tabbar .cta{background:var(--ink);color:var(--paper);border-right:0;font-family:var(--serif);font-weight:900;font-size:18px;letter-spacing:-.03em}
.w4-bigtype .rooms2{display:grid;grid-template-columns:1fr 1fr;border-bottom:1px solid var(--line)}
.w4-bigtype .rooms2>a{display:flex;flex-direction:column;min-width:0;border-bottom:1px solid var(--line)}
.w4-bigtype .rooms2>a:nth-child(odd){border-right:1px solid var(--line)}
.w4-bigtype .rooms2>a:nth-last-child(-n+2):nth-child(odd),.w4-bigtype .rooms2>a:last-child{border-bottom:0}
.w4-bigtype .rooms2 .ph{aspect-ratio:3/2;overflow:hidden;border-bottom:1px solid var(--line)}
.w4-bigtype .rooms2 .cap{display:grid;grid-template-columns:22px 1fr;gap:2px 6px;padding:10px 12px 12px;align-items:baseline}
.w4-bigtype .rooms2 h3{font-size:18px;font-weight:900;letter-spacing:-.03em;line-height:1.25;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}
.w4-bigtype .rooms2 .cap p{grid-column:2;font-size:13px;line-height:1.5;color:var(--sub);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.w4-bigtype .gal--lead{grid-template-columns:1fr 1fr}
.w4-bigtype .gal--lead>*{aspect-ratio:4/3}
.w4-bigtype .gal--lead>*:nth-child(3n){border-right:1px solid var(--line)}
.w4-bigtype .gal--lead>*:first-child{grid-column:span 2;aspect-ratio:16/10;border-right:0}
.w4-bigtype .gal--lead>*:nth-child(odd){border-right:0}
.w4-bigtype .gal--lead>*:nth-last-child(-n+2):not(:first-child){border-bottom:0}
.w4-bigtype .guide--sm .g{grid-template-columns:36px 1fr 64px}
.w4-bigtype .g .d,.w4-bigtype .g .t{display:-webkit-box;-webkit-box-orient:vertical;overflow:hidden;-webkit-line-clamp:2}
.w4-bigtype .guide--sm .g .d{-webkit-line-clamp:1}
.w4-bigtype .g .w.clamp3{display:-webkit-box;-webkit-box-orient:vertical;overflow:hidden;-webkit-line-clamp:3;margin-top:4px}
.w4-bigtype .rail .car--multi .track{gap:0;padding:0}
.w4-bigtype .rail .car--200 .slide{border-right:1px solid var(--line)}
.w4-bigtype .rail .car--200 .slide:last-child{border-right:0}
.w4-bigtype .rail .fc{display:flex;flex-direction:column;width:100%;height:100%}
.w4-bigtype .rail .fc .ph,.w4-bigtype .rail .fc .noimg{display:block;aspect-ratio:4/3;overflow:hidden;border-bottom:1px solid var(--line)}
.w4-bigtype .rail .fc .noimg{padding:10px;font-size:14px}
.w4-bigtype .rail .fc .t{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;padding:10px 12px 0;font-size:17px;font-weight:500;line-height:1.35;min-height:calc(17px*1.35*2);box-sizing:content-box}
.w4-bigtype .rail .fc .d{display:block;padding:4px 12px 12px;font-size:13px;color:var(--sub);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.w4-bigtype .ppl>.txt{justify-content:flex-start;gap:4px}
.w4-bigtype .ppl .m{text-transform:none;letter-spacing:0;color:var(--sub)}
.w4-bigtype .rail .fc .ph img{aspect-ratio:auto;height:100%}
.w4-bigtype .ppl img{aspect-ratio:1;height:auto}
.w4-bigtype .hx{display:none}
@media(min-width:1024px){.w4-bigtype{--g:32px;--u:6px;max-width:none;width:100%;padding-bottom:0;font-size:16px}
.w4-bigtype .tabbar{display:none}
.w4-bigtype .player,.w4-bigtype .player.low{left:auto;right:24px;bottom:24px;width:300px;border:1px solid var(--line);padding:0 4px 0 12px}
.w4-bigtype .word .wm{font-size:min(var(--wm),64px)}
.w4-bigtype .tabs{overflow:visible}
.w4-bigtype .tabs>a{flex:0 0 auto;padding:13px 22px}
.w4-bigtype .tabs>a:last-of-type{border-right:1px solid var(--line)}
.w4-bigtype .tabs .hx{display:flex;order:9;margin-left:auto;border-left:1px solid var(--line)}
.w4-bigtype .tabs .hx>*{display:flex;align-items:center;padding:0 20px;border-right:1px solid var(--line);white-space:nowrap}
.w4-bigtype .tabs .hx>:last-child{border-right:0}
.w4-bigtype .tabs .hx .cta{background:var(--ink);color:var(--paper);font-family:var(--serif);font-weight:900;font-size:16px;letter-spacing:-.02em;text-transform:none;padding:0 28px}
.w4-bigtype .pagehead{display:grid;grid-template-columns:1fr 1fr;column-gap:48px;row-gap:12px;align-items:end;padding:22px var(--g)}
.w4-bigtype .pagehead .wm{font-size:40px;line-height:1}
.w4-bigtype .pagehead .lead{padding:0;font-size:17px}
.w4-bigtype .pagehead .wm{grid-row:1/3;align-self:center}
.w4-bigtype .pagehead .pills{grid-column:2;grid-row:2;margin-top:0}
.w4-bigtype[data-page=story] main{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr)}
.w4-bigtype[data-page=story] main>*{grid-column:1/-1}
.w4-bigtype[data-page=story] main>[data-pg=story]:has(>#blog){grid-column:1;grid-row:1}
.w4-bigtype[data-page=story] main>#reviews{grid-column:2;grid-row:1;border-left:1px solid var(--line)}
.w4-bigtype #reviews ul{grid-template-columns:repeat(2,minmax(0,1fr))}
.w4-bigtype .hero{grid-template-columns:minmax(0,1.3fr) minmax(0,1fr);height:460px}
.w4-bigtype .hero .ph{aspect-ratio:auto;height:100%;overflow:hidden}
.w4-bigtype .hero .wide{display:none}
.w4-bigtype .hero .txt{padding:24px var(--g) 0;gap:20px}
.w4-bigtype .hero--text{height:auto}
.w4-bigtype .hero--text .txt{padding:32px var(--g) 0}
.w4-bigtype .rot{font-size:22px;line-height:1.4}
.w4-bigtype .hero .hx{display:block;margin:auto calc(-1 * var(--g)) 0;border-top:1px solid var(--line)}
.w4-bigtype .hero .hx>:last-child{border-bottom:0}
.w4-bigtype .idx{padding-top:24px}
.w4-bigtype .line{padding-top:12px;padding-bottom:12px}
.w4-bigtype .g .w.clamp3{-webkit-line-clamp:2}
.w4-bigtype #postcard-maker canvas{max-width:300px}
.w4-bigtype .lead{padding-top:8px;font-size:16px;line-height:1.6}
.w4-bigtype .idx h2{font-size:28px}
.w4-bigtype .lead,.w4-bigtype #about p:not([class]),.w4-bigtype #about .body p{font-size:17px}
.w4-bigtype .wx{grid-template-columns:220px minmax(0,2fr) minmax(0,1fr) minmax(0,1.4fr)}
.w4-bigtype .wx>div,.w4-bigtype .wx>div:nth-child(2n){grid-column:auto;border-right:1px solid var(--line);border-bottom:0}
.w4-bigtype .wx>div.deg,.w4-bigtype .wx>div.note,.w4-bigtype .wx>div.span2{grid-column:auto;border-right:1px solid var(--line);border-bottom:0}
.w4-bigtype .wx>div.span2{grid-column:span 2}
.w4-bigtype .wx>div:last-child{border-right:0}
.w4-bigtype[data-page=home] [data-pg=home]:has(>.fig):not(:has(>#about)){display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);border-bottom:1px solid var(--line)}
.w4-bigtype[data-page=home] [data-pg=home]:has(>.fig):not(:has(>#about))>.fig{aspect-ratio:auto;min-height:220px;contain:size;border-left:1px solid var(--line);border-bottom:0}
.w4-bigtype[data-page=home] [data-pg=home]:has(>.fig):not(:has(>#about))>.fig img{height:100%;object-fit:cover}
.w4-bigtype .wx .deg{font-size:56px}
.w4-bigtype .wx .note{font-size:17px}
.w4-bigtype[data-page=home] [data-pg=home]:has(>#about){display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);border-bottom:1px solid var(--line)}
.w4-bigtype[data-page=home] [data-pg=home]:has(>#about)>:not(#about,.fig){grid-column:1/-1}
.w4-bigtype[data-page=home] [data-pg=home]:has(>#about)>#about{padding-bottom:24px}
.w4-bigtype[data-page=home] [data-pg=home]:has(>#about)>.fig{aspect-ratio:auto;min-height:360px;border-left:1px solid var(--line);border-bottom:0}
.w4-bigtype .rooms2,.w4-bigtype .gal,.w4-bigtype .guide,.w4-bigtype .ppl,.w4-bigtype .faq,.w4-bigtype .amen{display:grid;width:calc(100% + 1px);clip-path:inset(0 1px 0 0);border-bottom:0;border-top:0}
.w4-bigtype :is(.rooms2,.gal,.guide,.ppl,.faq,.amen)>*,.w4-bigtype :is(.rooms2,.gal,.ppl,.amen)>:is(:nth-child(n),:nth-child(odd),:nth-child(3n),:last-child){border-right:1px solid var(--line);border-bottom:1px solid var(--line)}
.w4-bigtype .rooms2{grid-template-columns:repeat(4,minmax(0,1fr))}
.w4-bigtype .guide,.w4-bigtype .faq{grid-template-columns:repeat(2,minmax(0,1fr))}
.w4-bigtype .guide--sm{grid-template-columns:repeat(3,minmax(0,1fr))}
.w4-bigtype #walk .guide{grid-template-columns:repeat(4,minmax(0,1fr))}
.w4-bigtype :is(#songs,#chronicle,#reading) .guide{grid-template-columns:repeat(3,minmax(0,1fr))}
.w4-bigtype #chronicle .guide{grid-template-columns:repeat(5,minmax(0,1fr))}
.w4-bigtype #chronicle .g{grid-template-columns:1fr;gap:4px}
.w4-bigtype .g{padding-top:10px;padding-bottom:10px}
.w4-bigtype .g .t{font-size:18px}
.w4-bigtype .rail .fc .ph,.w4-bigtype .rail .fc .noimg{aspect-ratio:3/2}
.w4-bigtype #channels{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:0}
.w4-bigtype #channels>.idx{grid-column:1/-1}
.w4-bigtype #channels>a:nth-of-type(odd){border-right:1px solid var(--line)}
.w4-bigtype #walk .g{grid-template-columns:28px minmax(0,1fr);align-items:start;gap:8px 6px;padding:0 var(--g) 12px 12px;height:100%}
.w4-bigtype #walk .g .ph{grid-column:1/-1;grid-row:1;aspect-ratio:3/2;margin:0 calc(-1 * var(--g)) 2px -12px;border-bottom:1px solid var(--line)}
.w4-bigtype #walk .g--flat{padding-top:16px}
.w4-bigtype #walk .g .t{font-size:18px}
.w4-bigtype #walk .g>span:nth-child(2){display:flex;align-items:baseline;justify-content:space-between;gap:8px;min-width:0}
.w4-bigtype #walk .g>span:nth-child(2) .t{-webkit-line-clamp:1}
.w4-bigtype #walk .g>span:nth-child(2) .d{flex:none;max-width:45%;-webkit-line-clamp:1}
.w4-bigtype .idx{padding-bottom:10px}
.w4-bigtype .ppl{grid-template-columns:repeat(4,minmax(0,1fr))}
.w4-bigtype .ppl img{aspect-ratio:3/2;object-position:center 25%}
.w4-bigtype .faq details{border-bottom:0}
.w4-bigtype .amen{grid-template-columns:repeat(4,minmax(0,1fr));border-top:1px solid var(--line);clip-path:inset(0 1px 0 0)}
.w4-bigtype #info .spec{display:grid;grid-template-columns:1fr 1fr;column-gap:40px}
.w4-bigtype #info .spec .long{grid-column:1/-1}
.w4-bigtype .gal{grid-template-columns:repeat(6,minmax(0,1fr))}
.w4-bigtype .gal>*{aspect-ratio:3/2}
.w4-bigtype .gal--lead{grid-template-columns:minmax(0,2fr) minmax(0,1fr) minmax(0,1fr)}
.w4-bigtype .gal--lead>*,.w4-bigtype .gal--lead>*:first-child{grid-column:auto;aspect-ratio:3/2}
.w4-bigtype .gal--lead>*:first-child{grid-row:span 2;aspect-ratio:auto}
.w4-bigtype .gal--lead{grid-auto-rows:124px}
.w4-bigtype .gal--lead>*,.w4-bigtype .gal--lead>*:first-child{aspect-ratio:auto}
.w4-bigtype #postcard-maker,.w4-bigtype #reviews{--section-space:0 28px}
.w4-bigtype .gal--lead>:nth-child(n+6){display:none}
.w4-bigtype .films{grid-template-columns:repeat(4,minmax(0,1fr))}
.w4-bigtype .film{display:block}
.w4-bigtype .film img{aspect-ratio:16/9;height:auto;object-fit:cover}
.w4-bigtype .films>a{border-right:1px solid var(--line)}
.w4-bigtype .book{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);column-gap:48px;margin-top:28px;padding:28px var(--g)}
.w4-bigtype .book>*{grid-column:1}
.w4-bigtype .book h2{font-size:32px;line-height:1.05}
.w4-bigtype .book .acts{grid-column:2;grid-row:1/4;margin-top:0;align-self:center}
.w4-bigtype [data-pg=stay]>section[id^=unit-]{display:grid;grid-template-columns:minmax(0,5fr) minmax(0,7fr);grid-template-rows:auto auto auto auto 1fr}
.w4-bigtype [data-pg=stay]>section[id^=unit-]>*{grid-column:2}
.w4-bigtype [data-pg=stay]>section[id^=unit-]>.idx{grid-column:1/-1}
.w4-bigtype [data-pg=stay]>section[id^=unit-]>.car{grid-column:1;grid-row:2/6;border-right:1px solid var(--line)}
.w4-bigtype [data-pg=stay]>section[id^=unit-] .car .slide img{aspect-ratio:3/2}
.w4-bigtype [data-pg=stay]>section[id^=unit-]>.body{padding-bottom:20px}
.w4-bigtype [data-pg=stay]>section[id^=unit-]>.body p:not([class]){padding:16px var(--g) 0;font-size:16px}
.w4-bigtype .loc{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-template-rows:repeat(6,auto) 1fr}
.w4-bigtype .loc>*{grid-column:2}
.w4-bigtype .loc>.idx{grid-column:1/-1}
.w4-bigtype .loc .map{grid-column:1;grid-row:2/8;aspect-ratio:auto;height:100%;min-height:320px;border-right:1px solid var(--line)}}
@media(min-width:1024px){.w4-bigtype .hero{grid-template-columns:minmax(0,1.4fr) minmax(0,1fr);height:520px;border-top:1px solid var(--line)}
.w4-bigtype .hero .txt{padding:28px var(--g) 0}
.w4-bigtype .hero .hx{margin:auto calc(-1 * var(--g)) 0}
.w4-bigtype .hero .hx .line{padding-left:var(--g);padding-right:var(--g)}}
@media(min-width:1024px){.w4-bigtype .hero .rot{font-size:30px;line-height:1.45;margin-top:auto;margin-bottom:28px;max-width:520px}
.w4-bigtype .rooms2{grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}
.w4-bigtype .films{grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}
.w4-bigtype .films:has(>a:only-child)>a{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);border-right:0}
.w4-bigtype .films:has(>a:only-child)>a{grid-template-columns:minmax(0,5fr) minmax(0,7fr)}
.w4-bigtype .films:has(>a:only-child) .film{aspect-ratio:auto;height:300px;border-bottom:0;border-right:1px solid var(--line)}
.w4-bigtype .films:has(>a:only-child) .film img{height:100%}
.w4-bigtype .films:has(>a:only-child)>a>.m{display:flex;align-items:flex-end;padding:28px var(--g);font-size:16px}
.w4-bigtype .ppl{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.w4-bigtype .amen{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}}
.w4-bigtype .lead+:is(.chips,.tabrow){margin-top:20px}

View File

@ -0,0 +1,45 @@
import type {Layout} from '@site/lib/layout';
import {StoryTail} from '../paper/StoryTail';
import {Around} from './Around';
import {Booking} from './Booking';
import {Faq} from './Faq';
import {Festival} from './Festival';
import {Frame} from './Frame';
import {Gallery} from './Gallery';
import {Hero} from './Hero';
import {Info} from './Info';
import {Intro} from './Intro';
import {Itinerary} from './Itinerary';
import {Location} from './Location';
import {Rooms} from './Rooms';
import {SectionHead} from './SectionHead';
import {Story} from './Story';
import {Video} from './Video';
const Nothing = () => null;
export const layout: Layout = {
Frame,
SectionHead,
sections: {
hero: Hero,
weather: Nothing,
intro: Intro,
blog: Nothing,
rooms: Rooms,
menu: Rooms,
programs: Rooms,
photos: Gallery,
video: Video,
local: Around,
booking: Booking,
info: Info,
map: Location,
faq: Faq,
festival: Festival,
itinerary: Itinerary,
story: Story,
reviews: StoryTail,
postcardMaker: Nothing,
},
};

View File

@ -0,0 +1,13 @@
import {useEffect, useState} from 'react';
export function useWide(): boolean {
const [wide, setWide] = useState(false);
useEffect(() => {
const query = window.matchMedia('(min-width: 1024px)');
const sync = () => setWide(query.matches);
sync();
query.addEventListener('change', sync);
return () => query.removeEventListener('change', sync);
}, []);
return wide;
}

View File

@ -0,0 +1,16 @@
const WIDE = /[ᄀ-ᇿ㄰-㆏가-힣぀-ヿ一-鿿]/;
function units(text: string): number {
return [...text].reduce((sum, ch) => {
if (WIDE.test(ch)) return sum + 1;
if (/\s/.test(ch)) return sum + 0.3;
if (/[A-Z0-9MW]/.test(ch)) return sum + 0.72;
return sum + 0.52;
}, 0);
}
export function wordmark(name: string): {lines: string[]; units: number} {
const words = name.trim().split(/\s+/).filter(Boolean);
const lines = words.length > 1 && units(name) > 5 ? words : [words.join(' ')];
return {lines, units: Math.max(1, ...lines.map(units))};
}

View File

@ -0,0 +1,145 @@
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';
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 FIRST_SPOTS = 6;
const FIRST_EATS = 5;
const HOME_ROWS = 3;
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, value, onChange}: {places: LocalPlace[]; value: string; onChange: (id: string) => void}) {
const tabs = RANGES.filter((r, i) => {
const n = places.filter((p) => r.test(meters(p))).length;
return i === 0 || (n > 0 && n < places.length);
});
if (tabs.length < 2) return null;
return (
<div className="chips" role="tablist">
{tabs.map((r) => (
<button key={r.id} type="button" role="tab" aria-current={r.id === value} onClick={() => onChange(r.id)}>
{r.label} {places.filter((p) => r.test(meters(p))).length}
</button>
))}
</div>
);
}
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<LocalPlace | null>(null);
const [spotRange, setSpotRange] = useState('all');
const [eatRange, setEatRange] = useState('all');
const firstSpots = FIRST_SPOTS;
if (rail.length === 0 && eats.length === 0) return null;
const shownSpots = spots.filter((p) => inRange(spotRange)(meters(p)));
const shownEats = eats.filter((p) => inRange(eatRange)(meters(p)));
const place = (item: LocalPlace, withText = true) => (
<button key={item.name} type="button" className={item.imageUrl ? 'place' : 'place noph'} onClick={() => setOpen(item)}>
{item.imageUrl && (
<span className="ph">
<img src={item.imageUrl} alt={item.name} loading="lazy" />
</span>
)}
<span className="tx">
{item.distanceText && <small>{item.distanceText}</small>}
<b className="nm">{item.name}</b>
{withText && item.description && <span className="ds">{item.description}</span>}
</span>
</button>
);
return (
<>
{rail.length > 0 && (
<div data-pg="home">
<section className="wrap" id="around" aria-labelledby="around-heading">
<p className="label">AROUND</p>
<h2 id="around-heading">주변 안내</h2>
<p className="lead">걸어서 닿는 명소와 맛집입니다.</p>
<div className="guide">{rail.slice(0, HOME_ROWS).map((item) => place(item))}</div>
<a className="link b-more--c" href="#/area">
지역 소개에서 모두 보기
</a>
</section>
</div>
)}
<div data-pg="area" id="guide">
{spots.length > 0 && (
<section className="wrap" id="walk">
<p className="label">AROUND</p>
<h2>
주변 명소 <span className="cnt">{spots.length}곳</span>
</h2>
<p className="lead">걸어서 닿는 곳부터 차로 가는 곳까지 묶었습니다.</p>
<RangeTabs places={spots} value={spotRange} onChange={setSpotRange} />
<div className="guide">{shownSpots.slice(0, firstSpots).map((item) => place(item))}</div>
{shownSpots.length > firstSpots && (
<details className="fold" key={spotRange}>
<summary className="link">{shownSpots.length - firstSpots}곳 더 보기</summary>
<div className="guide">{shownSpots.slice(firstSpots).map((item) => place(item))}</div>
</details>
)}
</section>
)}
{eats.length > 0 && (
<section className="wrap" id="eat">
<p className="label">EAT</p>
<h2>
주변 맛집 <span className="cnt">{eats.length}곳</span>
</h2>
<RangeTabs places={eats} value={eatRange} onChange={setEatRange} />
<div className="guide">{shownEats.slice(0, FIRST_EATS).map((item) => place(item, false))}</div>
{shownEats.length > FIRST_EATS && (
<details className="fold" key={eatRange}>
<summary className="link">{shownEats.length - FIRST_EATS}곳 더 보기</summary>
<div className="guide">{shownEats.slice(FIRST_EATS).map((item) => place(item, false))}</div>
</details>
)}
</section>
)}
</div>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
{open && (
<>
{open.imageUrl && <img src={open.imageUrl} alt={open.name} />}
<h3>{open.name}</h3>
<p className="meta">{[open.distanceText, open.category].filter(Boolean).join(' · ')}</p>
{open.description && <p>{open.description}</p>}
<a
className="out"
href={naverMapSearchUrl(open.searchQuery ?? open.name)}
target="_blank"
rel="noopener noreferrer"
>
네이버에서 보기
</a>
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,69 @@
import {PlaceCategory} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, galleryImages, stayBookingView} from '@site/lib/derive';
import {BOOK_EVENT} from '../paper/Rooms';
const book = () => window.dispatchEvent(new Event(BOOK_EVENT));
export function Booking() {
const payload = useSite();
const links = bookingLinks(payload);
const stay = payload.place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
const phone = payload.place.phone;
const images = galleryImages(payload);
const backdrop = images[images.length > 2 ? 2 : 0];
return (
<>
<div data-pg="home">
<section className="book" aria-labelledby="booking-home-heading">
{backdrop && <img src={backdrop.url} alt={backdrop.alt} loading="lazy" />}
<div className="bd">
<p className="label">RESERVATION</p>
<h2 id="booking-home-heading">이용안내 및 예약</h2>
<p>요금과 예약 방법을 안내합니다.</p>
{stay ? (
<button type="button" className="btn" onClick={book}>
예약하기
</button>
) : (
links.slice(0, 1).map((link) => (
<a key={link.url} className="btn" href={link.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(link)}
</a>
))
)}
<a className="link" href="#/stay">
이용안내 보기
</a>
</div>
</section>
</div>
<div data-pg="stay">
<section className="wrap" id="booking" aria-labelledby="booking-heading">
<p className="label">RESERVATION</p>
<h2 id="booking-heading">이용안내 및 예약</h2>
<p className="lead">요금과 예약 방법을 안내합니다.</p>
<div className="btns">
{links.map((link) => (
<a key={link.url} className="btn" href={link.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(link)}
</a>
))}
{phone && (
<a className="btn ghost" href={`tel:${phone}`}>
{phone}
</a>
)}
{stay && (
<button type="button" className="btn ghost" onClick={book}>
예약하기
</button>
)}
</div>
</section>
</div>
</>
);
}

View File

@ -0,0 +1,35 @@
import {SourceType} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {faqList} from '@site/lib/derive';
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]) => (
<details key={faq.faqId}>
<summary>{faq.question}</summary>
<p>{faq.answer}</p>
</details>
);
return (
<section className="wrap" id="faq" aria-labelledby="faq-heading">
<p className="label">FAQ</p>
<h2 id="faq-heading">
자주 묻는 질문 <span className="cnt">{faqs.length}</span>
</h2>
{verified && <p className="lead">아래 답변은 모두 사업자가 확인한 내용입니다.</p>}
<div className="faq">{faqs.slice(0, FIRST).map(item)}</div>
{faqs.length > FIRST && (
<details className="fold">
<summary className="link">{faqs.length - FIRST}개 더 보기</summary>
<div className="faq">{faqs.slice(FIRST).map(item)}</div>
</details>
)}
</section>
);
}

View File

@ -0,0 +1,75 @@
import {useState} from 'react';
import type {FestivalEntry} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverSearchUrl} from '@site/lib/format';
import {Sheet} from '../paper/Sheet';
const SEASONS = ['봄', '여름', '가을', '겨울'];
export function Festival() {
const payload = useSite();
const festivals = payload.local.festivals;
const [season, setSeason] = useState('');
const [open, setOpen] = useState<FestivalEntry | null>(null);
if (festivals.length === 0) return null;
const tabs = SEASONS.filter((s) => festivals.some((f) => f.season === s));
const shown = festivals.filter((f) => !season || f.season === season);
return (
<>
<section className="wrap" id="festival" aria-labelledby="festival-heading">
<p className="label">SEASON</p>
<h2 id="festival-heading">
계절별 축제 <span className="cnt">{festivals.length}</span>
</h2>
<p className="lead">{payload.place.addressLocality ?? '이 지역'}의 축제와 행사를 계절로 묶었습니다.</p>
{tabs.length > 1 && (
<div className="chips" role="tablist">
{['', ...tabs].map((s) => (
<button
key={s || 'all'}
type="button"
role="tab"
aria-current={s === season}
onClick={() => setSeason(s)}
>
{s || '전체'} {festivals.filter((f) => !s || f.season === s).length}
</button>
))}
</div>
)}
<div className="rail">
{shown.map((f) => (
<button key={f.name} type="button" className="card" onClick={() => setOpen(f)}>
{f.imageUrl ? (
<img src={f.imageUrl} alt={f.name} loading="lazy" />
) : (
<span className="noimg">{f.name}</span>
)}
<b className="nm">{f.name}</b>
<span className="ds">{[f.month, f.location].filter(Boolean).join(' · ')}</span>
</button>
))}
</div>
</section>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
{open && (
<>
{open.imageUrl && <img src={open.imageUrl} alt={open.name} />}
<h3>{open.name}</h3>
<p className="meta">{[open.period ?? open.month, open.location].filter(Boolean).join(' · ')}</p>
{open.description && <p>{open.description}</p>}
<a
className="out"
href={open.officialUrl ?? naverSearchUrl(open.searchQuery)}
target="_blank"
rel="noopener noreferrer"
>
자세히 보기
</a>
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,385 @@
import {useEffect, useRef, useState, type ReactNode, type RefObject} 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 {useScrollLock} from '@site/lib/ui/use-scroll-lock';
import {SongPlayer} from '@site/sections/SongPlayer';
import {BookingForm} from '../paper/BookingForm';
import {BOOK_EVENT} from '../paper/Rooms';
import {Sheet} from '../paper/Sheet';
import {ChatIcon, HOUSE_WORD, pad2, PhoneIcon, PinIcon, Wordmark} from './ui';
import '../kit/kit.css';
import './boutique.css';
type PageId = 'home' | 'area' | 'stay' | 'story';
const PAGE_HEAD: Record<Exclude<PageId, 'home'>, {en: string; title: string; note: string}> = {
area: {
en: 'Around',
title: '지역 소개',
note: '대문에서 걸어 닿는 자리와, 이 도시가 남긴 이야기입니다.',
},
stay: {
en: 'Stay',
title: '이용안내 · 예약',
note: '객실 상세와 요금, 예약하는 방법입니다.',
},
story: {
en: 'Story',
title: '이야기',
note: '사장님이 남기는 글과 다녀가신 분들의 후기, 그리고 엽서 한 장입니다.',
},
};
const PAGE_EN: Record<PageId, string> = {home: 'The House', area: 'Around', stay: 'Stay', story: 'Story'};
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<PageId>('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;
}
function useSolid(page: PageId): boolean {
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const sync = () => setScrolled(window.scrollY > window.innerHeight * 0.8);
sync();
window.addEventListener('scroll', sync, {passive: true});
return () => window.removeEventListener('scroll', sync);
}, []);
return page !== 'home' || scrolled;
}
function useReveal(root: RefObject<HTMLDivElement | null>, page: PageId) {
useEffect(() => {
const el = root.current;
if (!el || !('IntersectionObserver' in window)) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const seen = new WeakSet<Element>();
const io = new IntersectionObserver(
(entries) =>
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add('show');
io.unobserve(entry.target);
}),
{threshold: 0.12},
);
const scan = () => {
el.querySelectorAll('.rv:not(.show)').forEach((node) => {
if (seen.has(node)) return;
seen.add(node);
const box = node.getBoundingClientRect();
if (box.height > 0 && box.top < window.innerHeight && box.bottom > 0) node.classList.add('show');
else io.observe(node);
});
};
scan();
el.classList.add('rv-on');
const mo = new MutationObserver(scan);
mo.observe(el, {childList: true, subtree: true});
return () => {
mo.disconnect();
io.disconnect();
};
}, [root, page]);
}
function useTabs() {
const payload = useSite();
const tabs: {label: string; page: PageId}[] = [
{label: `${HOUSE_WORD[payload.place.category] ?? '가게'} 소개`, page: 'home'},
];
if (isSectionEnabled(payload, 'local')) tabs.push({label: '지역 소개', page: 'area'});
tabs.push({label: '이용안내 · 예약', page: 'stay'});
tabs.push({label: '이야기', page: 'story'});
return tabs;
}
export function Frame({children}: {children: ReactNode}) {
const payload = useSite();
const {place, site} = payload;
const tabs = useTabs();
const page = usePage();
const solid = useSolid(page);
const rootRef = useRef<HTMLDivElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [bookingOpen, setBookingOpen] = useState(false);
useReveal(rootRef, page);
useScrollLock(menuOpen);
useEffect(() => {
const open = () => setBookingOpen(true);
window.addEventListener(BOOK_EVENT, open);
return () => window.removeEventListener(BOOK_EVENT, open);
}, []);
useEffect(() => setMenuOpen(false), [page]);
useEffect(() => {
if (!menuOpen) return;
const onKey = (event: KeyboardEvent) => event.key === 'Escape' && setMenuOpen(false);
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [menuOpen]);
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 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 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')],
['날씨', '#/', isSectionEnabled(payload, 'weather')],
] as [string, string, boolean][]
).filter(([, , on]) => on);
const locality = place.addressLocality ?? place.addressRegion;
const english = place.englishName?.trim();
return (
<div className="w4-boutique" data-page={page} ref={rootRef}>
<header className={solid ? 'b-hd solid' : 'b-hd'}>
<a className={english ? 'b-logo' : 'b-logo b-logo--ko'} href="#/">
{english || place.name}
{locality && <small>{locality}</small>}
</a>
<nav className="b-hnav" aria-label="페이지">
{tabs.map((tab) => (
<a
key={tab.page}
href={tab.page === 'home' ? '#/' : `#/${tab.page}`}
className={tab.page === page ? 'on' : undefined}
aria-current={tab.page === page ? 'page' : undefined}
>
{tab.label}
</a>
))}
</nav>
<div className="b-hcta">
{place.phone && (
<a href={`tel:${place.phone}`}>
<PhoneIcon />
{place.phone}
</a>
)}
{stayBooking ? (
<button type="button" className="cta" onClick={() => setBookingOpen(true)}>
예약하기
</button>
) : (
booking && (
<a className="cta" href={booking.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(booking)}
</a>
)
)}
</div>
<div className="b-hbtn">
{place.phone && (
<a className="b-circ" href={`tel:${place.phone}`} aria-label="전화 걸기">
CALL
</a>
)}
<button
type="button"
className="b-circ"
aria-expanded={menuOpen}
aria-controls="b-menu"
onClick={() => setMenuOpen((was) => !was)}
>
{menuOpen ? 'CLOSE' : 'MENU'}
</button>
</div>
</header>
<nav className="b-menu" id="b-menu" aria-label="주요 메뉴" hidden={!menuOpen}>
{tabs.map((tab, i) => (
<a
key={tab.page}
href={tab.page === 'home' ? '#/' : `#/${tab.page}`}
className={tab.page === page ? 'on' : undefined}
aria-current={tab.page === page ? 'page' : undefined}
onClick={() => setMenuOpen(false)}
>
<span className="b-menu-no">{pad2(i + 1)}</span>
<span className="b-menu-en">{PAGE_EN[tab.page]}</span>
<span className="b-menu-ko">{tab.label}</span>
</a>
))}
</nav>
{(Object.keys(PAGE_HEAD) as Exclude<PageId, 'home'>[]).map((id) => (
<div key={id} data-pg={id}>
<section className="b-phead">
<p className="b-phead-en">{PAGE_HEAD[id].en}</p>
<p className="b-phead-ko">
{id === 'area' ? (place.addressLocality?.replace(/(시|군)$/, '') ?? PAGE_HEAD.area.title) : PAGE_HEAD[id].title}
</p>
<p className="b-phead-note">{PAGE_HEAD[id].note}</p>
{id === 'area' && areaPills.length > 0 && (
<div className="b-pills">
{areaPills.map(([label, href]) => (
<a key={href} className="b-pill" href={href}>
{label}
</a>
))}
</div>
)}
</section>
</div>
))}
{children}
{channels.length > 0 && (
<div data-pg="stay">
<section className="b-sec" id="channels" aria-labelledby="channels-heading">
<div className="b-sh">
<p className="b-sh-en" aria-hidden>
Channels
</p>
<h2 className="b-sh-ko" id="channels-heading">
공식 채널
</h2>
</div>
<div className="b-list">
{channels.map((link) => (
<a key={link.url} className="b-row" href={link.url} target="_blank" rel="noopener noreferrer">
<span>{channelLabel(link)}</span>
<span className="r">OPEN ↗</span>
</a>
))}
</div>
</section>
</div>
)}
<footer className="b-ft">
<Wordmark className="b-ft-big" name={place.name} english={english} />
<p>
{place.name}
{address && ` · ${address}`}
</p>
{hours.length > 0 && <p>{hours.join(' · ')}</p>}
{(place.phone || instagram) && (
<p>
{place.phone && <a href={`tel:${place.phone}`}>{place.phone}</a>}
{place.phone && instagram && ' · '}
{instagram && (
<a href={instagram.url} target="_blank" rel="noopener noreferrer">
instagram
</a>
)}
</p>
)}
{place.legal?.businessRegistrationNumber && (
<p>
{place.legal.representative && `대표 ${place.legal.representative} · `}
사업자등록번호 {place.legal.businessRegistrationNumber}
{place.legal.mailOrderNumber && ` · 통신판매업신고 ${place.legal.mailOrderNumber}`}
</p>
)}
<p className="byline credit">
<span className="author">작성·운영 {place.name}</span> · 최종 업데이트{' '}
<time dateTime={site.updatedAt}>{isoDate(site.updatedAt)}</time>
<br />
<a href="https://www.o2osolution.ai/" target="_blank" rel="noopener noreferrer">
AI O2O
</a>
의 Web4Ai로 만든 사이트입니다.
</p>
</footer>
<div className="b-player">
<SongPlayer />
</div>
<nav className="b-bar" aria-label="연락 · 예약">
{address && (
<a href={naverDirectionsUrl(place.name, place.latitude, place.longitude)} target="_blank" rel="noopener noreferrer nofollow">
<PinIcon />
오시는 길
</a>
)}
{kakao && (
<a href={kakao.url} target="_blank" rel="noopener noreferrer" aria-label={channelLabel(kakao)}>
<ChatIcon />
카톡
</a>
)}
{place.phone && (
<a href={`tel:${place.phone}`}>
<PhoneIcon />
전화
</a>
)}
{stayBooking ? (
<button type="button" className="cta" onClick={() => setBookingOpen(true)}>
BOOK
<small>예약하기</small>
</button>
) : (
booking && (
<a className="cta" href={booking.url} target="_blank" rel="noopener noreferrer">
BOOK
<small>{bookingActionLabel(booking)}</small>
</a>
)
)}
</nav>
{stayBooking && (
<Sheet open={bookingOpen} onClose={() => setBookingOpen(false)} label="예약 요청" plain>
<div className="b-bookbox">
<p className="b-cap">Reservation</p>
<h3>예약 요청하기</h3>
<BookingForm />
</div>
</Sheet>
)}
</div>
);
}

View File

@ -0,0 +1,46 @@
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';
const FIRST = 5;
export function Gallery() {
const payload = useSite();
const images = galleryImages(payload);
const [open, setOpen] = useState<MediaItem | null>(null);
if (images.length === 0) return null;
const card = (image: MediaItem) => (
<button key={image.mediaId} type="button" onClick={() => setOpen(image)}>
<img src={image.url} alt={image.alt} loading="lazy" />
</button>
);
return (
<div data-pg="home">
<section className="wrap" id="gallery" aria-labelledby="gallery-heading">
<p className="label">GALLERY</p>
<h2 id="gallery-heading">
{sectionName(payload, 'photos', '사진 갤러리')} <span className="cnt">{images.length}장</span>
</h2>
<div className="gal">{images.slice(0, FIRST).map(card)}</div>
{images.length > FIRST && (
<details className="fold">
<summary className="link">사진 {images.length - FIRST}장 더 보기</summary>
<div className="gal gal--rest">{images.slice(FIRST).map(card)}</div>
</details>
)}
</section>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.alt ?? ''}>
{open && (
<>
<img src={open.url} alt={open.alt} />
<h3>{open.alt}</h3>
{open.category && <p className="meta">{open.category}</p>}
</>
)}
</Sheet>
</div>
);
}

View File

@ -0,0 +1,94 @@
import {useEffect, useState} from 'react';
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
import {PlaceCategory} from '@o2o/shared';
import {BOOK_EVENT} from '../paper/Rooms';
import {pad2} from './ui';
import {Weather} from './Weather';
const MAX_SLIDES = 5;
export function Hero() {
const payload = useSite();
const {place, narrative} = payload;
const slides = [...payload.media]
.filter((image) => image.alt?.trim())
.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary))
.slice(0, MAX_SLIDES);
const [index, setIndex] = useState(0);
const total = slides.length;
useEffect(() => {
if (total < 2) return;
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
const timer = window.setInterval(() => {
if (!document.hidden && !motion.matches) setIndex((was) => (was + 1) % total);
}, 5000);
return () => window.clearInterval(timer);
}, [total]);
const fixed = narrative.tagline ?? narrative.heroSubline;
const english = place.englishName?.trim();
const locality = [place.addressLocality, place.addressRegion].find(Boolean);
const stayBooking = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
const booking = bookingLinks(payload)[0];
return (
<>
<section
className={total > 0 ? 'b-hero' : 'b-hero b-hero--plain'}
id="top"
data-pg="home"
role="region"
aria-roledescription="carousel"
aria-label={`${place.name} 사진`}
>
{slides.map((image, i) => (
<div key={image.mediaId} className={i === index ? 'b-slide on' : 'b-slide'} aria-hidden={i !== index}>
<img
src={image.url}
alt={image.alt}
fetchPriority={i === 0 ? 'high' : 'low'}
loading={i === 0 ? undefined : 'lazy'}
decoding="async"
/>
</div>
))}
<div className="b-hero-copy">
{english && <p className="b-hero-en">{english}</p>}
<h1 className={english ? 'b-hero-name' : 'b-hero-name b-hero-name--solo'}>{place.name}</h1>
{locality && <p className="b-hero-loc">{locality}</p>}
{fixed && <p className="b-hero-ko">{fixed}</p>}
<p className="b-hero-cp">
<HeroCatchphrase />
</p>
<div className="b-hero-act">
{stayBooking ? (
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
예약하기
</button>
) : (
booking && (
<a className="btn" href={booking.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(booking)}
</a>
)
)}
<a className="btn ghost" href="#/stay">
이용안내 보기
</a>
</div>
</div>
{total > 1 && (
<p className="b-hero-count" aria-live="polite">
<span>{pad2(index + 1)}</span> &nbsp;/&nbsp; {pad2(total)}
</p>
)}
</section>
{isSectionEnabled(payload, 'weather') && (
<div data-editor-id="weather" style={{display: 'contents'}}>
<Weather />
</div>
)}
</>
);
}

View File

@ -0,0 +1,82 @@
import {useSite} from '@site/lib/site-context';
import type {InfoRow} from '@site/lib/derive';
import {infoView, ON_VALUES} from '@site/sections/EssentialInfoSection';
import {CheckIcon, CrossIcon} from './icons';
function Row({row}: {row: InfoRow}) {
const long = Boolean(row.note) || row.value.length > 24;
return (
<div className={long ? 'line long' : 'line'}>
<span>{row.label}</span>
<span className="r">
{row.value}
{row.note && ` — ${row.note}`}
</span>
</div>
);
}
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 (
<section className="wrap" id="info" aria-labelledby="info-heading">
<p className="label">INFORMATION</p>
<h2 id="info-heading">이용안내</h2>
<p className="lead">방문 전 확인이 필요한 운영 규정과 시설 안내입니다.</p>
{ruleRows.length > 0 && (
<>
<p className="sub-h">예약 전 확인</p>
<div className="rows">
{[...short, ...long].map((row) => (
<Row key={row.key ?? row.label} row={row} />
))}
</div>
</>
)}
{detailRows.length + sorted.length > 0 && (
<>
<p className="sub-h">시설 · 편의</p>
<div className="rows">
{detailRows.map((row) => (
<Row key={row.key ?? row.label} row={row} />
))}
</div>
{sorted.length > 0 && (
<ul className="amen">
{sorted.map((row) => {
const on = ON_VALUES.has(row.value);
return (
<li key={row.key ?? row.label} className={on ? 'on' : 'off'}>
{on ? <CheckIcon /> : <CrossIcon />}
<span>
<b>{row.label}</b> {row.value}
</span>
</li>
);
})}
</ul>
)}
</>
)}
{notices.length > 0 && (
<div className="faq">
{notices.map((text) => (
<details key={text.slice(0, 32)}>
<summary>예약 공지</summary>
<p style={{whiteSpace: 'pre-line'}}>{text}</p>
</details>
))}
</div>
)}
</section>
);
}

View File

@ -0,0 +1,123 @@
import {useSite} from '@site/lib/site-context';
import {galleryImages, sectionBody} from '@site/lib/derive';
import {HOUSE_EN, Leaf, MoreSummary, Rule, Sh, Wordmark} from './ui';
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 (
<div className="b-post">
<p className="b-post-date">
<time dateTime={date}>{dotDate(date)}</time>
</p>
<p className="b-post-body">{body}</p>
</div>
);
}
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 (
<section className="b-sec" id={full ? 'blog' : undefined} aria-labelledby={full ? 'blog-heading' : undefined}>
<Sh en="Journal" id={full ? 'blog-heading' : undefined} lead="사장님이 남기는 숙소 이야기입니다.">
미니 블로그
</Sh>
<div className="b-wrap">
<div data-blog={full ? 'full' : 'home'}>
{shown.map((item) => (
<Post key={item.postId} date={item.publishedAt} body={item.body} />
))}
</div>
{more && (
<a href="#/story" className="b-more b-more--c">
이야기에서 지난 소식 더 보기
</a>
)}
{older.length > 0 && (
<details>
<MoreSummary>지난 소식 {older.length}개</MoreSummary>
{older.map((item) => (
<Post key={item.postId} date={item.publishedAt} body={item.body} />
))}
</details>
)}
</div>
</section>
);
}
export function Intro() {
const payload = useSite();
const {narrative, place} = payload;
const own = sectionBody(payload, 'intro');
const about = own.length > 0 ? own : narrative.about;
const images = galleryImages(payload);
const image = images.find((m) => !m.isPrimary) ?? images[0];
const english = place.englishName?.trim();
const vert = [place.addressRegion, place.addressLocality].filter(Boolean).join(' ');
const poem = narrative.summary ?? about[0];
const rest = narrative.summary ? about : about.slice(1);
return (
<>
<div data-pg="story">
<Blog full />
<Rule />
</div>
<div data-pg="home">
<section className="b-intro" id="about" aria-labelledby="about-heading">
<p className="b-over rv">{HOUSE_EN[place.category] ?? 'The House'}</p>
<Wordmark className="b-word rv" name={place.name} english={english} />
{image && (
<>
<div className="b-arch-bg" />
<div className="b-arch rv">
<img src={image.url} alt={image.alt} loading="lazy" />
</div>
</>
)}
{vert && <p className="b-vert">{vert}</p>}
<Leaf />
<h2 className="b-intro-h rv" id="about-heading">
{place.name} 소개
</h2>
{poem && (
<p className="b-poem rv">
<span className="b-q" aria-hidden>
“
</span>
{poem}
</p>
)}
{rest.length > 0 && (
<div className="b-prose">
<p>{rest[0]}</p>
{rest.length > 1 && (
<details>
<MoreSummary>더 읽기</MoreSummary>
{rest.slice(1).map((paragraph, i) => (
<p key={i}>{paragraph}</p>
))}
</details>
)}
</div>
)}
</section>
<Blog />
<Rule />
</div>
</>
);
}

View File

@ -0,0 +1,90 @@
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';
const FIRST = 4;
function stopsOf(item: ItineraryItem): PlannerStop[] {
return item.days?.[0]?.stops ?? item.stops ?? [];
}
export function Itinerary() {
const payload = useSite();
const parsed = sectionItems<ItineraryItem>(payload, 'itinerary');
const [tag, setTag] = useState('');
const [open, setOpen] = useState<ItineraryItem | null>(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) => (
<button key={item.name} type="button" className="course glass" onClick={() => setOpen(item)}>
<b className="nm">{item.name}</b>
<span className="meta">{[item.duration, item.audience].filter(Boolean).join(' · ')}</span>
{item.why && <span className="ds">{item.why}</span>}
<span className="path">
{stopsOf(item)
.slice(0, 4)
.map((stop) => [stop.name.split(' · ').pop(), stop.minutes && `${stop.minutes}분`].filter(Boolean).join(' '))
.join(' → ')}
</span>
</button>
);
return (
<>
<section className="wrap" id="itinerary" aria-labelledby="itinerary-heading">
<p className="label">ITINERARY</p>
<h2 id="itinerary-heading">
{parsed.title || sectionName(payload, 'itinerary', '추천 일정')} <span className="cnt">{items.length}</span>
</h2>
<p className="lead">{parsed.subtitle || '하루를 어떻게 쓸지 미리 짜 두었습니다.'}</p>
{tags.length > 1 && (
<div className="chips" role="tablist">
{['', ...tags].map((t) => (
<button key={t || 'all'} type="button" role="tab" aria-current={t === tag} onClick={() => setTag(t)}>
{t || '전체'} {items.filter((item) => !t || item.duration === t).length}
</button>
))}
</div>
)}
<div className="courses">{shown.slice(0, FIRST).map(card)}</div>
{shown.length > FIRST && (
<details className="fold">
<summary className="link">{shown.length - FIRST}개 더 보기</summary>
<div className="courses">{shown.slice(FIRST).map(card)}</div>
</details>
)}
</section>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
{open && (
<>
<h3>{open.name}</h3>
<p className="meta">{[open.duration, open.audience].filter(Boolean).join(' · ')}</p>
{open.why && <p>{open.why}</p>}
{(open.days?.length ? open.days : [{label: undefined, stops: open.stops}]).map((day, i) => (
<div key={i}>
{day.label && <p className="meta">{day.label}</p>}
<ul className="stops">
{(day.stops ?? []).map((stop, j) => (
<li key={j}>
<b>{stop.name}</b>
{(stop.note || stop.minutes) && (
<span>
{[stop.minutes && `${stop.minutes}분 머뭅니다`, stop.note].filter(Boolean).join(' · ')}
</span>
)}
</li>
))}
</ul>
</div>
))}
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,70 @@
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';
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 (
<section className="wrap" id="location" aria-labelledby="location-heading">
<p className="label">LOCATION</p>
<h2 id="location-heading">오시는 길</h2>
<p className="lead">주소와 주요 거점까지의 이동 시간을 안내합니다.</p>
<div className="loc">
{lat != null && lng != null && (
<iframe
className="map"
title={`${place.name} 위치 지도`}
src={osmEmbedUrl(lat, lng, 16 / 10)}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
)}
<address>{address}</address>
{lot && <p>지번 {lot}</p>}
{parking && <p>주차 {[parking.value, parking.note].filter(Boolean).join(' — ')}</p>}
</div>
{routes.length > 0 && (
<div className="rows">
{routes.map((route) => (
<div key={route.destination} className="line">
<span>{route.destination}</span>
<span className="r">
{[route.byCar && `차 ${route.byCar}`, route.byTransit, route.distanceText].filter(Boolean).join(' · ')}
</span>
</div>
))}
</div>
)}
<div className="btns">
<button type="button" className="btn ghost" onClick={copy}>
{copied ? '복사했습니다' : '주소 복사'}
</button>
<a className="btn ghost" href={naverDirectionsUrl(place.name, lat, lng)} target="_blank" rel="noopener noreferrer nofollow">
네이버 지도 길찾기
</a>
<a className="btn ghost" href={kakaoDirectionsUrl(place.name, lat, lng)} target="_blank" rel="noopener noreferrer nofollow">
카카오맵 길찾기
</a>
</div>
</section>
);
}

View File

@ -0,0 +1,138 @@
import {useState} from 'react';
import {factText, sanitizeUnits} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {sectionName, unitSpec, unitViews, type UnitView} from '@site/lib/derive';
import {Car} from '../paper/Car';
import {BOOK_EVENT} from '../paper/Rooms';
import {Sheet} from '../paper/Sheet';
function splitName(name: string): [string, string | undefined] {
const m = /^(.+?)\s*\((.+)\)\s*$/.exec(name);
return m ? [m[1], m[2]] : [name, undefined];
}
function specRows(unit: UnitView, facts: Parameters<typeof factText>[0]) {
const bed = [factText(facts, 'bed_type'), factText(facts, 'room_size')].filter(Boolean).join(' · ');
const std = factText(facts, 'standard_capacity');
const max = factText(facts, 'max_capacity');
const people = [std && `기준 ${std.replace(/명$/, '인')}`, max && `최대 ${max.replace(/명$/, '인')}`]
.filter(Boolean)
.join(' · ');
const price = unit.priceText ?? unit.prices.map((p) => `${p.label} ${p.value}`).join(' · ');
return [
['침대', bed],
['정원', people],
['요금', price],
].filter(([, value]) => value) as [string, string][];
}
const HOME_UNITS = 4;
const book = () => window.dispatchEvent(new Event(BOOK_EVENT));
export function Rooms() {
const payload = useSite();
const units = unitViews(payload);
const spec = unitSpec(payload);
const raw = new Map(sanitizeUnits(payload.units).map((unit) => [unit.unitId, unit]));
const [open, setOpen] = useState<UnitView | null>(null);
if (units.length === 0) return null;
const rows = (unit: UnitView) => specRows(unit, raw.get(unit.unitId)?.facts ?? []);
return (
<>
<div data-pg="home">
<section id="units" aria-labelledby="units-heading">
<div className="wrap">
<p className="label">ROOMS</p>
<h2 id="units-heading">{sectionName(payload, spec.path, `${spec.label} 안내`)}</h2>
</div>
<div className="wrap">
<div className="rooms2">
{units.slice(0, HOME_UNITS).map((unit, i) => {
const people = rows(unit).find(([label]) => label === '정원')?.[1];
return (
<a key={unit.unitId} className="room2" href="#/stay">
{unit.images[0] ? (
<img src={unit.images[0].url} alt={unit.images[0].alt} loading="lazy" />
) : (
<span className="room2-blank" />
)}
<span className="no">{String(i + 1).padStart(2, '0')}</span>
<b className="nm">{unit.name}</b>
{people && <span className="ds">{people}</span>}
</a>
);
})}
</div>
<a className="link b-more--c" href="#/stay">
이용안내에서 전체 보기
</a>
</div>
</section>
</div>
<div data-pg="stay">
{units.map((unit, i) => {
const [main, sub] = splitName(unit.name);
return (
<section key={unit.unitId} className="unit" id={`unit-${unit.slug}`}>
<div className="wrap">
<p className="label">ROOM {String(i + 1).padStart(2, '0')}</p>
<h2>
{main} {sub && <span className="cnt">{sub}</span>}
</h2>
</div>
{unit.images.length > 0 && (
<div className="unit-car">
<Car
label={`${main} 사진`}
arrows
count
dots
slides={unit.images.map((image) => (
<img key={image.mediaId} src={image.url} alt={image.alt} loading="lazy" />
))}
/>
</div>
)}
<div className="wrap">
{unit.intro && <p className="lead">{unit.intro}</p>}
<dl className="spec">
{rows(unit).map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
<div className="btns">
<button type="button" className="btn ghost" onClick={() => setOpen(unit)}>
{main} 자세히
</button>
<button type="button" className="btn" onClick={book}>
{main} 예약 요청
</button>
</div>
</div>
</section>
);
})}
</div>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
{open && (
<>
{open.images[0] && <img src={open.images[0].url} alt={open.images[0].alt} />}
<h3>{open.name}</h3>
{open.intro && <p>{open.intro}</p>}
{open.rows.map((row) => (
<p key={row.label}>
{row.label} — {row.value}
</p>
))}
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,35 @@
import type {SectionHeadProps} from '@site/lib/layout';
const LABEL: Record<string, string> = {
about: 'ABOUT',
info: 'INFORMATION',
units: 'ROOMS',
booking: 'RESERVATION',
gallery: 'GALLERY',
guide: 'AROUND',
weather: 'WEATHER',
location: 'LOCATION',
faq: 'FAQ',
video: 'FILM',
blog: 'JOURNAL',
festival: 'FESTIVAL',
itinerary: 'COURSE',
story: 'STORY',
rules: 'HOUSE RULES',
event: 'NEWS',
reviews: 'REVIEW',
'postcard-maker': 'POSTCARD',
};
export function SectionHead({id, title, lead, aside}: SectionHeadProps) {
return (
<div className="chead">
<div className="headrow">
{LABEL[id] && <p className="label">{LABEL[id]}</p>}
<h2 id={`${id}-heading`}>{title}</h2>
{aside}
</div>
{lead && <p className="lead">{lead}</p>}
</div>
);
}

View File

@ -0,0 +1,208 @@
import {useState, type ReactNode} from 'react';
import type {ChronicleItem, PeopleItem, ReadingItem, SongItem} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {Sheet} from '../paper/Sheet';
const COUNT_WORD: Record<number, string> = {1: '한', 2: '두', 3: '세', 4: '네'};
function More({rest, unit, children}: {rest: number; unit: string; children: ReactNode}) {
if (rest <= 0) return null;
return (
<details className="fold">
<summary className="link">
{rest}
{unit} 더 보기
</summary>
{children}
</details>
);
}
interface Open {
title: string;
meta?: string;
body?: string;
image?: string;
}
export function Story() {
const payload = useSite();
const region = payload.place.addressLocality?.replace(/시$|군$/, '') ?? '지역';
const songs = sectionItems<SongItem>(payload, 'songs');
const people = sectionItems<PeopleItem>(payload, 'people');
const chronicle = sectionItems<ChronicleItem>(payload, 'chronicle');
const reading = sectionItems<ReadingItem>(payload, 'reading');
const [open, setOpen] = useState<Open | null>(null);
const parts = [
songs.items.length > 0 && '노래',
people.items.length > 0 && '인물',
chronicle.items.length > 0 && '연표',
reading.items.length > 0 && '읽기',
].filter(Boolean) as string[];
if (parts.length === 0) return null;
const peopleSorted = [...people.items].sort((a, b) => Number(Boolean(b.imageUrl)) - Number(Boolean(a.imageUrl)));
const withPhoto = people.items.filter((p) => p.imageUrl).length;
const song = (item: SongItem) => (
<button
key={item.title + item.artist}
type="button"
className="line"
onClick={() =>
setOpen({title: item.title, meta: [item.artist, item.year].filter(Boolean).join(' · '), body: item.story})
}
>
<span>
{item.title} {item.artist && <span className="dim">{item.artist}</span>}
</span>
{item.year && <span className="r">{item.year}</span>}
</button>
);
const person = (item: PeopleItem) => (
<button
key={item.name}
type="button"
className={item.imageUrl ? 'card' : 'card card--tx'}
onClick={() =>
setOpen({
title: item.name,
meta: [item.role, item.years].filter(Boolean).join(' · '),
body: item.oneLine,
image: item.imageUrl,
})
}
>
{item.imageUrl && <img src={item.imageUrl} alt={item.name} loading="lazy" />}
<b className="nm">{item.name}</b>
{item.role && <span className="ds clamp">{item.role}</span>}
</button>
);
const group = (items: PeopleItem[]) => {
const photo = items.filter((p) => p.imageUrl);
const text = items.filter((p) => !p.imageUrl);
return (
<>
{photo.length > 0 && <div className="grid3">{photo.map(person)}</div>}
{text.length > 0 && <div className="grid2tx">{text.map(person)}</div>}
</>
);
};
const event = (item: ChronicleItem) => (
<button
key={`${item.year}-${item.title}`}
type="button"
className="line"
onClick={() =>
setOpen({
title: item.title,
meta: [item.year, item.place].filter(Boolean).join(' · '),
body: item.summary,
image: item.imageUrl,
})
}
>
<span>
{item.title}
{item.place && <span className="dim blk">{item.place}</span>}
</span>
{item.year && <span className="r">{item.year}</span>}
</button>
);
const book = (item: ReadingItem) => (
<button
key={item.title}
type="button"
className="course glass"
onClick={() => setOpen({title: item.title, meta: item.year ? String(item.year) : item.group, body: item.body})}
>
<b className="nm">
{item.title} {item.year && <span className="dim">{item.year}</span>}
</b>
<span className="ds clamp3">{item.body}</span>
</button>
);
return (
<>
<section className="wrap" id="story" aria-labelledby="story-heading">
<p className="label">STORY</p>
<h2 id="story-heading">
{sectionName(payload, 'story', `${region} 이야기`)} <span className="cnt">{parts.join(' · ')}</span>
</h2>
<p className="lead">
이 도시를 {COUNT_WORD[parts.length] ?? parts.length} 갈래로 봅니다. 하나씩 골라 보세요.
</p>
</section>
{songs.items.length > 0 && (
<section className="wrap tight" id="songs">
<p className="label">MUSIC</p>
<h2>
{songs.title || '가요 다방'} <span className="cnt">{songs.items.length}곡</span>
</h2>
<p className="lead">{region}을 노래한 곡들입니다.</p>
<div className="rows">{songs.items.slice(0, 6).map(song)}</div>
<More rest={songs.items.length - 6} unit="곡">
<div className="rows">{songs.items.slice(6).map(song)}</div>
</More>
</section>
)}
{people.items.length > 0 && (
<section className="wrap tight" id="people">
<p className="label">PEOPLE</p>
<h2>
{people.title || '인물 열전'} <span className="cnt">{people.items.length}명</span>
</h2>
<p className="lead">{region}과 얽힌 사람들입니다.</p>
{group(peopleSorted.slice(0, 6))}
<More rest={people.items.length - 6} unit="명">
{group(peopleSorted.slice(6))}
</More>
{withPhoto > 0 && withPhoto < people.items.length && (
<p className="note">사진이 있는 {withPhoto}명을 먼저 뒀습니다.</p>
)}
</section>
)}
{chronicle.items.length > 0 && (
<section className="wrap tight" id="chronicle">
<p className="label">CHRONICLE</p>
<h2>{chronicle.title || '시간의 골목'}</h2>
<p className="lead">개항부터 지금까지, 이 도시의 연표입니다.</p>
<div className="rows">{chronicle.items.slice(0, 5).map(event)}</div>
<More rest={chronicle.items.length - 5} unit="줄">
<div className="rows">{chronicle.items.slice(5).map(event)}</div>
</More>
</section>
)}
{reading.items.length > 0 && (
<section className="wrap tight" id="reading">
<p className="label">READING</p>
<h2>
{reading.title || `${region} 읽기`} <span className="cnt">{reading.items.length}편</span>
</h2>
<p className="lead">{region}을 배경으로 한 문학입니다.</p>
<div className="courses">{reading.items.slice(0, 3).map(book)}</div>
<More rest={reading.items.length - 3} unit="편">
<div className="courses">{reading.items.slice(3).map(book)}</div>
</More>
</section>
)}
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.title ?? ''}>
{open && (
<>
{open.image && <img src={open.image} alt={open.title} className="sq" />}
<h3>{open.title}</h3>
{open.meta && <p className="meta">{open.meta}</p>}
{open.body && <p>{open.body}</p>}
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,33 @@
import {youtubeId, youtubePoster, type VideoItem} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
export function Video() {
const payload = useSite();
const parsed = sectionItems<VideoItem>(payload, 'video');
const items = parsed.items.filter((item) => youtubeId(item.url));
if (items.length === 0) return null;
return (
<div data-pg="home">
<section className="wrap" id="video" aria-labelledby="video-heading">
<p className="label">FILM</p>
<h2 id="video-heading">
{parsed.title || sectionName(payload, 'video', '영상으로 보기')} <span className="cnt">{items.length}편</span>
</h2>
{parsed.subtitle && <p className="lead">{parsed.subtitle}</p>}
<div className="rail">
{items.map((item) => (
<a key={item.url} className="film" href={item.url} target="_blank" rel="noopener noreferrer">
<span className="poster">
<img src={youtubePoster(youtubeId(item.url)!)} alt="" loading="lazy" />
<span className="play" />
</span>
{item.caption && <span className="cap">{item.caption}</span>}
</a>
))}
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,70 @@
import {Cloud, CloudRain, CloudSnow} from 'lucide-react';
import type {WeatherMood, WeatherSky} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {useLiveWeather} from '@site/lib/use-live-weather';
import {useWeatherNotes} from '@site/lib/use-weather-note';
import {weatherBand, weatherMood} from '@site/lib/derive';
import {Sh} from './ui';
const MOOD_EN: Record<WeatherMood, string> = {맑음: 'Clear', 흐림: 'Cloudy', 비: 'Rain', 눈: 'Snow'};
const MOOD_ICON = {흐림: Cloud, 비: CloudRain, 눈: CloudSnow} as const;
const MONTH = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function observed(iso: string): string | undefined {
const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
if (!m) return undefined;
return `observed ${m[3]}:${m[4]}, ${MONTH[Number(m[1]) - 1] ?? ''} ${Number(m[2])}`;
}
export function Weather() {
const payload = useSite();
const weather = useLiveWeather({
initial: payload.local.weather,
regionCode: payload.place.regionCode,
latitude: payload.place.latitude,
longitude: payload.place.longitude,
});
const mood = weatherMood(weather?.condition ?? '');
const band = weatherBand(weather?.temperature ?? 0);
const notes = payload.local.weather;
const condition = (weather?.condition ?? '') as WeatherSky;
const skyKey: WeatherSky = notes?.noteSets?.[condition] ? condition : mood;
const [line, bandLine] = useWeatherNotes(
notes?.noteSets?.[skyKey],
notes?.notes?.[mood] ?? weather?.note,
notes?.tempNoteSets?.[band],
notes?.tempNotes?.[band],
);
if (!weather) return null;
const Icon = mood === '맑음' ? null : MOOD_ICON[mood];
const region = payload.place.addressLocality?.replace(/(시|군)$/, '');
const at = observed(weather.observedAt);
return (
<div className="b-weather" id="weather" data-band={band} data-mood={mood} data-pg="home">
<Sh en="Today's weather" id="weather-heading">
{region ? `지금 ${region}의 하늘` : '오늘의 날씨'}
</Sh>
<div className="b-wx rv">
<div className="b-wx-ico">{Icon && <Icon strokeWidth={1.2} aria-hidden />}</div>
<p className="b-wx-deg">
{Math.round(weather.temperature)}
<sup>°</sup>
</p>
<p className="b-wx-cond">
{MOOD_EN[mood]} · {weather.condition}
</p>
{line && (
<p key={line} className="b-wx-note">
{line}
</p>
)}
<p className="b-wx-band">
<span>{band}</span>
{bandLine}
</p>
{at && <p className="b-wx-obs">{at}</p>}
</div>
</div>
);
}

View File

@ -0,0 +1,347 @@
.w4-boutique{--u:min(1vw,4.4px);--bg:var(--tpl-bg,#f1ede9);--bg2:var(--tpl-card,#e8e1da);--ink:#5a3f2e;--brown:var(--tpl-text,#6f5847);--rose:var(--tpl-accent,#9f7666);--sub:var(--tpl-secondary,#8a7a6d);--line:rgba(111,88,71,.28);--g:20px;--sec:56px;--en:'Cormorant Garamond',serif;--cap:'Marcellus',serif;--ko:'Nanum Myeongjo',serif;--mj:'Nanum Myeongjo',serif;--k-bg:var(--bg);--k-ink:var(--brown);--k-sub:var(--sub);--k-line:var(--line);--k-soft:var(--bg2);--k-r:0px;--k-head:var(--mj);background:var(--bg);color:var(--brown);font-family:var(--ko);font-size:16px;line-height:1.9;word-break:keep-all;padding-bottom:76px;overflow-x:clip;-webkit-font-smoothing:antialiased}
.w4-boutique img{display:block;width:100%;height:100%;object-fit:cover}
.w4-boutique a{color:inherit;text-decoration:none}
.w4-boutique button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
.w4-boutique h1,.w4-boutique h2,.w4-boutique h3,.w4-boutique p,.w4-boutique figure,.w4-boutique dl,.w4-boutique dd{margin:0}
.w4-boutique.rv-on .rv{opacity:0;transform:translateY(24px);transition:opacity 1.1s ease,transform 1.1s ease}
.w4-boutique.rv-on .rv.show{opacity:1;transform:none}
.w4-boutique .b-hd{position:fixed;top:0;left:0;right:0;z-index:30;display:flex;justify-content:space-between;align-items:center;padding:14px var(--g);color:#fff;transition:background .4s,color .4s}
.w4-boutique .b-hd.solid,.w4-boutique:not([data-page=home]) .b-hd{background:rgba(241,237,233,.94);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:var(--brown)}
.w4-boutique .b-logo{font-family:var(--cap);font-size:19px;letter-spacing:.14em;line-height:1;text-align:center;text-transform:uppercase}
.w4-boutique .b-logo--ko{font-family:var(--mj);letter-spacing:.08em;text-transform:none}
.w4-boutique .b-logo small{display:block;font-family:var(--en);font-style:italic;font-size:11px;letter-spacing:.1em;margin-top:4px;text-transform:none}
.w4-boutique .b-hbtn{display:flex;gap:10px}
.w4-boutique .b-circ{width:42px;height:42px;border-radius:50%;border:1px solid currentColor;display:grid;place-items:center;font-family:var(--cap);font-size:10px;letter-spacing:.08em}
.w4-boutique .b-circ svg{width:18px;height:18px}
.w4-boutique .b-menu{position:fixed;inset:70px 12px auto;z-index:29;background:var(--bg);border:1px solid var(--line);padding:10px 18px;display:grid}
.w4-boutique .b-menu[hidden]{display:none}
.w4-boutique .b-menu a{display:grid;grid-template-columns:30px 1fr auto;align-items:baseline;gap:10px;padding:14px 0;border-bottom:1px solid var(--line)}
.w4-boutique .b-menu a:last-child{border-bottom:0}
.w4-boutique .b-menu-no{font-family:var(--en);font-style:italic;color:var(--rose)}
.w4-boutique .b-menu-en{font-family:var(--en);font-size:24px;font-weight:300;color:var(--brown)}
.w4-boutique .b-menu-ko{font-size:13px;color:var(--sub)}
.w4-boutique .b-menu a.on .b-menu-en{color:var(--rose)}
.w4-boutique .b-phead{padding:120px var(--g) 8px;text-align:center}
.w4-boutique .b-phead-en{font-family:var(--en);font-weight:300;font-size:40px;line-height:1.05}
.w4-boutique .b-phead-ko{margin-top:8px;font-size:17px;color:var(--ink)}
.w4-boutique .b-phead-note{margin-top:10px;font-size:14px;color:var(--sub)}
.w4-boutique .b-pills{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;margin-top:22px}
.w4-boutique .b-pill{border:1px solid var(--line);border-radius:999px;padding:5px 14px;font-family:var(--cap);font-size:12px;letter-spacing:.12em}
.w4-boutique .b-hero{position:relative;height:100svh;min-height:600px;overflow:hidden;background:#3a2c22}
.w4-boutique .b-hero--plain{background:linear-gradient(180deg,var(--bg2),var(--bg));height:72svh}
.w4-boutique .b-slide{position:absolute;inset:0;opacity:0;transition:opacity 1.6s ease}
.w4-boutique .b-slide.on{opacity:1}
.w4-boutique .b-slide img{transform:scale(1.08);transition:transform 7s ease-out}
.w4-boutique .b-slide.on img{transform:scale(1)}
.w4-boutique .b-hero:after{content:'';position:absolute;inset:0;background:linear-gradient(180deg,rgba(40,28,20,.25),rgba(40,28,20,0) 30%,rgba(40,28,20,.1) 60%,rgba(40,28,20,.5));pointer-events:none}
.w4-boutique .b-hero--plain:after{display:none}
.w4-boutique .b-hero-copy{position:absolute;left:0;right:0;bottom:84px;z-index:2;text-align:center;color:#fff;padding:0 var(--g)}
.w4-boutique .b-hero--plain .b-hero-copy{color:var(--brown);bottom:auto;top:50%;transform:translateY(-50%)}
.w4-boutique .b-hero-en{font-family:var(--cap);font-size:19px;letter-spacing:.16em;line-height:1.5;text-transform:uppercase}
.w4-boutique .b-hero-name{margin-top:6px;font-family:var(--cap);font-weight:400;font-size:15px;letter-spacing:.24em}
.w4-boutique .b-hero-name--solo{font-family:var(--mj);font-size:30px;letter-spacing:.12em}
.w4-boutique .b-hero-loc{margin-top:4px;font-family:var(--en);font-style:italic;font-size:14px;letter-spacing:.08em;opacity:.9}
.w4-boutique .b-hero-ko{margin-top:14px;font-size:15px;letter-spacing:.06em;opacity:.92}
.w4-boutique .b-hero-cp{margin-top:10px;font-size:15px;letter-spacing:.06em;min-height:3.4em;opacity:.95}
.w4-boutique .b-hero-count{position:absolute;left:0;right:0;bottom:36px;z-index:2;text-align:center;color:#fff;font-family:var(--en);font-size:15px;letter-spacing:.3em}
.w4-boutique .b-intro{position:relative;padding:var(--sec) 0 0;text-align:center;overflow:hidden}
.w4-boutique .b-over{font-family:var(--en);font-size:26px;position:relative;z-index:2}
.w4-boutique .b-word{display:block;font-family:var(--en);font-weight:300;font-size:min(44px,calc(260px / var(--wl,6)));line-height:.9;letter-spacing:-.02em;position:relative;z-index:2;margin-bottom:-14px;padding:0 8px}
.w4-boutique .b-word--ko{font-family:var(--mj);font-weight:400;font-size:min(38px,calc(230px / var(--wl,4)));letter-spacing:-.02em;margin-bottom:-14px}
.w4-boutique .b-ft-big.b-ft-big--ko{font-family:var(--mj);font-size:min(34px,calc(220px / var(--wl,4)))}
.w4-boutique .b-arch-bg{position:absolute;left:50%;top:calc(21 * var(--u));width:66%;aspect-ratio:3/4;transform:translateX(-50%);border-radius:999px 999px 0 0;background:var(--bg2)}
.w4-boutique .b-intro>div{position:relative}
.w4-boutique .b-arch{position:relative;width:56%;margin:0 auto;aspect-ratio:3/4.1;border-radius:999px 999px 0 0;overflow:hidden;z-index:1}
.w4-boutique .b-vert{position:absolute;left:14px;top:48%;writing-mode:vertical-rl;transform:rotate(180deg);font-family:var(--en);font-style:italic;font-size:14px;letter-spacing:.06em;color:var(--rose);z-index:3;max-height:40%;overflow:hidden}
.w4-boutique .b-leaf{position:absolute;right:-10px;top:52%;width:150px;z-index:3;transform-origin:90% 10%;animation:w4b-sway 6s ease-in-out infinite;pointer-events:none}
@keyframes w4b-sway{50%{transform:rotate(-5deg)}}
.w4-boutique .b-intro-h{margin-top:28px;padding:0 var(--g);font-family:var(--mj);font-weight:400;font-size:24px;color:var(--ink)}
.w4-boutique .b-poem{padding:14px var(--g) 0;font-size:16px;line-height:1.95}
.w4-boutique .b-q{display:block;font-family:var(--en);font-size:36px;line-height:.5;color:var(--rose);margin-bottom:6px}
.w4-boutique .b-prose{padding:12px var(--g) 0;text-align:left}
.w4-boutique .b-prose p{margin-bottom:12px;line-height:2}
.w4-boutique .b-rule{height:1px;background:var(--line);margin:48px var(--g) 0}
.w4-boutique .b-sec{padding-top:var(--sec)}
.w4-boutique .b-wrap{padding:0 var(--g)}
.w4-boutique .b-sh{text-align:center;padding:0 var(--g) 24px}
.w4-boutique .b-sh-en{font-family:var(--en);font-weight:300;font-size:34px;line-height:1.1}
.w4-boutique .b-sh-ko{margin-top:10px;font-family:var(--ko);font-weight:400;font-size:15px;color:var(--sub)}
.w4-boutique .b-count{font-family:var(--en);font-style:italic}
.w4-boutique .b-sh-lead{margin-top:8px;font-size:14px;color:var(--sub)}
.w4-boutique .b-list{border-top:1px solid var(--line)}
.w4-boutique .b-row{display:flex;justify-content:space-between;gap:12px;padding:18px 0;border-bottom:1px solid var(--line);font-size:16px}
.w4-boutique .b-row .r{font-family:var(--cap);font-size:12px;letter-spacing:.14em;color:var(--rose)}
.w4-boutique .b-more{display:inline-block;font-family:var(--cap);font-size:14px;letter-spacing:.14em;border-bottom:1px solid currentColor;padding-bottom:2px;color:var(--ink);text-transform:uppercase}
.w4-boutique .b-more--c{display:table;margin:22px auto 0}
.w4-boutique .b-post{padding:18px 0;border-bottom:1px solid var(--line);text-align:left}
.w4-boutique .b-post-date{font-family:var(--en);font-style:italic;color:var(--rose)}
.w4-boutique .b-weather{padding:var(--sec) var(--g) 0}.w4-boutique #postcard-maker,.w4-boutique #reviews{--section-space:var(--sec)}
.w4-boutique .b-wx{position:relative;text-align:center;margin-top:40px;padding:30px 24px 24px;border:1px solid var(--line);border-radius:999px 999px 0 0;background:linear-gradient(180deg,rgba(255,255,255,.4),rgba(255,255,255,0))}
.w4-boutique .b-wx-ico{width:40px;height:40px;margin:0 auto;border-radius:50%;border:1px solid var(--rose);display:grid;place-items:center;color:var(--rose)}
.w4-boutique .b-wx-ico svg{width:20px;height:20px;animation:w4b-glow 3s ease-in-out infinite}
@keyframes w4b-glow{50%{opacity:.6;transform:scale(.9)}}
.w4-boutique .b-wx-deg{font-family:var(--en);font-weight:300;font-size:64px;line-height:1;margin-top:10px}
.w4-boutique .b-wx-deg sup{font-size:24px;vertical-align:top}
.w4-boutique .b-wx-cond{font-family:var(--cap);letter-spacing:.2em;font-size:13px;color:var(--rose);text-transform:uppercase}
.w4-boutique .b-wx-note{margin-top:12px;font-size:16px;line-height:1.8}
.w4-boutique .b-wx-band{margin-top:8px;font-size:14px;color:var(--sub)}
.w4-boutique .b-wx-band b,.w4-boutique .b-wx-band span:first-child{display:inline-block;border:1px solid var(--line);border-radius:999px;padding:0 12px;margin-right:6px;color:var(--ink);font-weight:400}
.w4-boutique .b-wx-obs{margin-top:8px;font-family:var(--en);font-style:italic;font-size:13px;color:var(--sub)}
.w4-boutique .wrap{padding:0 var(--g)}
.w4-boutique section.wrap,.w4-boutique section.unit,.w4-boutique #units{padding-top:var(--sec)}
.w4-boutique section.tight{padding-top:40px}
.w4-boutique .label{display:block;text-align:center;font-family:var(--cap);font-size:12px;letter-spacing:.22em;color:var(--rose);text-transform:uppercase}
.w4-boutique section.wrap>h2,.w4-boutique .unit h2,.w4-boutique #units h2,.w4-boutique .headrow h2{margin-top:8px;text-align:center;font-family:var(--en);font-weight:300;font-size:40px;line-height:1.15;color:var(--brown)}
.w4-boutique h2 .cnt{font-family:var(--en);font-style:italic;font-size:18px;margin-left:6px;color:var(--rose)}
.w4-boutique .lead{margin-top:12px;text-align:center;font-size:15px;color:var(--sub)}
.w4-boutique .sub-h{margin-top:28px;font-family:var(--cap);font-size:12px;letter-spacing:.2em;color:var(--rose);text-align:center}
.w4-boutique .pills,.w4-boutique .chips{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;margin-top:20px}
.w4-boutique .pill,.w4-boutique .chips button{border:1px solid var(--line);border-radius:999px;padding:5px 14px;font-size:14px}
.w4-boutique .chips button[aria-pressed=true],.w4-boutique .chips button[aria-current=true]{background:var(--brown);color:var(--bg);border-color:var(--brown)}
.w4-boutique .link{font-family:var(--cap);font-size:14px;letter-spacing:.14em;border-bottom:1px solid currentColor;padding-bottom:2px;color:var(--ink);text-transform:uppercase}
.w4-boutique .rooms2{display:grid;grid-template-columns:1fr 1fr;gap:18px 12px;margin-top:24px}
.w4-boutique .room2{display:flex;flex-direction:column;min-width:0}
.w4-boutique .room2>img,.w4-boutique .room2-blank{display:block;aspect-ratio:3/2;height:auto;border-radius:999px 999px 0 0;background:var(--bg2)}
.w4-boutique .room2 .no{margin-top:8px;font-family:var(--en);font-style:italic;font-size:15px;line-height:1.2;color:var(--rose)}
.w4-boutique .room2 .nm{font-family:var(--mj);font-weight:400;font-size:17px;line-height:1.4;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}
.w4-boutique .room2 .ds{font-size:13px;line-height:1.6;color:var(--sub);display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}
.w4-boutique .headrow{display:flex;flex-direction:column;align-items:center;gap:8px}.w4-boutique .chead .headrow h2{margin-top:0}
.w4-boutique .unit .unit-car{margin-top:24px}
.w4-boutique .car .slide img{aspect-ratio:1/.84;height:auto}
.w4-boutique .car .arrow{position:absolute;top:50%;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:1px solid rgba(255,255,255,.8);color:#fff;display:grid;place-items:center;font-size:16px;background:rgba(58,40,28,.2)}
.w4-boutique .car .prev{left:10px}
.w4-boutique .car .next{right:10px}
.w4-boutique .car .count{position:absolute;left:0;right:0;bottom:12px;text-align:center;color:#fff;font-family:var(--en);font-size:14px;letter-spacing:.3em}
.w4-boutique .spec{margin:28px var(--g) 0;display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line)}
.w4-boutique .spec div{padding:18px 0;border-bottom:1px solid var(--line);text-align:center}
.w4-boutique .spec div:nth-child(odd){border-right:1px solid var(--line)}
.w4-boutique .spec dt{font-family:var(--cap);font-size:11px;letter-spacing:.2em;color:var(--rose);text-transform:uppercase}
.w4-boutique .spec dd{font-family:var(--mj);font-size:17px;line-height:1.5}
.w4-boutique .btns{display:flex;justify-content:center;gap:10px;flex-wrap:wrap;margin:28px var(--g) 0}
.w4-boutique section.wrap .btns{margin-left:0;margin-right:0}
.w4-boutique .btn{display:inline-flex;align-items:center;justify-content:center;min-height:48px;padding:0 26px;border:1px solid var(--brown);background:var(--brown);color:var(--bg);font-family:var(--cap);font-size:14px;letter-spacing:.14em}
.w4-boutique .btn.ghost{background:transparent;color:var(--brown)}
.w4-boutique .rows{margin-top:24px;border-top:1px solid var(--line)}
.w4-boutique .line{display:flex;justify-content:space-between;gap:12px;width:100%;padding:18px 0;border-bottom:1px solid var(--line);text-align:left;font-size:16px}
.w4-boutique .line .r{font-family:var(--cap);font-size:12px;letter-spacing:.14em;color:var(--rose)}
.w4-boutique .amen{list-style:none;padding:0;margin:28px 0 0;display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line)}
.w4-boutique .amen li{display:flex;gap:10px;align-items:center;justify-content:center;padding:16px 8px;border-bottom:1px solid var(--line);font-size:15px;color:var(--sub)}
.w4-boutique .amen li:nth-child(odd){border-right:1px solid var(--line)}
.w4-boutique .amen li.on{color:var(--ink)}
.w4-boutique .amen svg{width:18px;height:18px;flex:none;color:var(--rose)}
.w4-boutique .amen b{font-weight:400}
.w4-boutique .book{position:relative;margin:var(--sec) calc(var(--g)*-1) 0;min-height:60svh;overflow:hidden;display:flex}
.w4-boutique .book>img{position:absolute;inset:0}
.w4-boutique .book:after{content:'';position:absolute;inset:0;background:rgba(58,40,28,.42)}
.w4-boutique .book .bd{position:relative;z-index:1;flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;text-align:center;color:#fff;padding:48px var(--g)}
.w4-boutique .book h2{font-family:var(--en);font-weight:300;font-size:46px;line-height:1.05;color:#fff}
.w4-boutique .book .btn{width:128px;height:128px;border-radius:50%;padding:0;background:transparent;border:1px solid #fff;color:#fff;position:relative;margin-top:16px}
.w4-boutique .book .btn:before{content:'';position:absolute;inset:-9px;border-radius:50%;border:1px dashed rgba(255,255,255,.5);animation:w4b-spin 24s linear infinite}
@keyframes w4b-spin{to{transform:rotate(360deg)}}
.w4-boutique .book .link{color:#fff}
.w4-boutique .gal{margin-top:28px;display:grid;grid-template-columns:1fr 1fr;gap:10px}
.w4-boutique .gal button{aspect-ratio:3/4;overflow:hidden;background:var(--bg2)}
.w4-boutique .gal button{aspect-ratio:4/3}
.w4-boutique .gal button:first-child{grid-column:span 2;aspect-ratio:16/10}
.w4-boutique .gal button:nth-child(2){border-radius:999px 999px 0 0}
.w4-boutique .gal--rest{margin-top:10px}
.w4-boutique .gal--rest button:first-child{grid-column:auto;aspect-ratio:4/3}
.w4-boutique .gal--rest button:nth-child(2){border-radius:0}
.w4-boutique .loc{margin-top:28px;text-align:center}
.w4-boutique .loc .map{display:block;width:80%;margin:0 auto;aspect-ratio:1;border:0;border-radius:50%;filter:sepia(.35) saturate(.7)}
.w4-boutique .loc address{display:block;margin-top:24px;font-style:normal;font-size:18px;color:var(--ink)}
.w4-boutique .loc p{font-size:14px;color:var(--sub)}
.w4-boutique .rail{display:flex;gap:12px;overflow-x:auto;margin:22px calc(var(--g)*-1) 0;padding:0 var(--g);scrollbar-width:none}
.w4-boutique .rail::-webkit-scrollbar{display:none}.w4-boutique .film{flex:0 0 78%;display:flex;flex-direction:column;gap:8px;text-align:left}.w4-boutique .film .poster{position:relative;display:block;aspect-ratio:16/9;overflow:hidden;background:var(--bg2)}.w4-boutique .film .poster img{width:100%;height:100%;object-fit:cover}.w4-boutique .film .play{position:absolute;inset:0;display:flex;align-items:center;justify-content:center}.w4-boutique .film .play::after{content:'';width:14px;height:18px;background:#fff;clip-path:polygon(0 0,100% 50%,0 100%)}.w4-boutique .film .cap{font-size:14px;color:var(--sub)}
.w4-boutique .card{flex:0 0 70%;display:flex;flex-direction:column;gap:6px;padding:20px;border:1px solid var(--line);text-align:left;background:rgba(255,255,255,.3)}
.w4-boutique .card .nm{font-family:var(--mj);font-weight:400;font-size:18px}
.w4-boutique .card .ds{font-size:14px;color:var(--sub);line-height:1.7}
.w4-boutique .guide{margin-top:24px;counter-reset:bg}
.w4-boutique .place{counter-increment:bg;display:flex;align-items:center;gap:16px;width:100%;padding:14px 0;border-bottom:1px solid var(--line);text-align:left}
.w4-boutique .place .ph{position:relative;flex:0 0 90px;width:90px;aspect-ratio:3/4;border-radius:999px 999px 0 0;overflow:hidden;background:var(--bg2)}
.w4-boutique .place .ph img{position:absolute;inset:0}
.w4-boutique .place:nth-child(even) .ph{order:2}
.w4-boutique .place .ph:after{content:counter(bg,decimal-leading-zero);position:absolute;left:0;right:0;bottom:4px;text-align:center;font-family:var(--en);font-style:italic;font-size:15px;color:#fff;text-shadow:0 1px 4px rgba(40,28,20,.6)}
.w4-boutique .place .tx{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
.w4-boutique .place small{display:block;font-family:var(--cap);letter-spacing:.14em;font-size:11px;line-height:1.5;color:var(--rose)}
.w4-boutique .place .nm{display:block;font-family:var(--mj);font-weight:400;font-size:19px;line-height:1.4}
.w4-boutique .place .ds{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-size:14px;line-height:1.7;color:var(--sub)}
.w4-boutique .place.noph .tx{flex-direction:row;flex-wrap:wrap;align-items:baseline;column-gap:12px}
.w4-boutique .place.noph small{order:2}
.w4-boutique .place.noph .ds{flex-basis:100%}
.w4-boutique .fold .guide{counter-reset:none;margin-top:0}
.w4-boutique .fold{margin-top:8px;text-align:center}
.w4-boutique details>summary{list-style:none;cursor:pointer}.w4-boutique details:not([open])>:not(summary){display:none}
.w4-boutique details>summary::-webkit-details-marker{display:none}
.w4-boutique .fold summary.link{display:inline-block;margin:18px auto 0}
.w4-boutique .courses{display:grid;gap:10px;margin-top:22px}
.w4-boutique .course{display:flex;flex-direction:column;gap:4px;padding:20px;border:1px solid var(--line);text-align:left}
.w4-boutique .course .dim{font-family:var(--en);font-style:italic;color:var(--rose)}
.w4-boutique .course .ds{font-size:14px;color:var(--sub)}
.w4-boutique .clamp3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.w4-boutique .clamp{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.w4-boutique .grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:22px}
.w4-boutique .grid3 .card{flex:auto;padding:10px}
.w4-boutique .grid3 .card img{aspect-ratio:3/4;height:auto;border-radius:999px 999px 0 0}
.w4-boutique .grid3 .card .nm{font-size:16px;line-height:1.4}
.w4-boutique .grid3 .card .ds{font-size:13px}
.w4-boutique .grid2tx{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px}
.w4-boutique .card--tx{flex:auto;padding:14px 16px;gap:2px}
.w4-boutique .card--tx .nm{font-size:16px;line-height:1.4}
.w4-boutique .card--tx .ds{font-size:13px}
.w4-boutique .rail .card{flex-basis:62%;padding:0 0 14px;gap:4px}
.w4-boutique .rail .card img,.w4-boutique .rail .card .noimg{aspect-ratio:4/3;height:auto;border-radius:999px 999px 0 0}
.w4-boutique .rail .card .noimg{display:grid;place-items:center;padding:0 16px;font-family:var(--mj);text-align:center}
.w4-boutique .rail .card .nm,.w4-boutique .rail .card .ds{padding:0 14px}
.w4-boutique .rail .card .nm{display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}
.w4-boutique .noimg{display:block;aspect-ratio:3/4;border-radius:999px 999px 0 0;background:var(--bg2)}
.w4-boutique .dim{color:var(--sub)}
.w4-boutique .blk{display:block}
.w4-boutique .faq{margin-top:28px;border-top:1px solid var(--line);counter-reset:fq}
.w4-boutique .faq details{counter-increment:fq;border-bottom:1px solid var(--line)}
.w4-boutique .faq summary{display:flex;gap:14px;padding:20px 0;font-size:16px;color:var(--ink)}
.w4-boutique .faq summary:before{content:counter(fq,lower-roman) '.';font-family:var(--en);font-style:italic;color:var(--rose);font-size:18px;flex:none}
.w4-boutique .faq details p{padding:0 0 20px 30px;font-size:15px;color:var(--sub)}
.w4-boutique #postcard-maker,.w4-boutique #reviews{background:transparent;border-color:var(--line)}
.w4-boutique #postcard-maker .rounded,.w4-boutique #reviews .rounded-lg{border-radius:0}
.w4-boutique .b-ft{margin-top:88px;padding:56px var(--g) 36px;text-align:center;border-top:1px solid var(--line);font-size:13px;color:var(--sub)}
.w4-boutique .b-ft p{margin-top:6px}
.w4-boutique .b-ft-big{display:block;font-family:var(--en);font-weight:300;font-size:40px;line-height:1;color:var(--brown);margin-bottom:16px}
.w4-boutique .byline a{text-decoration:underline}
.w4-boutique .b-player{position:fixed;left:14px;bottom:78px;z-index:30;background:rgba(241,237,233,.94);border:1px solid var(--line);border-radius:999px;padding:2px 8px 2px 12px;backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)}
.w4-boutique #w4d-mini{display:flex;align-items:center;gap:2px}
.w4-boutique #w4d-tape{width:22px;height:22px;margin-right:6px;color:var(--rose)}
.w4-boutique #w4d-tape svg{width:100%;height:100%}
.w4-boutique #w4d-now{max-width:110px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;color:var(--ink)}
.w4-boutique #w4d-mini button{width:36px;height:36px;padding:9px;color:var(--ink)}
.w4-boutique #w4d-mini button svg{width:100%;height:100%}
.w4-boutique #w4d-panel{background:var(--bg);border:1px solid var(--line);border-radius:0;color:var(--ink)}
.w4-boutique .b-bar{position:fixed;left:0;right:0;bottom:0;z-index:30;display:grid;grid-auto-flow:column;grid-auto-columns:1fr;height:64px;background:var(--bg);border-top:1px solid var(--line)}
.w4-boutique .b-bar>a,.w4-boutique .b-bar>button{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;font-size:11px;color:var(--sub);border-right:1px solid var(--line)}
.w4-boutique .b-bar svg{width:20px;height:20px}
.w4-boutique .b-bar .cta{grid-column:span 2;background:var(--brown);color:var(--bg);border:0;font-family:var(--cap);font-size:15px;letter-spacing:.2em}
.w4-boutique .sform h3,.w4-boutique .b-bookbox h3{font-family:var(--en);font-weight:300;font-size:32px}
.w4-boutique .b-bookbox{padding:22px var(--g) 8px}
.w4-boutique .b-cap{font-family:var(--cap);font-size:12px;letter-spacing:.22em;color:var(--rose);text-transform:uppercase}
@media (prefers-reduced-motion:reduce){.w4-boutique .b-leaf,.w4-boutique .b-wx-ico svg,.w4-boutique .book .btn:before{animation:none}
.w4-boutique .b-slide img{transition:none;transform:none}}
@media (min-width:700px){.w4-boutique{--sec:64px;--side:max(0px,calc((100vw - 640px) / 2));max-width:640px;margin-left:auto;margin-right:auto;box-shadow:0 0 0 1px rgba(0,0,0,.06)}.w4-boutique .b-hd{left:var(--side);right:var(--side)}.w4-boutique .b-menu{left:calc(var(--side) + 12px);right:calc(var(--side) + 12px)}.w4-boutique .b-player{left:calc(var(--side) + 14px)}.w4-boutique .b-bar{left:var(--side);right:var(--side)}}
@media(max-width:1023px){.w4-boutique .b-arch-bg{position:relative;left:auto;top:auto;transform:none;margin:24px auto 0}
.w4-boutique .b-arch-bg+.b-arch{margin-top:-78%}}
.w4-boutique .b-circ{font-size:12px}
.w4-boutique .b-hnav,.w4-boutique .b-hcta,.w4-boutique .b-hero-act{display:none}
@media(min-width:1024px){.w4-boutique{--g:max(32px, calc((100vw - 1080px)/2) );--sec:48px;--side:0px;max-width:none;box-shadow:none;padding-bottom:0}
.w4-boutique .b-hd,.w4-boutique .b-hd.solid{left:0;right:0;height:72px;padding:0 var(--g);gap:32px;background:#f1ede9f0;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:var(--brown);border-bottom:1px solid var(--line)}
.w4-boutique .b-hbtn,.w4-boutique .b-menu,.w4-boutique .b-bar{display:none}
.w4-boutique .b-hnav{display:flex;gap:28px;margin-left:auto;font-size:15px;letter-spacing:.04em}
.w4-boutique .b-hnav a{padding:4px 0;border-bottom:1px solid transparent}
.w4-boutique .b-hnav a.on{color:var(--rose);border-color:currentColor}
.w4-boutique .b-hcta{display:flex;gap:8px;align-items:center}
.w4-boutique .b-hcta>a,.w4-boutique .b-hcta>button{display:inline-flex;align-items:center;gap:6px;height:40px;padding:0 16px;border:1px solid var(--line);font-size:14px;letter-spacing:.04em}
.w4-boutique .b-hcta svg{width:16px;height:16px}
.w4-boutique .b-hcta .cta{background:var(--brown);border-color:var(--brown);color:var(--bg);font-family:var(--cap);letter-spacing:.12em}
.w4-boutique .b-player{left:auto;right:24px;bottom:24px}
.w4-boutique .b-phead{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 20px;padding:112px var(--g) 0;text-align:left}
.w4-boutique .b-phead-en{font-size:36px}
.w4-boutique .b-phead-ko,.w4-boutique .b-phead-note,.w4-boutique .b-pills{margin-top:0}
.w4-boutique .b-pills{flex-basis:100%;justify-content:flex-start;margin-top:14px}
.w4-boutique .b-hero{height:auto;min-height:0;padding:100px var(--g) 0;background:transparent;overflow:visible;display:grid;grid-template-columns:420px minmax(0,1fr);grid-template-rows:460px;column-gap:80px;align-items:center}
.w4-boutique .b-hero:before{content:"";position:absolute;left:calc(var(--g) + 32px);top:128px;width:420px;height:460px;border-radius:210px 210px 0 0;background:var(--bg2)}
.w4-boutique .b-hero:after{display:none}
.w4-boutique .b-slide{inset:100px auto auto var(--g);width:420px;height:460px;border-radius:210px 210px 0 0;overflow:hidden;z-index:1}
.w4-boutique .b-hero-copy{position:relative;grid-column:2;left:auto;right:auto;bottom:auto;padding:0;text-align:left;color:var(--brown)}
.w4-boutique .b-hero-en{font-size:44px;letter-spacing:.1em;line-height:1.15}
.w4-boutique .b-hero-name{margin-top:10px;font-size:17px}
.w4-boutique .b-hero-name--solo{margin-top:0;font-size:48px;letter-spacing:.1em;line-height:1.2}
.w4-boutique .b-hero-loc{margin-top:10px;font-size:16px;color:var(--rose)}
.w4-boutique .b-hero-ko{margin-top:28px;padding-top:24px;border-top:1px solid var(--line);font-size:17px;color:var(--ink)}
.w4-boutique .b-hero-cp{font-size:17px;min-height:3.4em;color:var(--ink)}
.w4-boutique .b-hero-act{display:flex;gap:10px;margin-top:24px}
.w4-boutique .b-hero-count{left:var(--g);right:auto;width:420px;bottom:16px;z-index:2;font-size:14px}
.w4-boutique .b-hero--plain{grid-template-columns:minmax(0,1fr);grid-template-rows:auto;padding-bottom:24px}
.w4-boutique .b-hero--plain:before{display:none}
.w4-boutique .b-hero--plain .b-hero-copy{grid-column:1;top:auto;transform:none;text-align:center}
.w4-boutique .b-hero--plain .b-hero-act{justify-content:center}
.w4-boutique .b-weather{display:grid;grid-template-columns:280px minmax(0,1fr);column-gap:48px;align-items:center}
.w4-boutique .b-weather .b-sh{text-align:left;padding:0}
.w4-boutique .b-wx{margin-top:0;display:grid;grid-template-columns:48px auto minmax(0,1fr);column-gap:28px;align-items:center;text-align:left;padding:20px 40px;border-radius:999px}
.w4-boutique .b-wx>*{grid-column:3;margin-top:0}
.w4-boutique .b-wx-ico{grid-column:1;grid-row:1/span 4;margin:0}
.w4-boutique .b-wx-deg{grid-column:2;grid-row:1/span 3;margin:0}
.w4-boutique .b-wx-cond{grid-column:2;grid-row:4;white-space:nowrap}
.w4-boutique .b-wx-deg sup{vertical-align:.8em;line-height:0}
.w4-boutique .b-sh-en{font-size:32px}
.w4-boutique section.wrap>h2,.w4-boutique .unit h2,.w4-boutique #units h2,.w4-boutique .headrow h2,.w4-boutique .book h2{font-size:32px}
.w4-boutique .b-intro{display:grid;grid-template-columns:300px minmax(0,1fr);grid-template-rows:repeat(5,auto) 1fr;column-gap:88px;padding:var(--sec) var(--g) 0;text-align:left;overflow:visible}
.w4-boutique .b-intro>*{grid-column:2}
.w4-boutique .b-arch{grid-column:1;grid-row:1/7;width:300px;margin:0}
.w4-boutique .b-intro>.b-arch-bg{position:absolute;grid-column:1;grid-row:1/7;left:28px;top:24px;width:300px;aspect-ratio:3/4.1;transform:none}
.w4-boutique .b-vert{grid-column:1;grid-row:1/7;left:-40px;top:200px}
.w4-boutique .b-leaf{grid-column:1;grid-row:1/7;left:240px;right:auto;top:auto;bottom:-24px;width:120px}
.w4-boutique .b-over{padding-top:24px}
.w4-boutique .b-word{margin-bottom:0;padding:0}
.w4-boutique .b-intro-h{margin-top:28px;padding:0;font-size:28px}
.w4-boutique .b-poem,.w4-boutique .b-prose{padding:14px 0 0;max-width:600px}
.w4-boutique .b-sec{display:grid;grid-template-columns:240px minmax(0,1fr);column-gap:48px;align-items:start;padding:var(--sec) var(--g) 0}
.w4-boutique .b-sec .b-sh{text-align:left;padding:0}
.w4-boutique .b-sec .b-wrap{padding:0}
.w4-boutique .b-sec .b-post{padding-top:12px}
.w4-boutique .b-sec .b-more--c{margin-left:0}
.w4-boutique .b-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:48px}
.w4-boutique .b-rule{margin-top:var(--sec)}
.w4-boutique .rooms2{grid-template-columns:repeat(4,minmax(0,1fr));gap:24px}
.w4-boutique .book{margin:var(--sec) var(--g) 0;min-height:0;height:260px}
.w4-boutique .book .bd{gap:10px;padding:32px}
.w4-boutique .book .btn{width:104px;height:104px;margin-top:8px}
.w4-boutique .rail{margin-left:0;margin-right:0;padding:0;gap:24px}
.w4-boutique .film,.w4-boutique .rail .card{flex-basis:calc((100% - 48px) / 3)}
.w4-boutique .rail .card img,.w4-boutique .rail .card .noimg{aspect-ratio:3/2}
.w4-boutique .gal{grid-template-columns:2fr 1fr 1fr;grid-template-rows:repeat(2,150px);gap:10px}
.w4-boutique .gal button,.w4-boutique .gal button:first-child{aspect-ratio:auto;grid-column:auto}
.w4-boutique .gal button:first-child{grid-row:span 2}
.w4-boutique .gal--rest{grid-template-columns:repeat(4,minmax(0,1fr));grid-template-rows:none}
.w4-boutique .gal--rest button,.w4-boutique .gal--rest button:first-child{grid-row:auto;aspect-ratio:3/2}
.w4-boutique .guide{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:24px}
.w4-boutique .place{flex-direction:column;align-items:stretch;gap:10px;padding:0;border-bottom:0}
.w4-boutique .place .ph{flex:none;width:100%;aspect-ratio:3/2}
.w4-boutique .place:nth-child(2n) .ph{order:0}
.w4-boutique .place .nm{font-size:17px}
.w4-boutique #eat .guide{grid-template-columns:repeat(2,minmax(0,1fr));column-gap:48px;row-gap:0}
.w4-boutique #eat .place{flex-direction:row;align-items:center;gap:16px;padding:12px 0;border-bottom:1px solid var(--line)}
.w4-boutique #eat .place .ph{flex:0 0 72px;width:72px;aspect-ratio:3/4}
.w4-boutique .rows{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:48px}
.w4-boutique .courses{grid-template-columns:repeat(2,minmax(0,1fr))}
.w4-boutique .grid3,.w4-boutique .grid2tx{grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}
.w4-boutique .grid3 .card img{aspect-ratio:3/2}
.w4-boutique .unit{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-template-rows:auto 1fr;column-gap:48px;padding:var(--sec) var(--g) 0;align-items:start}
.w4-boutique .unit>.wrap{grid-column:2;padding:0}
.w4-boutique .unit .unit-car{grid-column:1;grid-row:1/3;margin-top:0}
.w4-boutique .unit .label,.w4-boutique .unit h2,.w4-boutique .unit .lead{text-align:left}
.w4-boutique .unit .spec{margin:20px 0 0}
.w4-boutique .unit .btns{justify-content:flex-start;margin:20px 0 0}
.w4-boutique .car .slide img{aspect-ratio:3/2}
.w4-boutique .amen{grid-template-columns:repeat(4,minmax(0,1fr))}
.w4-boutique .amen li,.w4-boutique .amen li:nth-child(odd){border-right:1px solid var(--line)}
.w4-boutique .amen li:nth-child(4n){border-right:0}
.w4-boutique .faq{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:48px;align-items:start}
.w4-boutique .loc{text-align:left}
.w4-boutique .loc:has(.map){margin-top:0;display:grid;grid-template-columns:200px minmax(0,1fr);column-gap:32px;align-items:center}
.w4-boutique .loc .map{grid-row:1/span 3;width:200px}
.w4-boutique .loc address{margin-top:0}
.w4-boutique section.wrap{display:flow-root}
.w4-boutique section.wrap>:is(.label,h2,.lead){float:left;clear:left;width:240px;text-align:left}
.w4-boutique section.wrap>:not(.label,h2,.lead){margin-left:288px;margin-top:0}
.w4-boutique section.wrap>:not(.label,h2,.lead)+:not(.label,h2,.lead){margin-top:20px}
.w4-boutique section.wrap .sub-h{text-align:left}
.w4-boutique section.wrap .chips,.w4-boutique section.wrap .btns{justify-content:flex-start}
.w4-boutique section.wrap .fold summary.link{margin-top:12px}
.w4-boutique .b-ft{margin-top:56px;padding:40px var(--g) 32px}
.w4-boutique[data-page=story] main{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.2fr);column-gap:48px;align-items:start;padding:0 var(--g)}
.w4-boutique[data-page=story] main [data-pg~=story]{grid-column:1}
.w4-boutique[data-page=story] main #reviews{grid-column:1}
.w4-boutique[data-page=story] main #postcard-maker{grid-column:2;grid-row:1/span 2}
.w4-boutique[data-page=story] main .b-sec{display:block;padding-left:0;padding-right:0}
.w4-boutique[data-page=story] main .b-sec .b-sh{text-align:left}
.w4-boutique[data-page=story] main .b-rule{margin-left:0;margin-right:0}
.w4-boutique[data-page=story] main .shell{padding-inline:24px}
.w4-boutique[data-page=story] main #reviews{margin-top:var(--sec)}}

View File

@ -0,0 +1,50 @@
const base = {
viewBox: '0 0 24 24',
width: 20,
height: 20,
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.6,
'aria-hidden': true,
} as const;
export function MapIcon() {
return (
<svg {...base}>
<path d="M12 21s-7-6.2-7-11.5A7 7 0 0 1 19 9.5C19 14.8 12 21 12 21z" />
<circle cx="12" cy="9.5" r="2.5" />
</svg>
);
}
export function TalkIcon() {
return (
<svg {...base}>
<path d="M12 4C7 4 3 7.1 3 11c0 2.5 1.7 4.7 4.2 5.9L6.5 20l3.6-2.3c.6.1 1.2.1 1.9.1 5 0 9-3.1 9-6.9S17 4 12 4z" />
</svg>
);
}
export function PhoneIcon() {
return (
<svg {...base}>
<path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2" />
</svg>
);
}
export function CheckIcon() {
return (
<svg {...base} width={16} height={16} strokeWidth={2}>
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
);
}
export function CrossIcon() {
return (
<svg {...base} width={16} height={16} strokeWidth={2}>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
);
}

View File

@ -0,0 +1,45 @@
import type {Layout} from '@site/lib/layout';
import {StoryTail} from '../paper/StoryTail';
import {Around} from './Around';
import {Booking} from './Booking';
import {Faq} from './Faq';
import {Festival} from './Festival';
import {Frame} from './Frame';
import {Gallery} from './Gallery';
import {Hero} from './Hero';
import {Info} from './Info';
import {Intro} from './Intro';
import {Itinerary} from './Itinerary';
import {Location} from './Location';
import {Rooms} from './Rooms';
import {SectionHead} from './SectionHead';
import {Story} from './Story';
import {Video} from './Video';
const Nothing = () => null;
export const layout: Layout = {
Frame,
SectionHead,
sections: {
hero: Hero,
weather: Nothing,
intro: Intro,
blog: Nothing,
rooms: Rooms,
menu: Rooms,
programs: Rooms,
photos: Gallery,
video: Video,
local: Around,
booking: Booking,
info: Info,
map: Location,
faq: Faq,
festival: Festival,
itinerary: Itinerary,
story: Story,
reviews: StoryTail,
postcardMaker: Nothing,
},
};

View File

@ -0,0 +1,154 @@
import type {CSSProperties, ReactNode} from 'react';
import {PlaceCategory} from '@o2o/shared';
export const HOUSE_EN: Record<number, string> = {
[PlaceCategory.LODGING]: 'Stay',
[PlaceCategory.CAFE]: 'Café',
[PlaceCategory.RESTAURANT]: 'Restaurant',
[PlaceCategory.CLINIC]: 'Clinic',
};
export const HOUSE_WORD: Record<number, string> = {
[PlaceCategory.LODGING]: '펜션',
[PlaceCategory.CAFE]: '카페',
[PlaceCategory.RESTAURANT]: '가게',
[PlaceCategory.CLINIC]: '병원',
};
export function pad2(n: number): string {
return String(n).padStart(2, '0');
}
const ROMAN: [number, string][] = [
[10, 'x'],
[9, 'ix'],
[5, 'v'],
[4, 'iv'],
[1, 'i'],
];
export function roman(n: number): string {
let rest = n;
let out = '';
for (const [value, mark] of ROMAN) {
while (rest >= value) {
out += mark;
rest -= value;
}
}
return out;
}
export function Sh({
en,
id,
children,
count,
lead,
}: {
en: string;
id?: string;
children?: ReactNode;
count?: ReactNode;
lead?: ReactNode;
}) {
return (
<div className="b-sh rv">
<p className="b-sh-en" aria-hidden={children ? true : undefined}>
{en}
</p>
{children && (
<h2 className="b-sh-ko" id={id}>
{children}
{count != null && <span className="b-count"> {count}</span>}
</h2>
)}
{lead && <p className="b-sh-lead">{lead}</p>}
</div>
);
}
export function Rule() {
return <div className="b-rule" />;
}
export function MoreSummary({children}: {children: ReactNode}) {
return (
<summary>
<span className="b-more">{children}</span>
</summary>
);
}
export function PinIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden>
<path d="M12 21s-7-6.2-7-11.5A7 7 0 0 1 19 9.5C19 14.8 12 21 12 21z" />
<circle cx="12" cy="9.5" r="2.5" />
</svg>
);
}
export function ChatIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden>
<path d="M12 4C7 4 3 7.1 3 11c0 2.5 1.7 4.7 4.2 5.9L6.5 20l3.6-2.3c.6.1 1.2.1 1.9.1 5 0 9-3.1 9-6.9S17 4 12 4z" />
</svg>
);
}
export function PhoneIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden>
<path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2" />
</svg>
);
}
export function CheckIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden>
<path d="M5 12.5l4.2 4.2L19 7" />
</svg>
);
}
export function CrossIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden>
<path d="M7 7l10 10M17 7L7 17" />
</svg>
);
}
export function Leaf() {
return (
<svg className="b-leaf" viewBox="0 0 150 220" fill="none" aria-hidden>
<path d="M140 6 C100 60 60 120 18 214" stroke="#6b7b56" strokeWidth="2" />
<g fill="#7d8c66" opacity=".85">
<path d="M128 26 c-30 -6 -48 4 -58 18 c22 4 42 -2 58 -18z" />
<path d="M116 52 c-32 -2 -50 10 -58 26 c24 2 44 -8 58 -26z" />
<path d="M102 80 c-32 2 -48 16 -54 32 c24 0 42 -12 54 -32z" />
<path d="M88 110 c-30 6 -44 20 -48 36 c22 -2 40 -16 48 -36z" />
<path d="M126 30 c10 -24 4 -18 -2 -22" />
<path d="M112 58 c28 -10 34 -26 30 -38 c-18 8 -28 20 -30 38z" />
<path d="M98 88 c28 -8 36 -24 32 -38 c-18 8 -30 20 -32 38z" />
<path d="M84 118 c26 -6 34 -22 32 -36 c-18 6 -30 18 -32 36z" />
</g>
</svg>
);
}
export function Wordmark({name, english, className}: {name: string; english?: string; className: string}) {
const text = english?.trim() || name;
const ko = !english?.trim();
return (
<p
className={ko ? `${className} ${className.split(' ')[0]}--ko` : className}
style={{'--wl': Math.max(text.length, 1)} as CSSProperties}
aria-hidden
>
{text}
</p>
);
}

View File

@ -0,0 +1,154 @@
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 {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 FIRST_SPOTS = 6;
const FIRST_EATS = 5;
const HOME_ROWS = 3;
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, value, onChange}: {places: LocalPlace[]; value: string; onChange: (id: string) => void}) {
const tabs = RANGES.filter((r, i) => {
const n = places.filter((p) => r.test(meters(p))).length;
return i === 0 || (n > 0 && n < places.length);
});
if (tabs.length < 2) return null;
return (
<div className="chips" role="tablist">
{tabs.map((r) => (
<button key={r.id} type="button" role="tab" aria-current={r.id === value} onClick={() => onChange(r.id)}>
{r.label} {places.filter((p) => r.test(meters(p))).length}
</button>
))}
</div>
);
}
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<LocalPlace | null>(null);
const [spotRange, setSpotRange] = useState('all');
const [eatRange, setEatRange] = useState('all');
const firstSpots = useWide() ? 8 : FIRST_SPOTS;
if (rail.length === 0 && eats.length === 0) return null;
const shownSpots = spots.filter((p) => inRange(spotRange)(meters(p)));
const shownEats = eats.filter((p) => inRange(eatRange)(meters(p)));
const place = (item: LocalPlace, withText = true) => (
<button key={item.name} type="button" className={item.imageUrl ? 'place' : 'place noph'} onClick={() => setOpen(item)}>
{item.imageUrl && (
<span className="ph">
<img src={item.imageUrl} alt={item.name} loading="lazy" />
</span>
)}
<span>
{item.distanceText && <small>{item.distanceText}</small>}
<b className="nm">{item.name}</b>
{withText && item.description && <span className="ds clamp">{item.description}</span>}
</span>
</button>
);
return (
<>
{rail.length > 0 && (
<div data-pg="home">
<section className="wrap" id="around" aria-labelledby="around-heading">
<p className="label">AROUND</p>
<h2 id="around-heading">주변 안내</h2>
<p className="lead">걸어서 닿는 명소와 맛집입니다.</p>
<div className="grid3">
{rail.slice(0, HOME_ROWS).map((item) => (
<button key={item.name} type="button" className="card" onClick={() => setOpen(item)}>
<img src={item.imageUrl} alt={item.name} loading="lazy" />
<b className="nm clamp">{item.name}</b>
{item.distanceText && <span className="ds">{item.distanceText}</span>}
</button>
))}
</div>
<a className="link more" href="#/area">
지역 소개에서 모두 보기
</a>
</section>
</div>
)}
<div data-pg="area" id="guide">
{spots.length > 0 && (
<section className="wrap" id="walk">
<p className="label">AROUND</p>
<h2>
주변 명소 <span className="cnt">{spots.length}곳</span>
</h2>
<p className="lead">걸어서 닿는 곳부터 차로 가는 곳까지 묶었습니다.</p>
<RangeTabs places={spots} value={spotRange} onChange={setSpotRange} />
<div className="guide">{shownSpots.slice(0, firstSpots).map((item) => place(item))}</div>
{shownSpots.length > firstSpots && (
<details className="fold">
<summary className="link">{shownSpots.length - firstSpots}곳 더 보기</summary>
<div className="guide">{shownSpots.slice(firstSpots).map((item) => place(item))}</div>
</details>
)}
</section>
)}
{eats.length > 0 && (
<section className="wrap" id="eat">
<p className="label">EAT</p>
<h2>
주변 맛집 <span className="cnt">{eats.length}곳</span>
</h2>
<RangeTabs places={eats} value={eatRange} onChange={setEatRange} />
<div className="guide">{shownEats.slice(0, FIRST_EATS).map((item) => place(item, false))}</div>
{shownEats.length > FIRST_EATS && (
<details className="fold">
<summary className="link">{shownEats.length - FIRST_EATS}곳 더 보기</summary>
<div className="guide">{shownEats.slice(FIRST_EATS).map((item) => place(item, false))}</div>
</details>
)}
</section>
)}
</div>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
{open && (
<>
{open.imageUrl && <img src={open.imageUrl} alt={open.name} />}
<h3>{open.name}</h3>
<p className="meta">{[open.distanceText, open.category].filter(Boolean).join(' · ')}</p>
{open.description && <p>{open.description}</p>}
<a
className="out"
href={naverMapSearchUrl(open.searchQuery ?? open.name)}
target="_blank"
rel="noopener noreferrer"
>
네이버에서 보기
</a>
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,69 @@
import {PlaceCategory} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, galleryImages, stayBookingView} from '@site/lib/derive';
import {BOOK_EVENT} from '../paper/Rooms';
const book = () => window.dispatchEvent(new Event(BOOK_EVENT));
export function Booking() {
const payload = useSite();
const links = bookingLinks(payload);
const stay = payload.place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
const phone = payload.place.phone;
const images = galleryImages(payload);
const backdrop = images[images.length > 2 ? 2 : 0];
return (
<>
<div data-pg="home">
<section className="book" aria-labelledby="booking-home-heading">
{backdrop && <img src={backdrop.url} alt={backdrop.alt} loading="lazy" />}
<div className="bd">
<p className="label">RESERVATION</p>
<h2 id="booking-home-heading">이용안내 및 예약</h2>
<p>요금과 예약 방법을 안내합니다.</p>
{stay ? (
<button type="button" className="btn" onClick={book}>
예약하기
</button>
) : (
links.slice(0, 1).map((link) => (
<a key={link.url} className="btn" href={link.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(link)}
</a>
))
)}
<a className="link" href="#/stay">
이용안내 보기
</a>
</div>
</section>
</div>
<div data-pg="stay">
<section className="wrap" id="booking" aria-labelledby="booking-heading">
<p className="label">RESERVATION</p>
<h2 id="booking-heading">이용안내 및 예약</h2>
<p className="lead">요금과 예약 방법을 안내합니다.</p>
<div className="btns">
{links.map((link) => (
<a key={link.url} className="btn" href={link.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(link)}
</a>
))}
{phone && (
<a className="btn ghost" href={`tel:${phone}`}>
{phone}
</a>
)}
{stay && (
<button type="button" className="btn ghost" onClick={book}>
예약하기
</button>
)}
</div>
</section>
</div>
</>
);
}

View File

@ -0,0 +1,35 @@
import {SourceType} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {faqList} from '@site/lib/derive';
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]) => (
<details key={faq.faqId}>
<summary>{faq.question}</summary>
<p>{faq.answer}</p>
</details>
);
return (
<section className="wrap" id="faq" aria-labelledby="faq-heading">
<p className="label">FAQ</p>
<h2 id="faq-heading">
자주 묻는 질문 <span className="cnt">{faqs.length}</span>
</h2>
{verified && <p className="lead">아래 답변은 모두 사업자가 확인한 내용입니다.</p>}
<div className="faq">{faqs.slice(0, FIRST).map(item)}</div>
{faqs.length > FIRST && (
<details className="fold">
<summary className="link">{faqs.length - FIRST}개 더 보기</summary>
<div className="faq">{faqs.slice(FIRST).map(item)}</div>
</details>
)}
</section>
);
}

View File

@ -0,0 +1,75 @@
import {useState} from 'react';
import type {FestivalEntry} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverSearchUrl} from '@site/lib/format';
import {Sheet} from '../paper/Sheet';
const SEASONS = ['봄', '여름', '가을', '겨울'];
export function Festival() {
const payload = useSite();
const festivals = payload.local.festivals;
const [season, setSeason] = useState('');
const [open, setOpen] = useState<FestivalEntry | null>(null);
if (festivals.length === 0) return null;
const tabs = SEASONS.filter((s) => festivals.some((f) => f.season === s));
const shown = festivals.filter((f) => !season || f.season === season);
return (
<>
<section className="wrap" id="festival" aria-labelledby="festival-heading">
<p className="label">SEASON</p>
<h2 id="festival-heading">
계절별 축제 <span className="cnt">{festivals.length}</span>
</h2>
<p className="lead">{payload.place.addressLocality ?? '이 지역'}의 축제와 행사를 계절로 묶었습니다.</p>
{tabs.length > 1 && (
<div className="chips" role="tablist">
{['', ...tabs].map((s) => (
<button
key={s || 'all'}
type="button"
role="tab"
aria-current={s === season}
onClick={() => setSeason(s)}
>
{s || '전체'} {festivals.filter((f) => !s || f.season === s).length}
</button>
))}
</div>
)}
<div className="rail">
{shown.map((f) => (
<button key={f.name} type="button" className="card" onClick={() => setOpen(f)}>
{f.imageUrl ? (
<img src={f.imageUrl} alt={f.name} loading="lazy" />
) : (
<span className="noimg">{f.name}</span>
)}
<b className="nm">{f.name}</b>
<span className="ds">{[f.month, f.location].filter(Boolean).join(' · ')}</span>
</button>
))}
</div>
</section>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
{open && (
<>
{open.imageUrl && <img src={open.imageUrl} alt={open.name} />}
<h3>{open.name}</h3>
<p className="meta">{[open.period ?? open.month, open.location].filter(Boolean).join(' · ')}</p>
{open.description && <p>{open.description}</p>}
<a
className="out"
href={open.officialUrl ?? naverSearchUrl(open.searchQuery)}
target="_blank"
rel="noopener noreferrer"
>
자세히 보기
</a>
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,332 @@
import {useEffect, useState, 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 {MapIcon, PhoneIcon, TalkIcon} from './icons';
import '../kit/kit.css';
import './cinema.css';
const HOUSE_WORD: Record<number, string> = {
[PlaceCategory.LODGING]: '펜션',
[PlaceCategory.CAFE]: '카페',
[PlaceCategory.RESTAURANT]: '가게',
[PlaceCategory.CLINIC]: '병원',
};
type PageId = 'home' | 'area' | 'stay' | 'story';
const PAGE_HEAD: Record<Exclude<PageId, 'home'>, {label: string; title: string; note: string}> = {
area: {
label: 'AREA',
title: '지역 소개',
note: '대문에서 걸어 닿는 자리와, 이 도시가 남긴 이야기입니다.',
},
stay: {
label: 'STAY',
title: '이용안내 · 예약',
note: '객실 상세와 요금, 예약하는 방법입니다.',
},
story: {
label: '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<PageId>('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;
}
function useScrolled(): boolean {
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const sync = () => setScrolled(window.scrollY > 40);
sync();
window.addEventListener('scroll', sync, {passive: true});
return () => window.removeEventListener('scroll', sync);
}, []);
return scrolled;
}
export function Frame({children}: {children: ReactNode}) {
const payload = useSite();
const {place, site} = payload;
const page = usePage();
const scrolled = useScrolled();
const [menuOpen, setMenuOpen] = useState(false);
const [bookingOpen, setBookingOpen] = useState(false);
useEffect(() => {
const open = () => setBookingOpen(true);
window.addEventListener(BOOK_EVENT, open);
return () => window.removeEventListener(BOOK_EVENT, open);
}, []);
useEffect(() => setMenuOpen(false), [page]);
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 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 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')],
['날씨', '#/', isSectionEnabled(payload, 'weather')],
] 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.7fr'] : [])].join(' ');
const cta = stayBooking ? (
<button type="button" className="cta" onClick={() => setBookingOpen(true)}>
예약하기
</button>
) : (
booking && (
<a className="cta" href={booking.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(booking)}
</a>
)
);
return (
<div className="w4-cinema" data-page={page}>
<header className={scrolled || page !== 'home' || menuOpen ? 'top solid' : 'top'}>
<div className="wrap">
<a className="logo" href="#/">
{place.name}
</a>
<nav className="dnav" aria-label="페이지">
{tabs.map((tab) => (
<a
key={tab.page}
href={tab.page === 'home' ? '#/' : `#/${tab.page}`}
className={tab.page === page ? 'on' : undefined}
>
{tab.label}
</a>
))}
</nav>
<div className="hd-cta">
{place.phone && (
<a className="hd-tel" href={`tel:${place.phone}`}>
<PhoneIcon />
{place.phone}
</a>
)}
{cta}
</div>
<button
type="button"
className="menu"
aria-label="메뉴"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((was) => !was)}
>
<span />
</button>
</div>
<nav className="drawer glass" aria-label="주요 메뉴" hidden={!menuOpen}>
{tabs.map((tab) => (
<a
key={tab.page}
href={tab.page === 'home' ? '#/' : `#/${tab.page}`}
aria-current={tab.page === page ? 'page' : undefined}
>
{tab.label}
</a>
))}
</nav>
</header>
<nav className="tabs" aria-label="페이지">
{tabs.map((tab) => (
<a
key={tab.page}
href={tab.page === 'home' ? '#/' : `#/${tab.page}`}
className={tab.page === page ? 'on' : undefined}
>
{tab.label}
</a>
))}
</nav>
{(Object.keys(PAGE_HEAD) as Exclude<PageId, 'home'>[]).map((id) => (
<div key={id} data-pg={id}>
<section className="wrap pagehead">
<p className="label">{PAGE_HEAD[id].label}</p>
<h2>
{id === 'area'
? (place.addressLocality?.replace(/(시|군)$/, '') ?? PAGE_HEAD.area.title)
: PAGE_HEAD[id].title}
</h2>
<p className="lead">{PAGE_HEAD[id].note}</p>
{id === 'area' && areaPills.length > 0 && (
<div className="pills">
{areaPills.map(([label, href]) => (
<a key={href} className="pill" href={href}>
{label}
</a>
))}
</div>
)}
</section>
</div>
))}
{children}
{channels.length > 0 && (
<div data-pg="stay">
<section className="wrap" id="channels">
<p className="label">CHANNELS</p>
<h2>공식 채널</h2>
<div className="rows">
{channels.map((link) => (
<a key={link.url} className="line" href={link.url} target="_blank" rel="noopener noreferrer">
<span>{channelLabel(link)}</span>
<span className="r">열기 ↗</span>
</a>
))}
</div>
</section>
</div>
)}
<footer>
<div className="wrap">
<p className="fname">{place.name}</p>
{address && <p>{address}</p>}
{hours.length > 0 && <p>{hours.join(' · ')}</p>}
{(place.phone || instagram) && (
<p>
{place.phone && <a href={`tel:${place.phone}`}>{place.phone}</a>}
{place.phone && instagram && ' · '}
{instagram && (
<a href={instagram.url} target="_blank" rel="noopener noreferrer">
instagram
</a>
)}
</p>
)}
{place.legal?.businessRegistrationNumber && (
<p>
{place.legal.representative && `대표 ${place.legal.representative} · `}
사업자등록번호 {place.legal.businessRegistrationNumber}
{place.legal.mailOrderNumber && ` · 통신판매업신고 ${place.legal.mailOrderNumber}`}
</p>
)}
<p className="byline credit">
<span className="author">작성·운영 {place.name}</span> · 최종 업데이트{' '}
<time dateTime={site.updatedAt}>{isoDate(site.updatedAt)}</time>
<br />
<a href="https://www.o2osolution.ai/" target="_blank" rel="noopener noreferrer">
AI O2O
</a>
의 Web4Ai로 만든 사이트입니다.
</p>
</div>
</footer>
{hasSongs && (
<div className={showBar ? 'player glass' : 'player glass low'}>
<SongPlayer />
</div>
)}
{showBar && (
<nav className="tabbar glass" aria-label="연락 · 예약" style={{gridTemplateColumns: columns}}>
{address && (
<a href={naverDirectionsUrl(place.name, place.latitude, place.longitude)} target="_blank" rel="noopener noreferrer nofollow">
<MapIcon />
길찾기
</a>
)}
{kakao && (
<a href={kakao.url} target="_blank" rel="noopener noreferrer">
<TalkIcon />
카톡
</a>
)}
{place.phone && (
<a href={`tel:${place.phone}`}>
<PhoneIcon />
전화
</a>
)}
{cta}
</nav>
)}
{stayBooking && (
<Sheet open={bookingOpen} onClose={() => setBookingOpen(false)} label="예약 요청" plain>
<div className="sform">
<h3>예약 요청하기</h3>
<BookingForm />
</div>
</Sheet>
)}
</div>
);
}

View File

@ -0,0 +1,46 @@
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';
const FIRST = 5;
export function Gallery() {
const payload = useSite();
const images = galleryImages(payload);
const [open, setOpen] = useState<MediaItem | null>(null);
if (images.length === 0) return null;
const shot = (image: MediaItem) => (
<button key={image.mediaId} type="button" onClick={() => setOpen(image)}>
<img src={image.url} alt={image.alt} loading="lazy" />
</button>
);
return (
<div data-pg="home">
<section className="wrap" id="gallery" aria-labelledby="gallery-heading">
<p className="label">GALLERY</p>
<h2 id="gallery-heading">
{sectionName(payload, 'photos', '사진 갤러리')} <span className="cnt">{images.length}장</span>
</h2>
<div className="gal">{images.slice(0, FIRST).map(shot)}</div>
{images.length > FIRST && (
<details className="fold">
<summary className="link">사진 {images.length - FIRST}장 더 보기</summary>
<div className="gal">{images.slice(FIRST).map(shot)}</div>
</details>
)}
</section>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.alt ?? ''}>
{open && (
<>
<img src={open.url} alt={open.alt} />
<h3>{open.alt}</h3>
{open.category && <p className="meta">{open.category}</p>}
</>
)}
</Sheet>
</div>
);
}

View File

@ -0,0 +1,89 @@
import {PlaceCategory} from '@o2o/shared';
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
import {useSite} from '@site/lib/site-context';
import {galleryImages, isSectionEnabled} from '@site/lib/derive';
import {useLiveWeather} from '@site/lib/use-live-weather';
import {Car} from '../paper/Car';
import {MoodIcon, Weather} from './Weather';
const MAX_SLIDES = 5;
export function Hero() {
const payload = useSite();
const {place, narrative} = payload;
const slides = [...payload.media]
.filter((image) => image.alt?.trim())
.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary))
.slice(0, MAX_SLIDES);
const fixed = narrative.tagline ?? narrative.heroSubline;
const lodging = place.category === PlaceCategory.LODGING;
const where = place.addressLocality ?? place.addressRegion;
const sub = [where, fixed].filter(Boolean).join(' · ');
const showWeather = isSectionEnabled(payload, 'weather');
const weather = useLiveWeather({
initial: payload.local.weather,
regionCode: payload.place.regionCode,
latitude: payload.place.latitude,
longitude: payload.place.longitude,
});
const backdrop = galleryImages(payload).find((image) => !image.isPrimary);
return (
<>
<section className="hero" id="top" data-pg="home">
{slides.length > 0 && (
<Car
label={`${place.name} 사진`}
autoplay={6000}
style={{height: '100%'}}
slides={slides.map((image, i) => (
<img
key={image.mediaId}
src={image.url}
alt={image.alt}
fetchPriority={i === 0 ? 'high' : 'low'}
loading={i === 0 ? undefined : 'lazy'}
decoding="async"
/>
))}
/>
)}
<div className="veil" />
<div className="hero-copy">
{lodging ? (
<>
<p className="big">
<HeroCatchphrase />
</p>
<h1 className="nm">{place.name}</h1>
{sub && <p className="sub">{sub}</p>}
</>
) : (
<>
<h1 className="big">{place.name}</h1>
{sub && <p className="sub">{sub}</p>}
</>
)}
</div>
<div className="hero-meta">
{showWeather && weather ? (
<a className="wx-chip glass" href="#weather">
<MoodIcon condition={weather.condition} />
{Math.round(weather.temperature)}° {weather.condition}
</a>
) : (
<span />
)}
<span className="scroll" aria-hidden>
SCROLL
</span>
</div>
</section>
{showWeather && weather && (
<div data-editor-id="weather" style={{display: 'contents'}}>
<Weather weather={weather} backdrop={backdrop} />
</div>
)}
</>
);
}

View File

@ -0,0 +1,82 @@
import {useSite} from '@site/lib/site-context';
import type {InfoRow} from '@site/lib/derive';
import {infoView, ON_VALUES} from '@site/sections/EssentialInfoSection';
import {CheckIcon, CrossIcon} from './icons';
function Row({row}: {row: InfoRow}) {
const long = Boolean(row.note) || row.value.length > 24;
return (
<div className={long ? 'line long' : 'line'}>
<span>{row.label}</span>
<span className="r">
{row.value}
{row.note && ` — ${row.note}`}
</span>
</div>
);
}
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 (
<section className="wrap" id="info" aria-labelledby="info-heading">
<p className="label">INFORMATION</p>
<h2 id="info-heading">이용안내</h2>
<p className="lead">방문 전 확인이 필요한 운영 규정과 시설 안내입니다.</p>
{ruleRows.length > 0 && (
<>
<p className="sub-h">예약 전 확인</p>
<div className="rows">
{[...short, ...long].map((row) => (
<Row key={row.key ?? row.label} row={row} />
))}
</div>
</>
)}
{detailRows.length + sorted.length > 0 && (
<>
<p className="sub-h">시설 · 편의</p>
<div className="rows">
{detailRows.map((row) => (
<Row key={row.key ?? row.label} row={row} />
))}
</div>
{sorted.length > 0 && (
<ul className="amen">
{sorted.map((row) => {
const on = ON_VALUES.has(row.value);
return (
<li key={row.key ?? row.label} className={on ? 'on' : 'off'}>
{on ? <CheckIcon /> : <CrossIcon />}
<span>
<b>{row.label}</b> {row.value}
</span>
</li>
);
})}
</ul>
)}
</>
)}
{notices.length > 0 && (
<div className="faq">
{notices.map((text) => (
<details key={text.slice(0, 32)}>
<summary>예약 공지</summary>
<p style={{whiteSpace: 'pre-line'}}>{text}</p>
</details>
))}
</div>
)}
</section>
);
}

View File

@ -0,0 +1,103 @@
import {useSite} from '@site/lib/site-context';
import {heroFacts, sectionBody} from '@site/lib/derive';
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({publishedAt, body}: {publishedAt: string; body: string}) {
return (
<article className="post">
<time dateTime={publishedAt}>{dotDate(publishedAt)}</time>
<p>{body}</p>
</article>
);
}
const HOME_MAX = 3;
const FULL_FIRST = 5;
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 (
<section className="wrap" id={full ? 'blog' : undefined}>
<p className="label">JOURNAL</p>
<h2>미니 블로그</h2>
<p className="lead">사장님이 남기는 숙소 이야기입니다.</p>
<div className="posts" data-blog={full ? 'full' : 'home'}>
{shown.map((item) => (
<Post key={item.postId} publishedAt={item.publishedAt} body={item.body} />
))}
</div>
{more && (
<a href="#/story" className="link more-link">
이야기에서 지난 소식 더 보기
</a>
)}
{older.length > 0 && (
<details className="fold">
<summary className="link">지난 소식 {older.length}개</summary>
<div className="posts">
{older.map((item) => (
<Post key={item.postId} publishedAt={item.publishedAt} body={item.body} />
))}
</div>
</details>
)}
</section>
);
}
export function Intro() {
const payload = useSite();
const {narrative, place} = payload;
const own = sectionBody(payload, 'intro');
const about = own.length > 0 ? own : narrative.about;
const facts = heroFacts(payload);
return (
<>
<div data-pg="story">
<Blog full />
</div>
<div data-pg="home">
{about.length > 0 && (
<section className="wrap" id="about" aria-labelledby="about-heading">
<p className="label">ABOUT</p>
<h2 id="about-heading">{place.name} 소개</h2>
{narrative.summary && <p className="lead strong">{narrative.summary}</p>}
<p className="lead">{about[0]}</p>
{about.length > 1 && (
<details className="fold">
<summary className="link">더 읽기</summary>
{about.slice(1).map((paragraph, i) => (
<p key={i} className="lead">
{paragraph}
</p>
))}
</details>
)}
{facts.length > 0 && (
<div className="facts">
{facts.map((row) => (
<div key={row.key ?? row.label}>
<small>{row.label}</small>
<b>{row.value}</b>
</div>
))}
</div>
)}
</section>
)}
<Blog />
</div>
</>
);
}

View File

@ -0,0 +1,90 @@
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';
const FIRST = 4;
function stopsOf(item: ItineraryItem): PlannerStop[] {
return item.days?.[0]?.stops ?? item.stops ?? [];
}
export function Itinerary() {
const payload = useSite();
const parsed = sectionItems<ItineraryItem>(payload, 'itinerary');
const [tag, setTag] = useState('');
const [open, setOpen] = useState<ItineraryItem | null>(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) => (
<button key={item.name} type="button" className="course glass" onClick={() => setOpen(item)}>
<b className="nm">{item.name}</b>
<span className="meta">{[item.duration, item.audience].filter(Boolean).join(' · ')}</span>
{item.why && <span className="ds">{item.why}</span>}
<span className="path">
{stopsOf(item)
.slice(0, 4)
.map((stop) => [stop.name.split(' · ').pop(), stop.minutes && `${stop.minutes}분`].filter(Boolean).join(' '))
.join(' → ')}
</span>
</button>
);
return (
<>
<section className="wrap" id="itinerary" aria-labelledby="itinerary-heading">
<p className="label">ITINERARY</p>
<h2 id="itinerary-heading">
{parsed.title || sectionName(payload, 'itinerary', '추천 일정')} <span className="cnt">{items.length}</span>
</h2>
<p className="lead">{parsed.subtitle || '하루를 어떻게 쓸지 미리 짜 두었습니다.'}</p>
{tags.length > 1 && (
<div className="chips" role="tablist">
{['', ...tags].map((t) => (
<button key={t || 'all'} type="button" role="tab" aria-current={t === tag} onClick={() => setTag(t)}>
{t || '전체'} {items.filter((item) => !t || item.duration === t).length}
</button>
))}
</div>
)}
<div className="courses">{shown.slice(0, FIRST).map(card)}</div>
{shown.length > FIRST && (
<details className="fold">
<summary className="link">{shown.length - FIRST}개 더 보기</summary>
<div className="courses">{shown.slice(FIRST).map(card)}</div>
</details>
)}
</section>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
{open && (
<>
<h3>{open.name}</h3>
<p className="meta">{[open.duration, open.audience].filter(Boolean).join(' · ')}</p>
{open.why && <p>{open.why}</p>}
{(open.days?.length ? open.days : [{label: undefined, stops: open.stops}]).map((day, i) => (
<div key={i}>
{day.label && <p className="meta">{day.label}</p>}
<ul className="stops">
{(day.stops ?? []).map((stop, j) => (
<li key={j}>
<b>{stop.name}</b>
{(stop.note || stop.minutes) && (
<span>
{[stop.minutes && `${stop.minutes}분 머뭅니다`, stop.note].filter(Boolean).join(' · ')}
</span>
)}
</li>
))}
</ul>
</div>
))}
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,70 @@
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';
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 (
<section className="wrap" id="location" aria-labelledby="location-heading">
<p className="label">LOCATION</p>
<h2 id="location-heading">오시는 길</h2>
<p className="lead">주소와 주요 거점까지의 이동 시간을 안내합니다.</p>
<div className="loc">
{lat != null && lng != null && (
<iframe
className="map"
title={`${place.name} 위치 지도`}
src={osmEmbedUrl(lat, lng, 16 / 10)}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
)}
<address>{address}</address>
{lot && <p>지번 {lot}</p>}
{parking && <p>주차 {[parking.value, parking.note].filter(Boolean).join(' — ')}</p>}
</div>
{routes.length > 0 && (
<div className="rows">
{routes.map((route) => (
<div key={route.destination} className="line">
<span>{route.destination}</span>
<span className="r">
{[route.byCar && `차 ${route.byCar}`, route.byTransit, route.distanceText].filter(Boolean).join(' · ')}
</span>
</div>
))}
</div>
)}
<div className="btns">
<button type="button" className="btn ghost" onClick={copy}>
{copied ? '복사했습니다' : '주소 복사'}
</button>
<a className="btn ghost" href={naverDirectionsUrl(place.name, lat, lng)} target="_blank" rel="noopener noreferrer nofollow">
네이버 지도 길찾기
</a>
<a className="btn ghost" href={kakaoDirectionsUrl(place.name, lat, lng)} target="_blank" rel="noopener noreferrer nofollow">
카카오맵 길찾기
</a>
</div>
</section>
);
}

View File

@ -0,0 +1,130 @@
import {useState} from 'react';
import {factText, sanitizeUnits} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {sectionName, unitSpec, unitViews, type UnitView} from '@site/lib/derive';
import {Car} from '../paper/Car';
import {BOOK_EVENT} from '../paper/Rooms';
import {Sheet} from '../paper/Sheet';
function splitName(name: string): [string, string | undefined] {
const m = /^(.+?)\s*\((.+)\)\s*$/.exec(name);
return m ? [m[1], m[2]] : [name, undefined];
}
function specRows(unit: UnitView, facts: Parameters<typeof factText>[0]) {
const bed = [factText(facts, 'bed_type'), factText(facts, 'room_size')].filter(Boolean).join(' · ');
const std = factText(facts, 'standard_capacity');
const max = factText(facts, 'max_capacity');
const people = [std && `기준 ${std.replace(/명$/, '인')}`, max && `최대 ${max.replace(/명$/, '인')}`]
.filter(Boolean)
.join(' · ');
const price = unit.priceText ?? unit.prices.map((p) => `${p.label} ${p.value}`).join(' · ');
return [
['침대', bed],
['정원', people],
['요금', price],
].filter(([, value]) => value) as [string, string][];
}
const HOME_UNITS = 4;
const book = () => window.dispatchEvent(new Event(BOOK_EVENT));
export function Rooms() {
const payload = useSite();
const units = unitViews(payload);
const spec = unitSpec(payload);
const raw = new Map(sanitizeUnits(payload.units).map((unit) => [unit.unitId, unit]));
const [open, setOpen] = useState<UnitView | null>(null);
if (units.length === 0) return null;
const rows = (unit: UnitView) => specRows(unit, raw.get(unit.unitId)?.facts ?? []);
const people = (unit: UnitView) => rows(unit).find(([label]) => label === '정원')?.[1];
return (
<>
<div data-pg="home">
<section id="units" aria-labelledby="units-heading">
<div className="wrap">
<p className="label">ROOMS</p>
<h2 id="units-heading">{sectionName(payload, spec.path, `${spec.label} 안내`)}</h2>
<div className="rgrid">
{units.slice(0, HOME_UNITS).map((unit) => (
<a key={unit.unitId} className="rcard" href="#/stay">
{unit.images[0] && <img src={unit.images[0].url} alt={unit.images[0].alt} loading="lazy" />}
<b className="nm">{unit.name}</b>
{people(unit) && <span className="ds">{people(unit)}</span>}
</a>
))}
</div>
<a className="link more" href="#/stay">
이용안내에서 전체 보기
</a>
</div>
</section>
</div>
<div data-pg="stay">
{units.map((unit, i) => {
const [main, sub] = splitName(unit.name);
return (
<section key={unit.unitId} className="unit" id={`unit-${unit.slug}`}>
<div className="wrap">
<p className="label">ROOM {String(i + 1).padStart(2, '0')}</p>
<h2>
{main} {sub && <span className="cnt">{sub}</span>}
</h2>
</div>
{unit.images.length > 0 && (
<div className="unit-car">
<Car
label={`${main} 사진`}
arrows
count
dots
slides={unit.images.map((image) => (
<img key={image.mediaId} src={image.url} alt={image.alt} loading="lazy" />
))}
/>
</div>
)}
<div className="wrap">
{unit.intro && <p className="lead">{unit.intro}</p>}
<dl className="spec">
{rows(unit).map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
<div className="btns">
<button type="button" className="btn ghost" onClick={() => setOpen(unit)}>
{main} 자세히
</button>
<button type="button" className="btn" onClick={book}>
{main} 예약 요청
</button>
</div>
</div>
</section>
);
})}
</div>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
{open && (
<>
{open.images[0] && <img src={open.images[0].url} alt={open.images[0].alt} />}
<h3>{open.name}</h3>
{open.intro && <p>{open.intro}</p>}
{open.rows.map((row) => (
<p key={row.label}>
{row.label} — {row.value}
</p>
))}
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,39 @@
import type {SectionHeadProps} from '@site/lib/layout';
const LABEL: Record<string, string> = {
about: 'ABOUT',
info: 'INFORMATION',
units: 'ROOMS',
booking: 'RESERVATION',
gallery: 'GALLERY',
guide: 'AROUND',
weather: 'WEATHER',
location: 'LOCATION',
faq: 'FAQ',
video: 'FILM',
blog: 'JOURNAL',
festival: 'FESTIVAL',
itinerary: 'COURSE',
story: 'STORY',
rules: 'HOUSE RULES',
event: 'NEWS',
reviews: 'REVIEW',
'postcard-maker': 'POSTCARD',
};
export function SectionHead({id, title, lead, aside}: SectionHeadProps) {
return (
<div className="chead">
{LABEL[id] && <p className="label">{LABEL[id]}</p>}
{aside ? (
<div className="headrow">
<h2 id={`${id}-heading`}>{title}</h2>
{aside}
</div>
) : (
<h2 id={`${id}-heading`}>{title}</h2>
)}
{lead && <p className="lead">{lead}</p>}
</div>
);
}

View File

@ -0,0 +1,201 @@
import {useState, type ReactNode} from 'react';
import type {ChronicleItem, PeopleItem, ReadingItem, SongItem} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {Sheet} from '../paper/Sheet';
import {useWide} from './useWide';
const COUNT_WORD: Record<number, string> = {1: '한', 2: '두', 3: '세', 4: '네'};
function More({rest, unit, children}: {rest: number; unit: string; children: ReactNode}) {
if (rest <= 0) return null;
return (
<details className="fold">
<summary className="link">
{rest}
{unit} 더 보기
</summary>
{children}
</details>
);
}
interface Open {
title: string;
meta?: string;
body?: string;
image?: string;
}
export function Story() {
const payload = useSite();
const region = payload.place.addressLocality?.replace(/시$|군$/, '') ?? '지역';
const songs = sectionItems<SongItem>(payload, 'songs');
const people = sectionItems<PeopleItem>(payload, 'people');
const chronicle = sectionItems<ChronicleItem>(payload, 'chronicle');
const reading = sectionItems<ReadingItem>(payload, 'reading');
const [open, setOpen] = useState<Open | null>(null);
const firstPeople = useWide() ? 4 : 6;
const parts = [
songs.items.length > 0 && '노래',
people.items.length > 0 && '인물',
chronicle.items.length > 0 && '연표',
reading.items.length > 0 && '읽기',
].filter(Boolean) as string[];
if (parts.length === 0) return null;
const peopleSorted = [...people.items].sort((a, b) => Number(Boolean(b.imageUrl)) - Number(Boolean(a.imageUrl)));
const withPhoto = people.items.filter((p) => p.imageUrl).length;
const song = (item: SongItem) => (
<button
key={item.title + item.artist}
type="button"
className="line"
onClick={() =>
setOpen({title: item.title, meta: [item.artist, item.year].filter(Boolean).join(' · '), body: item.story})
}
>
<span>
{item.title} {item.artist && <span className="dim">{item.artist}</span>}
</span>
{item.year && <span className="r">{item.year}</span>}
</button>
);
const person = (item: PeopleItem) => (
<button
key={item.name}
type="button"
className={item.imageUrl ? 'card' : 'card txt'}
onClick={() =>
setOpen({
title: item.name,
meta: [item.role, item.years].filter(Boolean).join(' · '),
body: item.oneLine,
image: item.imageUrl,
})
}
>
{item.imageUrl && <img src={item.imageUrl} alt={item.name} loading="lazy" />}
<b className="nm">{item.name}</b>
{item.role && <span className="ds clamp">{item.role}</span>}
{!item.imageUrl && item.oneLine && <span className="ds clamp3">{item.oneLine}</span>}
</button>
);
const event = (item: ChronicleItem) => (
<button
key={`${item.year}-${item.title}`}
type="button"
className="line"
onClick={() =>
setOpen({
title: item.title,
meta: [item.year, item.place].filter(Boolean).join(' · '),
body: item.summary,
image: item.imageUrl,
})
}
>
<span>
{item.title}
{item.place && <span className="dim blk">{item.place}</span>}
</span>
{item.year && <span className="r">{item.year}</span>}
</button>
);
const book = (item: ReadingItem) => (
<button
key={item.title}
type="button"
className="course glass"
onClick={() => setOpen({title: item.title, meta: item.year ? String(item.year) : item.group, body: item.body})}
>
<b className="nm">
{item.title} {item.year && <span className="dim">{item.year}</span>}
</b>
<span className="ds clamp3">{item.body}</span>
</button>
);
return (
<>
<section className="wrap" id="story" aria-labelledby="story-heading">
<p className="label">STORY</p>
<h2 id="story-heading">
{sectionName(payload, 'story', `${region} 이야기`)} <span className="cnt">{parts.join(' · ')}</span>
</h2>
<p className="lead">
이 도시를 {COUNT_WORD[parts.length] ?? parts.length} 갈래로 봅니다. 하나씩 골라 보세요.
</p>
</section>
{songs.items.length > 0 && (
<section className="wrap sub" id="songs">
<p className="label">MUSIC</p>
<h2>
{songs.title || '가요 다방'} <span className="cnt">{songs.items.length}곡</span>
</h2>
<p className="lead">{region}을 노래한 곡들입니다.</p>
<div className="rows">{songs.items.slice(0, 6).map(song)}</div>
<More rest={songs.items.length - 6} unit="곡">
<div className="rows">{songs.items.slice(6).map(song)}</div>
</More>
</section>
)}
{people.items.length > 0 && (
<section className="wrap sub" id="people">
<p className="label">PEOPLE</p>
<h2>
{people.title || '인물 열전'} <span className="cnt">{people.items.length}명</span>
</h2>
<p className="lead">{region}과 얽힌 사람들입니다.</p>
<div className="grid3">{peopleSorted.slice(0, firstPeople).map(person)}</div>
<More rest={people.items.length - firstPeople} unit="명">
<div className="grid3">{peopleSorted.slice(firstPeople).map(person)}</div>
</More>
{withPhoto > 0 && withPhoto < people.items.length && (
<p className="note">사진이 있는 {withPhoto}명을 먼저 뒀습니다.</p>
)}
</section>
)}
{chronicle.items.length > 0 && (
<section className="wrap sub" id="chronicle">
<p className="label">CHRONICLE</p>
<h2>{chronicle.title || '시간의 골목'}</h2>
<p className="lead">개항부터 지금까지, 이 도시의 연표입니다.</p>
<div className="rows">{chronicle.items.slice(0, 5).map(event)}</div>
<More rest={chronicle.items.length - 5} unit="줄">
<div className="rows">{chronicle.items.slice(5).map(event)}</div>
</More>
</section>
)}
{reading.items.length > 0 && (
<section className="wrap sub" id="reading">
<p className="label">READING</p>
<h2>
{reading.title || `${region} 읽기`} <span className="cnt">{reading.items.length}편</span>
</h2>
<p className="lead">{region}을 배경으로 한 문학입니다.</p>
<div className="courses">{reading.items.slice(0, 3).map(book)}</div>
<More rest={reading.items.length - 3} unit="편">
<div className="courses">{reading.items.slice(3).map(book)}</div>
</More>
</section>
)}
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.title ?? ''}>
{open && (
<>
{open.image && <img src={open.image} alt={open.title} className="sq" />}
<h3>{open.title}</h3>
{open.meta && <p className="meta">{open.meta}</p>}
{open.body && <p>{open.body}</p>}
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,33 @@
import {youtubeId, youtubePoster, type VideoItem} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
export function Video() {
const payload = useSite();
const parsed = sectionItems<VideoItem>(payload, 'video');
const items = parsed.items.filter((item) => youtubeId(item.url));
if (items.length === 0) return null;
return (
<div data-pg="home">
<section className="wrap" id="video" aria-labelledby="video-heading">
<p className="label">FILM</p>
<h2 id="video-heading">
{parsed.title || sectionName(payload, 'video', '영상으로 보기')} <span className="cnt">{items.length}편</span>
</h2>
{parsed.subtitle && <p className="lead">{parsed.subtitle}</p>}
<div className="rail">
{items.map((item) => (
<a key={item.url} className="film" href={item.url} target="_blank" rel="noopener noreferrer">
<span className="poster">
<img src={youtubePoster(youtubeId(item.url)!)} alt="" loading="lazy" />
<span className="play" />
</span>
{item.caption && <span className="cap">{item.caption}</span>}
</a>
))}
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,65 @@
import {Cloud, CloudRain, CloudSnow, Sun} from 'lucide-react';
import type {MediaItem, WeatherSky} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import type {LiveWeather} from '@site/lib/use-live-weather';
import {useWeatherNotes} from '@site/lib/use-weather-note';
import {weatherBand, weatherMood} from '@site/lib/derive';
const MOOD_ICON = {
맑음: Sun,
흐림: Cloud,
비: CloudRain,
눈: CloudSnow,
} as const;
export function MoodIcon({condition}: {condition: string}) {
const Icon = MOOD_ICON[weatherMood(condition)];
return <Icon className="wx-ic" strokeWidth={1.6} aria-hidden />;
}
function observedClock(iso: string | undefined): string | undefined {
const m = /T(\d{2}):(\d{2})/.exec(iso ?? '');
return m ? `${m[1]}:${m[2]} 관측` : undefined;
}
export function Weather({weather, backdrop}: {weather: LiveWeather; backdrop?: MediaItem}) {
const payload = useSite();
const mood = weatherMood(weather.condition);
const band = weatherBand(weather.temperature);
const notes = payload.local.weather;
const condition = weather.condition as WeatherSky;
const skyKey: WeatherSky = notes?.noteSets?.[condition] ? condition : mood;
const [line, bandLine] = useWeatherNotes(
notes?.noteSets?.[skyKey],
notes?.notes?.[mood] ?? weather.note,
notes?.tempNoteSets?.[band],
notes?.tempNotes?.[band],
);
const observed = observedClock(weather.observedAt);
return (
<section className="wrap" id="weather" data-band={band} data-pg="home" aria-labelledby="weather-heading">
<p className="label">WEATHER</p>
<h2 id="weather-heading">오늘의 날씨</h2>
<div className={backdrop ? 'weather' : 'weather bare'}>
{backdrop && <img src={backdrop.url} alt={backdrop.alt} loading="lazy" />}
<div className="card glass">
<div className="t">
<span className="deg">{Math.round(weather.temperature * 10) / 10}°</span>
<span className="cond">{weather.condition}</span>
{observed && <span className="obs">{observed}</span>}
</div>
{line && (
<p key={line} className="note">
{line}
</p>
)}
<p className="band">
<span>{band}</span>
{bandLine && <em key={bandLine}>{bandLine}</em>}
</p>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,298 @@
.w4-cinema{--bg:var(--tpl-bg,#0f0e0c);--ink:var(--tpl-text,#f4efe6);--sub:rgba(244,239,230,.72);--mute:rgba(244,239,230,.5);--line:rgba(244,239,230,.16);--accent:var(--tpl-accent,#e7b77a);--g:20px;--k-bg:#1b1916;--k-ink:var(--ink);--k-sub:var(--sub);--k-line:var(--line);--k-soft:rgba(244,239,230,.06);--k-r:14px;background:var(--bg);color:var(--ink);font:17px/1.65 'Noto Sans KR',system-ui,sans-serif;letter-spacing:-.01em;-webkit-font-smoothing:antialiased;word-break:keep-all;padding-bottom:90px;min-height:100svh;overflow-x:clip}
.w4-cinema img{display:block;width:100%;height:100%;object-fit:cover}
.w4-cinema a{color:inherit;text-decoration:none}
.w4-cinema button{font:inherit;color:inherit}
.w4-cinema h2,.w4-cinema h3,.w4-cinema p{margin:0}
.w4-cinema .wrap{max-width:1120px;margin:0 auto;padding-left:var(--g);padding-right:var(--g)}
.w4-cinema .glass{background:rgba(20,18,15,.38);backdrop-filter:blur(18px) saturate(1.2);-webkit-backdrop-filter:blur(18px) saturate(1.2);border:1px solid rgba(255,255,255,.12)}
.w4-cinema .top{position:fixed;top:0;left:0;right:0;z-index:30;transition:background .35s}
.w4-cinema .top.solid{background:rgba(15,14,12,.86);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px)}
.w4-cinema .top .wrap{height:60px;display:flex;align-items:center;justify-content:space-between}
.w4-cinema .logo{font-weight:800;font-size:19px;letter-spacing:.12em}
.w4-cinema .menu{width:44px;height:44px;border:0;background:none;display:grid;place-items:center;cursor:pointer}
.w4-cinema .menu span{display:block;width:22px;height:12px;border-top:1.5px solid var(--ink);border-bottom:1.5px solid var(--ink)}
.w4-cinema .drawer{margin:0 12px;border-radius:18px;padding:8px;display:grid}
.w4-cinema .drawer[hidden]{display:none}
.w4-cinema .drawer a{padding:14px 16px;border-radius:12px;font-size:17px;font-weight:600}
.w4-cinema .drawer a[aria-current]{background:rgba(255,255,255,.1);color:var(--accent)}
.w4-cinema .tabs{display:none}
.w4-cinema:not([data-page=home]) .pagehead{padding-top:120px}
.w4-cinema .label{font-size:12px;letter-spacing:.24em;color:var(--accent);font-weight:600;margin-bottom:8px;text-transform:uppercase}
.w4-cinema{--sec:56px}
.w4-cinema section{padding-top:var(--sec)}
.w4-cinema section.sub{padding-top:40px}
.w4-cinema section h2{font-size:32px;font-weight:700;line-height:1.35;letter-spacing:-.02em}
.w4-cinema section h2 .cnt{font-size:15px;font-weight:500;color:var(--mute);margin-left:8px;letter-spacing:0}
.w4-cinema .lead{margin-top:16px;color:var(--sub);font-size:17px;line-height:1.8}
.w4-cinema .sub-h{margin-top:28px;font-size:13px;letter-spacing:.2em;color:var(--mute)}
.w4-cinema .pills,.w4-cinema .chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:22px}
.w4-cinema .pill,.w4-cinema .chips button{border:1px solid var(--line);border-radius:999px;padding:8px 16px;font-size:15px;background:none;cursor:pointer}
.w4-cinema .chips button[aria-pressed=true],.w4-cinema .chips button[aria-current=true]{border-color:var(--accent);color:var(--accent)}
.w4-cinema .hero{position:relative;height:100svh;min-height:620px;padding:0;overflow:hidden;background:#000}
.w4-cinema .hero .car,.w4-cinema .hero .vp,.w4-cinema .hero .track,.w4-cinema .hero .slide{height:100%}
.w4-cinema .hero .slide img{animation:w4c-zoom 9s ease-out both}
@keyframes w4c-zoom{from{transform:scale(1.08)}to{transform:scale(1)}}
.w4-cinema .veil{position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,.35),rgba(0,0,0,.05) 35%,rgba(0,0,0,.2) 60%,var(--bg));pointer-events:none}
.w4-cinema .hero-copy{position:absolute;left:0;right:0;top:50%;transform:translateY(-50%);text-align:center;padding:0 var(--g);z-index:2}
.w4-cinema .hero-copy .big{font-size:40px;font-weight:800;line-height:1.3;letter-spacing:-.01em;text-shadow:0 2px 24px rgba(0,0,0,.25);min-height:2.6em}
.w4-cinema .hero-copy .nm{margin:18px 0 0;font-size:14px;letter-spacing:.24em;font-weight:600;opacity:.9}
.w4-cinema .hero-copy .sub{margin-top:10px;font-size:16px;color:var(--sub);font-weight:500}
.w4-cinema .hero-meta{position:absolute;left:var(--g);right:var(--g);bottom:36px;z-index:2;display:flex;justify-content:space-between;align-items:flex-end;font-size:14px;color:var(--sub)}
.w4-cinema .wx-chip{border-radius:999px;padding:8px 14px 8px 10px;display:inline-flex;gap:8px;align-items:center;color:var(--ink);font-weight:600}
.w4-cinema .wx-ic{width:18px;height:18px}
.w4-cinema .scroll{writing-mode:vertical-rl;letter-spacing:.2em;font-size:11px}
.w4-cinema .weather{position:relative;border-radius:24px;overflow:hidden;min-height:420px;margin-top:28px;background:linear-gradient(160deg,#2a2620,#14120f)}
.w4-cinema .weather>img{position:absolute;inset:0}
.w4-cinema .weather .card{position:absolute;left:14px;right:14px;bottom:14px;border-radius:18px;padding:22px}
.w4-cinema .weather .t{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
.w4-cinema .weather .deg{font-size:56px;font-weight:300;letter-spacing:-.04em;line-height:1}
.w4-cinema .weather .cond{font-size:18px;font-weight:600}
.w4-cinema .weather .obs{font-size:13px;color:var(--mute);margin-left:auto}
.w4-cinema .weather .note{margin-top:14px;font-size:17px;line-height:1.7}
.w4-cinema .weather .band{margin-top:12px;font-size:14px;color:var(--sub);display:flex;gap:8px;align-items:center}
.w4-cinema .weather .band em{font-style:normal;border:1px solid var(--line);border-radius:999px;padding:2px 10px;color:var(--accent);font-weight:600}
.w4-cinema .link{display:inline-flex;gap:8px;align-items:center;font-weight:600;font-size:15px;background:none;border:0;padding:0;cursor:pointer;color:var(--ink)}
.w4-cinema .link:after{content:'';width:18px;height:1px;background:currentColor}
.w4-cinema .headrow{display:flex;justify-content:space-between;align-items:flex-end;gap:12px}
.w4-cinema .unit .unit-car{margin-top:24px}
.w4-cinema .car .vp{border-radius:18px}
.w4-cinema .unit .slide img{aspect-ratio:4/3;height:auto}
.w4-cinema .hero .slide img{height:100%}
.w4-cinema .car .arrow{position:absolute;top:50%;transform:translateY(-50%);width:40px;height:40px;border:0;border-radius:50%;background:rgba(20,18,15,.55);backdrop-filter:blur(10px);display:grid;place-items:center;cursor:pointer;font-size:18px}
.w4-cinema .car .prev{left:10px}
.w4-cinema .car .next{right:10px}
.w4-cinema .car .count{position:absolute;right:12px;bottom:12px;padding:3px 10px;border-radius:999px;background:rgba(0,0,0,.55);font-size:13px;letter-spacing:.08em}
.w4-cinema .spec{margin:22px var(--g) 0;display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line)}
.w4-cinema .spec div{padding:16px 0;border-bottom:1px solid var(--line)}
.w4-cinema .spec div:nth-child(odd){padding-right:12px;border-right:1px solid var(--line)}
.w4-cinema .spec div:nth-child(even){padding-left:16px}
.w4-cinema .spec dt{font-size:13px;color:var(--mute)}
.w4-cinema .spec dd{margin:0;font-size:17px;font-weight:600}
.w4-cinema .btns{display:flex;gap:8px;flex-wrap:wrap;margin:24px var(--g) 0}
.w4-cinema section.wrap .btns{margin-left:0;margin-right:0}
.w4-cinema .btn{flex:1;display:inline-flex;align-items:center;justify-content:center;min-height:52px;padding:0 24px;border-radius:999px;border:0;background:var(--ink);color:var(--bg);font-weight:700;font-size:16px;cursor:pointer}
.w4-cinema .btn.ghost{background:transparent;color:var(--ink);border:1px solid var(--line)}
.w4-cinema .rows{margin-top:24px;border-top:1px solid var(--line)}
.w4-cinema .line{display:flex;justify-content:space-between;gap:12px;width:100%;padding:18px 0;border:0;border-bottom:1px solid var(--line);background:none;text-align:left;font-size:17px;cursor:pointer}
.w4-cinema .line .r{color:var(--mute);font-size:15px}
.w4-cinema .amen{list-style:none;margin:28px 0 0;padding:0;display:grid;grid-template-columns:1fr 1fr;gap:10px}
.w4-cinema .amen li{display:flex;gap:10px;align-items:center;padding:14px;border-radius:14px;background:rgba(244,239,230,.05);font-size:15px;color:var(--mute)}
.w4-cinema .amen li.on{color:var(--ink)}
.w4-cinema .amen svg{width:22px;height:22px;flex:none;color:var(--accent)}
.w4-cinema .amen b{font-weight:500}
.w4-cinema .book{position:relative;margin:var(--sec) calc(var(--g)*-1) 0;min-height:70svh;padding:0;overflow:hidden;display:flex}
.w4-cinema .book>img{position:absolute;inset:0}
.w4-cinema .book:after{content:'';position:absolute;inset:0;background:rgba(0,0,0,.45)}
.w4-cinema .book .bd{position:relative;z-index:1;flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:48px var(--g);gap:14px}
.w4-cinema .book h2{font-size:34px}
.w4-cinema .book p{color:var(--sub)}
.w4-cinema .book .btn{flex:none;margin-top:14px}
.w4-cinema .gal{margin:28px calc(var(--g)*-1) 0;display:flex;gap:10px;overflow-x:auto;padding:0 var(--g);scroll-snap-type:x mandatory;scrollbar-width:none}
.w4-cinema .gal::-webkit-scrollbar{display:none}
.w4-cinema .gal button{flex:0 0 78%;aspect-ratio:3/4;border-radius:18px;overflow:hidden;scroll-snap-align:center;border:0;padding:0;background:#1b1916;cursor:pointer}
.w4-cinema .loc{margin-top:24px}
.w4-cinema .loc .map{display:block;width:100%;aspect-ratio:16/10;border:0;border-radius:18px;filter:grayscale(.6) invert(.9) hue-rotate(180deg)}
.w4-cinema .loc address{margin-top:16px;font-style:normal;font-size:18px;font-weight:600}
.w4-cinema .loc p{color:var(--sub);font-size:15px}
.w4-cinema .rail{display:flex;gap:10px;overflow-x:auto;margin:20px calc(var(--g)*-1) 0;padding:0 var(--g);scrollbar-width:none}
.w4-cinema .rail::-webkit-scrollbar{display:none}
.w4-cinema .film{display:flex;flex-direction:column;gap:8px}
.w4-cinema .film .poster{position:relative;display:block}
.w4-cinema .film .cap{font-size:14px;color:var(--sub)}
.w4-cinema .card{flex:0 0 72%;display:flex;flex-direction:column;gap:4px;padding:18px;border-radius:18px;border:1px solid var(--line);background:rgba(244,239,230,.04);text-align:left;cursor:pointer}
.w4-cinema .card .nm{font-size:17px;font-weight:600}
.w4-cinema .card .ds{font-size:14px;color:var(--sub);line-height:1.5}
.w4-cinema .guide{margin-top:24px;border-top:1px solid var(--line)}
.w4-cinema .place{display:grid;grid-template-columns:96px 1fr;gap:16px;width:100%;padding:18px 0;border:0;border-bottom:1px solid var(--line);background:none;text-align:left;align-items:center;cursor:pointer}
.w4-cinema .place .ph{display:contents}
.w4-cinema .place img{grid-row:span 2;width:96px;height:96px;border-radius:12px}
.w4-cinema .place small{align-self:end;font-size:13px;color:var(--accent)}
.w4-cinema .place .nm{align-self:start;font-size:18px;font-weight:600}
.w4-cinema .place.noph{grid-template-columns:1fr}
.w4-cinema .fold{margin-top:6px}
.w4-cinema .fold:not([open])>:not(summary){display:none}
.w4-cinema .fold summary.link{padding:16px 0}
.w4-cinema .courses{display:grid;gap:10px;margin-top:22px}
.w4-cinema .course{display:flex;flex-direction:column;gap:4px;padding:18px 20px;border-radius:18px;text-align:left;cursor:pointer}
.w4-cinema .course .dim{font-size:13px;color:var(--accent)}
.w4-cinema .course .ds{font-size:15px;color:var(--sub)}
.w4-cinema .clamp{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.w4-cinema .grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:20px}
.w4-cinema .grid3 .card{flex:auto;padding:10px;gap:6px}
.w4-cinema .noimg{display:grid;place-items:center;padding:12px;text-align:center;font-weight:600;color:var(--mute);border-radius:12px;background:linear-gradient(160deg,#2a2620,#1b1916)}
.w4-cinema .dim{color:var(--mute)}
.w4-cinema .blk{display:block}
.w4-cinema .post,.w4-cinema .posts{display:grid;gap:12px}
.w4-cinema .faq{margin-top:24px;border-top:1px solid var(--line)}
.w4-cinema .faq details{border-bottom:1px solid var(--line)}
.w4-cinema .faq summary{display:flex;justify-content:space-between;gap:12px;padding:20px 0;font-size:17px;font-weight:500}
.w4-cinema .faq summary:after{content:'+';color:var(--accent);font-size:22px;line-height:1}
.w4-cinema .faq details[open] summary:after{content:'−'}
.w4-cinema .faq details p{padding:0 0 20px;color:var(--sub);font-size:16px}
.w4-cinema footer{padding:72px 0 32px;color:var(--mute);font-size:13px;letter-spacing:.02em}
.w4-cinema footer p{margin-top:6px}
.w4-cinema footer .fname{font-size:19px;font-weight:800;letter-spacing:.12em;color:var(--ink);margin-bottom:10px}
.w4-cinema .player{position:fixed;left:12px;bottom:88px;z-index:30;border-radius:999px;padding:2px 6px 2px 12px}
.w4-cinema .player.low{bottom:16px}
.w4-cinema #w4d-mini{display:flex;align-items:center;gap:2px}
.w4-cinema #w4d-tape{width:24px;height:24px;margin-right:6px;color:var(--accent)}
.w4-cinema #w4d-tape svg{width:100%;height:100%}
.w4-cinema #w4d-now{max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:600}
.w4-cinema #w4d-mini button{width:38px;height:38px;padding:9px;border:0;background:none;color:var(--ink);cursor:pointer}
.w4-cinema #w4d-mini button svg{width:100%;height:100%}
.w4-cinema #w4d-panel{background:#1b1916;border:1px solid var(--line);border-radius:18px;color:var(--ink)}
.w4-cinema .tabbar{position:fixed;left:12px;right:12px;bottom:max(12px,env(safe-area-inset-bottom));z-index:30;display:grid;border-radius:20px;padding:6px;gap:4px}
.w4-cinema .tabbar>a,.w4-cinema .tabbar>button{height:50px;border-radius:14px;border:0;background:none;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;font-size:11px;color:var(--sub);font-weight:500;cursor:pointer}
.w4-cinema .tabbar svg{width:20px;height:20px}
.w4-cinema .tabbar .cta{background:var(--ink);color:var(--bg);font-size:16px;font-weight:700}
.w4-cinema #postcard-maker,.w4-cinema #reviews{background:transparent;border-color:var(--line);--section-space:var(--sec)}
.w4-cinema .byline a{text-decoration:underline}
@media (prefers-reduced-motion:reduce){.w4-cinema .hero .slide img{animation:none}}
.w4-cinema .place small,.w4-cinema .place .nm{display:block}
.w4-cinema .rgrid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:24px}
.w4-cinema .rcard{display:flex;flex-direction:column;gap:2px;min-width:0}
.w4-cinema .rcard img{aspect-ratio:3/2;height:auto;border-radius:14px;margin-bottom:8px}
.w4-cinema .rcard .nm,.w4-cinema .rcard .ds{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.w4-cinema .rcard .nm{font-size:17px;font-weight:600;line-height:1.4}
.w4-cinema .rcard .ds{font-size:14px;color:var(--sub)}
.w4-cinema .link.more{margin-top:20px}
.w4-cinema .grid3 .card img{aspect-ratio:1;height:auto;border-radius:10px}
.w4-cinema #around .grid3 .card img{aspect-ratio:3/2}
.w4-cinema .grid3 .card .nm{font-size:15px;line-height:1.4}
.w4-cinema .grid3 .card .ds{font-size:13px}
.w4-cinema .grid3 .card.txt{padding:14px 12px}
.w4-cinema .rail .card img,.w4-cinema .rail .card .noimg{aspect-ratio:3/2;height:auto;border-radius:12px;margin-bottom:8px}
.w4-cinema .place .ds{margin-top:4px;font-size:15px;line-height:1.55;color:var(--sub)}
.w4-cinema .clamp3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
@media (min-width:700px){.w4-cinema{--sec:64px;--side:max(0px,calc((100vw - 640px) / 2));max-width:640px;margin-left:auto;margin-right:auto;box-shadow:0 0 0 1px rgba(0,0,0,.06)}.w4-cinema .top{left:var(--side);right:var(--side)}.w4-cinema .player{left:calc(var(--side) + 12px)}.w4-cinema .tabbar{left:calc(var(--side) + 12px);right:calc(var(--side) + 12px)}}
.w4-cinema .dnav,.w4-cinema .hd-cta{display:none}
@media(min-width:1024px){.w4-cinema{--g:32px;--side:0px;max-width:none;margin:0;box-shadow:none;padding-bottom:0}
.w4-cinema .wrap{max-width:1144px}
.w4-cinema .top,.w4-cinema .top.solid{background:#0f0e0ce6;backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1px solid var(--line)}
.w4-cinema .top .wrap{height:68px;gap:28px;justify-content:flex-start}
.w4-cinema .menu,.w4-cinema .drawer{display:none}
.w4-cinema .dnav{display:flex;gap:4px;flex:1;min-width:0}
.w4-cinema .dnav a{padding:8px 14px;border-radius:999px;font-size:15px;font-weight:500;color:var(--sub)}
.w4-cinema .dnav a.on{color:var(--ink);background:#f4efe614}
.w4-cinema .hd-cta{display:flex;align-items:center;gap:8px;flex:none}
.w4-cinema .hd-cta>a,.w4-cinema .hd-cta>button{display:inline-flex;align-items:center;gap:6px;height:40px;padding:0 18px;border-radius:999px;border:1px solid var(--line);background:none;font-size:15px;font-weight:600;cursor:pointer}
.w4-cinema .hd-cta svg{width:16px;height:16px}
.w4-cinema .hd-cta .cta{background:var(--ink);color:var(--bg);border-color:transparent;font-weight:700}
.w4-cinema .tabbar{display:none}
.w4-cinema .player,.w4-cinema .player.low{left:auto;right:24px;bottom:24px}
.w4-cinema:not([data-page=home]) .pagehead{padding-top:100px}
.w4-cinema .pagehead:has(.pills){display:grid;grid-template-columns:minmax(0,1fr) minmax(0,520px);column-gap:48px;align-items:end}
.w4-cinema .pagehead:has(.pills)>:not(.pills){grid-column:1}
.w4-cinema .pagehead .pills{grid-column:2;grid-row:1/span 3;margin:0;justify-content:flex-end}
.w4-cinema .fold summary.link{padding:12px 0}
.w4-cinema .lead{font-size:17px}
.w4-cinema .hero{width:min(1080px,calc(100% - 2 * var(--g)));height:520px;min-height:0;margin:88px auto 0;border-radius:20px}
.w4-cinema .hero-copy .big{font-size:44px;min-height:0}
.w4-cinema .hero-meta{bottom:24px;left:28px;right:28px}
.w4-cinema .weather{min-height:220px;margin-top:24px}
.w4-cinema .weather .deg{font-size:48px}
.w4-cinema main{display:grid;grid-template-columns:minmax(var(--g),1fr) repeat(12,minmax(0,90px)) minmax(var(--g),1fr);align-items:start}
.w4-cinema main>*,.w4-cinema main>*>*{grid-column:1/-1;min-width:0}
.w4-cinema .rgrid,.w4-cinema .grid3{grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}
.w4-cinema #around .grid3{grid-template-columns:repeat(3,minmax(0,1fr))}
.w4-cinema .book{width:auto;max-width:none;margin:calc(var(--sec) - 32px) 0 0;min-height:0;height:100%;border-radius:20px}
.w4-cinema .book .bd{padding:32px;justify-content:flex-start}
.w4-cinema .book h2{font-size:28px}
.w4-cinema[data-page=home] main:has(>[data-editor-id=weather]>#weather):has(>[data-editor-id=intro]>[data-pg=home]>section)>[data-editor-id=weather]>#weather{grid-column:2/9;padding-left:0;padding-right:24px;max-width:none;margin:0}
.w4-cinema[data-page=home] main:has(>[data-editor-id=weather]>#weather):has(>[data-editor-id=intro]>[data-pg=home]>section)>[data-editor-id=intro]>[data-pg=home]{grid-column:9/14;padding-left:24px}
.w4-cinema[data-page=home] main:has(>[data-editor-id=video]>[data-pg=home]):has(>[data-editor-id=photos]>[data-pg=home])>[data-editor-id=video]>[data-pg=home]{grid-column:2/6;padding-right:24px}
.w4-cinema[data-page=home] main:has(>[data-editor-id=video]>[data-pg=home]):has(>[data-editor-id=photos]>[data-pg=home])>[data-editor-id=photos]>[data-pg=home]{grid-column:6/14;padding-left:24px}
.w4-cinema[data-page=home] main:has(>[data-editor-id=booking]>[data-pg=home]):has(>[data-editor-id=local]>[data-pg=home])>:is([data-editor-id=booking],[data-editor-id=local])>[data-pg=home]{order:1;align-self:stretch}
.w4-cinema[data-page=home] main:has(>[data-editor-id=booking]>[data-pg=home]):has(>[data-editor-id=local]>[data-pg=home])>[data-editor-id=booking]>[data-pg=home]{grid-column:2/6;padding-right:24px;display:flex;flex-direction:column}
.w4-cinema[data-page=home] main:has(>[data-editor-id=booking]>[data-pg=home]):has(>[data-editor-id=local]>[data-pg=home])>[data-editor-id=local]>[data-pg=home]{grid-column:6/14;padding-left:24px}
.w4-cinema main [data-pg] :is(section.wrap,.wrap){min-width:0}
.w4-cinema main>*>section.wrap{width:100%}
.w4-cinema[data-page=home] main>:is([data-editor-id=intro],[data-editor-id=video],[data-editor-id=photos],[data-editor-id=booking],[data-editor-id=local])>[data-pg=home]:not([style]) section.wrap{padding-left:0;padding-right:0}
.w4-cinema .gal{margin:24px 0 0;padding:0;overflow:visible}
.w4-cinema #gallery>.gal{display:grid;grid-template-columns:2fr 1fr 1fr;grid-template-rows:repeat(2,130px);gap:10px}
.w4-cinema #gallery>.gal button{aspect-ratio:auto;border-radius:14px}
.w4-cinema #gallery>.gal button:first-child{grid-row:1/span 2}
.w4-cinema .fold .gal{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:0}
.w4-cinema .fold .gal button{aspect-ratio:3/2;border-radius:12px}
.w4-cinema .rail{margin:20px 0 0;padding:0}
.w4-cinema #video .rail .film{flex:0 0 100%}
.w4-cinema #video .poster img{aspect-ratio:16/9;height:auto;border-radius:14px}
.w4-cinema .rail .card{flex:0 0 224px}
.w4-cinema #walk .guide{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;border:0;margin-top:24px}
.w4-cinema #walk .place{display:flex;flex-direction:column;align-items:stretch;gap:8px;padding:0;border:0}
.w4-cinema #walk .place img{width:100%;height:auto;aspect-ratio:3/2;border-radius:12px}
.w4-cinema #walk .place.noph{padding:14px;border:1px solid var(--line);border-radius:12px}
.w4-cinema #eat .guide,.w4-cinema #eat .fold[open]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:48px}
.w4-cinema #eat .fold[open]>summary,.w4-cinema #eat .fold .guide{grid-column:1/-1}
.w4-cinema #eat .place img{width:72px;height:72px}
.w4-cinema #eat .place{grid-template-columns:72px 1fr;padding:12px 0}
.w4-cinema[data-page=area] main:has(>[data-editor-id=story]>#people):has(>[data-editor-id=story]>#chronicle)>[data-editor-id=story]>:is(#songs,#chronicle){grid-column:2/8;padding-left:0;padding-right:24px;margin:0;max-width:none}
.w4-cinema[data-page=area] main:has(>[data-editor-id=story]>#people):has(>[data-editor-id=story]>#chronicle)>[data-editor-id=story]>:is(#people,#reading){grid-column:8/14;padding-left:24px;padding-right:0;margin:0;max-width:none}
.w4-cinema .line{padding:14px 0}
.w4-cinema .line .dim.blk{display:inline;margin-left:8px}
.w4-cinema #reading .course .clamp3{-webkit-line-clamp:2}
.w4-cinema #people .grid3{grid-template-columns:repeat(4,minmax(0,1fr))}
.w4-cinema #reading .courses{grid-template-columns:repeat(2,minmax(0,1fr))}
.w4-cinema[data-page=stay] main>[data-editor-id=rooms]>[data-pg=stay]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));column-gap:16px;max-width:1144px;width:100%;box-sizing:border-box;margin:0 auto;padding:0 var(--g)}
.w4-cinema .unit{min-width:0}
.w4-cinema .unit .wrap{padding:0}
.w4-cinema .unit .unit-car{margin-top:20px}
.w4-cinema .unit .slide img{aspect-ratio:3/2}
.w4-cinema .unit h2{font-size:24px}
.w4-cinema .unit .spec{margin:16px 0 0;grid-template-columns:1fr}
.w4-cinema .unit .spec div:nth-child(odd){border-right:0;padding-right:0}
.w4-cinema .unit .spec div:nth-child(2n){padding-left:0}
.w4-cinema .unit .btns{display:grid;gap:8px;margin-top:16px}
.w4-cinema .unit .btn{min-height:44px;font-size:15px}
.w4-cinema .amen{grid-template-columns:repeat(4,minmax(0,1fr))}
.w4-cinema[data-page=stay] main:has(>[data-editor-id=info]>#info):has(>[data-editor-id=booking]>[data-pg=stay])>[data-editor-id=info]>#info{grid-column:2/9;padding-left:0;padding-right:24px;max-width:none;margin:0}
.w4-cinema[data-page=stay] main:has(>[data-editor-id=info]>#info):has(>[data-editor-id=booking]>[data-pg=stay])>[data-editor-id=booking]>[data-pg=stay]{grid-column:9/14;padding-left:24px}
.w4-cinema[data-page=stay] main:has(>[data-editor-id=info]>#info):has(>[data-editor-id=booking]>[data-pg=stay])>[data-editor-id=booking]>[data-pg=stay]>.wrap{padding:var(--sec) 0 0;max-width:none}
.w4-cinema[data-page=stay] main:has(>[data-editor-id=info]>#info) #info .amen{grid-template-columns:repeat(3,minmax(0,1fr))}
.w4-cinema[data-page=stay] main:has(>[data-editor-id=info]>#info) #booking .btns{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}
.w4-cinema[data-page=stay] main:has(>[data-editor-id=info]>#info) #booking .btn{min-height:48px;padding:0 12px;font-size:15px}
.w4-cinema #location{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(0,1fr);grid-template-rows:auto auto auto repeat(5,auto) 1fr;column-gap:40px;align-items:start}
.w4-cinema #location>:not(.loc){grid-column:1/-1}
.w4-cinema #location>.loc{display:contents}
.w4-cinema #location .map{grid-column:1;grid-row:4/-1;aspect-ratio:auto;height:300px;margin-top:24px}
.w4-cinema #location .loc>:not(.map),.w4-cinema #location>.btns{grid-column:2}
.w4-cinema #location address{margin-top:24px}
.w4-cinema #location>.btns{flex-direction:column;margin-top:20px}
.w4-cinema #location>.btns .btn{min-height:44px;font-size:15px}
.w4-cinema #channels .rows{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:48px}
.w4-cinema #faq>.faq,.w4-cinema #faq .fold[open]>.faq{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:48px;align-items:start}
.w4-cinema[data-page=story] main:has(>#postcard-maker)>#postcard-maker{grid-column:2/8;margin-right:12px;border-radius:20px}
.w4-cinema[data-page=story] main:has(>#postcard-maker)>#reviews{grid-column:8/14;margin-left:12px;border-radius:20px;align-self:stretch}
.w4-cinema[data-page=story] #postcard-maker .grid{grid-template-columns:minmax(0,300px) minmax(0,1fr)}
.w4-cinema #blog{display:grid;grid-template-columns:minmax(0,5fr) minmax(0,7fr);column-gap:48px;align-items:start}
.w4-cinema #blog>:is(.label,h2,.lead){grid-column:1}
.w4-cinema #blog>:is(.posts,.fold){grid-column:2}
.w4-cinema #blog>.posts{grid-row:1/span 3}
.w4-cinema #blog>.fold{grid-row:4;margin-top:0}
.w4-cinema #blog .fold[open]>.posts{margin-top:8px}
.w4-cinema[data-page=story] main>:is(#postcard-maker,#reviews){border:0}
.w4-cinema[data-page=story] main>:is(#postcard-maker,#reviews)>.shell{padding-left:28px;padding-right:28px;max-width:none}
.w4-cinema #blog .posts{grid-template-columns:1fr}
.w4-cinema footer{padding:48px 0 32px}
.w4-cinema :is(#eat,#walk,#gallery,#faq) .fold:not([open])>:not(summary){display:none}}
@media(min-width:1024px){.w4-cinema{--edge:max(32px,calc((100vw - 1080px) / 2))}
.w4-cinema .hero{width:100%;height:min(640px,100svh);margin:0;border-radius:0}
.w4-cinema .hero-meta{left:var(--edge);right:var(--edge);bottom:32px}
.w4-cinema .hero-copy{padding-top:68px}
.w4-cinema .hero-copy .big{font-size:48px}}
@media(min-width:1024px){.w4-cinema .dnav{gap:28px;align-self:stretch;margin-left:20px}
.w4-cinema .dnav a{display:flex;align-items:center;padding:0;border-radius:0;background:none}
.w4-cinema .dnav a.on{background:none;box-shadow:inset 0 -2px var(--ink)}
.w4-cinema .rgrid{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.w4-cinema[data-page=stay] main>[data-editor-id=rooms]>[data-pg=stay]{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}
.w4-cinema .pagehead:has(.pills){display:block}
.w4-cinema .pagehead .pills{justify-content:flex-start;margin-top:20px}
.w4-cinema[data-page=story] main:has(>#postcard-maker)>:is(#postcard-maker,#reviews){grid-column:2/14;margin:0 0 24px}}
.w4-cinema .place.noph,.w4-cinema #eat .place.noph{grid-template-columns:minmax(0,1fr)}
@media(max-width:1023px){.w4-cinema :is(#booking,#location) .btns{display:grid;grid-template-columns:1fr 1fr}
.w4-cinema :is(#booking,#location) .btns>:only-child,.w4-cinema :is(#booking,#location) .btns>:last-child:nth-child(odd){grid-column:1/-1}
.w4-cinema :is(#booking,#location,.unit) .btns .btn{white-space:nowrap;min-width:0;padding-left:12px;padding-right:12px}
.w4-cinema .unit .btns{grid-template-columns:1fr 1fr}}
.w4-cinema .wrap>.label:has(+h2),.w4-cinema .book .label,.w4-cinema .rhead>.kicker{display:none}
.w4-cinema :is(h1,h2,.big,.hl){font-family:var(--tpl-font-heading)}

View File

@ -0,0 +1,50 @@
const base = {
viewBox: '0 0 24 24',
width: 20,
height: 20,
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.6,
'aria-hidden': true,
} as const;
export function MapIcon() {
return (
<svg {...base}>
<path d="M12 21s-7-6.2-7-11.5A7 7 0 0 1 19 9.5C19 14.8 12 21 12 21z" />
<circle cx="12" cy="9.5" r="2.5" />
</svg>
);
}
export function TalkIcon() {
return (
<svg {...base}>
<path d="M12 4C7 4 3 7.1 3 11c0 2.5 1.7 4.7 4.2 5.9L6.5 20l3.6-2.3c.6.1 1.2.1 1.9.1 5 0 9-3.1 9-6.9S17 4 12 4z" />
</svg>
);
}
export function PhoneIcon() {
return (
<svg {...base}>
<path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2" />
</svg>
);
}
export function CheckIcon() {
return (
<svg {...base} width={16} height={16} strokeWidth={2}>
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
);
}
export function CrossIcon() {
return (
<svg {...base} width={16} height={16} strokeWidth={2}>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
);
}

View File

@ -0,0 +1,45 @@
import type {Layout} from '@site/lib/layout';
import {StoryTail} from '../paper/StoryTail';
import {Around} from './Around';
import {Booking} from './Booking';
import {Faq} from './Faq';
import {Festival} from './Festival';
import {Frame} from './Frame';
import {Gallery} from './Gallery';
import {Hero} from './Hero';
import {Info} from './Info';
import {Intro} from './Intro';
import {Itinerary} from './Itinerary';
import {Location} from './Location';
import {Rooms} from './Rooms';
import {SectionHead} from './SectionHead';
import {Story} from './Story';
import {Video} from './Video';
const Nothing = () => null;
export const layout: Layout = {
Frame,
SectionHead,
sections: {
hero: Hero,
weather: Nothing,
intro: Intro,
blog: Nothing,
rooms: Rooms,
menu: Rooms,
programs: Rooms,
photos: Gallery,
video: Video,
local: Around,
booking: Booking,
info: Info,
map: Location,
faq: Faq,
festival: Festival,
itinerary: Itinerary,
story: Story,
reviews: StoryTail,
postcardMaker: Nothing,
},
};

View File

@ -0,0 +1,13 @@
import {useEffect, useState} from 'react';
export function useWide(): boolean {
const [wide, setWide] = useState(false);
useEffect(() => {
const query = window.matchMedia('(min-width: 1024px)');
const sync = () => setWide(query.matches);
sync();
query.addEventListener('change', sync);
return () => query.removeEventListener('change', sync);
}, []);
return wide;
}

View File

@ -0,0 +1,172 @@
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 {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 FIRST_ROWS = 5;
const FIRST_SPOTS = 6;
const RAIL_MAX = 3;
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, value, onChange}: {places: LocalPlace[]; value: string; onChange: (id: string) => void}) {
const tabs = RANGES.filter((r, i) => {
const n = places.filter((p) => r.test(meters(p))).length;
return i === 0 || (n > 0 && n < places.length);
});
if (tabs.length < 2) return null;
return (
<div className="tabrow" role="tablist">
{tabs.map((r) => (
<button key={r.id} type="button" role="tab" aria-current={r.id === value} onClick={() => onChange(r.id)}>
{r.label} {places.filter((p) => r.test(meters(p))).length}
</button>
))}
</div>
);
}
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<LocalPlace | null>(null);
const [spotRange, setSpotRange] = useState('all');
const [eatRange, setEatRange] = useState('all');
const wide = useWide();
const firstSpots = wide ? 8 : FIRST_SPOTS;
const railMax = wide ? 4 : RAIL_MAX;
if (rail.length === 0 && eats.length === 0) return null;
const shownSpots = spots.filter((p) => inRange(spotRange)(meters(p)));
const shownEats = eats.filter((p) => inRange(eatRange)(meters(p)));
const row = (place: LocalPlace) => (
<button key={place.name} type="button" className="itemrow" onClick={() => setOpen(place)}>
<span>{place.name}</span>
{place.distanceText && <span className="r">{place.distanceText}</span>}
</button>
);
const card = (place: LocalPlace) => (
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}>
{place.imageUrl && (
<div className="ph">
<img src={place.imageUrl} alt={place.name} loading="lazy" />
</div>
)}
<div className="bd">
<h3>{place.name}</h3>
{place.distanceText && <small>{place.distanceText}</small>}
{place.description && <p className="clamp">{place.description}</p>}
</div>
</button>
);
return (
<>
{rail.length > 0 && (
<div data-pg="home">
<section className="sec" id="around" aria-labelledby="around-heading">
<div className="head">
<div>
<p className="kicker">Travel</p>
<h2 id="around-heading">걸어서 닿는 곳</h2>
</div>
<a href="#/area">지역 소개 →</a>
</div>
<div className="guide guide--3">
{rail.slice(0, railMax).map((place) => (
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}>
<div className="ph">
<img src={place.imageUrl} alt={place.name} loading="lazy" />
</div>
<div className="bd">
<h3>{place.name}</h3>
{place.distanceText && <small>{place.distanceText}</small>}
</div>
</button>
))}
</div>
<a href="#/area" className="more">
지역 소개에서 모두 보기
</a>
</section>
</div>
)}
<div data-pg="area" id="guide">
{spots.length > 0 && (
<section className="sec" id="walk">
<p className="kicker">Spot</p>
<h2>
주변 명소 <span className="sub">{spots.length}곳</span>
</h2>
<RangeTabs places={spots} value={spotRange} onChange={setSpotRange} />
<div className="cards2">{shownSpots.slice(0, firstSpots).map(card)}</div>
{shownSpots.length > firstSpots && (
<details>
<summary className="more">{shownSpots.length - firstSpots}곳 더 보기</summary>
<div className="cards2">{shownSpots.slice(firstSpots).map(card)}</div>
</details>
)}
</section>
)}
{eats.length > 0 && (
<section className="sec" id="eat">
<p className="kicker">Table</p>
<h2>
주변 맛집 <span className="sub">{eats.length}곳</span>
</h2>
<RangeTabs places={eats} value={eatRange} onChange={setEatRange} />
<div className="list">
{shownEats.slice(0, FIRST_ROWS).map(row)}
{shownEats.length > FIRST_ROWS && (
<details>
<summary className="more">{shownEats.length - FIRST_ROWS}곳 더 보기</summary>
{shownEats.slice(FIRST_ROWS).map(row)}
</details>
)}
</div>
</section>
)}
</div>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
{open && (
<>
{open.imageUrl && <img src={open.imageUrl} alt={open.name} />}
<h3>{open.name}</h3>
<p className="meta">{[open.distanceText, open.category].filter(Boolean).join(' · ')}</p>
{open.description && <p>{open.description}</p>}
<a
className="out"
href={naverMapSearchUrl(open.searchQuery ?? open.name)}
target="_blank"
rel="noopener noreferrer"
>
네이버에서 보기
</a>
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,61 @@
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';
export function Booking() {
const payload = useSite();
const links = bookingLinks(payload);
const stay = payload.place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
const phone = payload.place.phone;
const book = () => window.dispatchEvent(new Event(BOOK_EVENT));
return (
<>
<div data-pg="home">
<section className="sec" aria-labelledby="booking-home-heading">
<div className="book">
<h2 id="booking-home-heading">이용안내 및 예약</h2>
<p>요금과 예약 방법을 안내합니다.</p>
<div className="row">
{links.slice(0, 1).map((link) => (
<a key={link.url} className="btn" href={link.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(link)}
</a>
))}
<a className={links.length > 0 ? 'btn ghost' : 'btn'} href="#/stay">
이용안내 보기
</a>
</div>
</div>
</section>
</div>
<div data-pg="stay">
<section className="sec" id="booking" aria-labelledby="booking-heading">
<div className="book">
<h2 id="booking-heading">이용안내 및 예약</h2>
<p>요금과 예약 방법을 안내합니다.</p>
<div className="stack">
{links.map((link) => (
<a key={link.url} className="btn" href={link.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(link)}
</a>
))}
{stay && (
<button type="button" className={links.length > 0 ? 'btn ghost' : 'btn'} onClick={book}>
예약하기
</button>
)}
{phone && (
<a className="btn ghost" href={`tel:${phone}`}>
{phone}
</a>
)}
</div>
</div>
</section>
</div>
</>
);
}

View File

@ -0,0 +1,37 @@
import {SourceType} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {faqList} from '@site/lib/derive';
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]) => (
<details key={faq.faqId}>
<summary>{faq.question}</summary>
<p>{faq.answer}</p>
</details>
);
return (
<section className="sec" id="faq" aria-labelledby="faq-heading">
<p className="kicker">Q &amp; A</p>
<h2 id="faq-heading">
자주 묻는 질문 <span className="sub">{faqs.length}</span>
</h2>
{verified && <p className="lead small">아래 답변은 모두 사업자가 확인한 내용입니다.</p>}
<div className="faq">
{faqs.slice(0, FIRST).map(item)}
{faqs.length > FIRST && (
<details className="rest">
<summary className="more">{faqs.length - FIRST}개 더 보기</summary>
{faqs.slice(FIRST).map(item)}
</details>
)}
</div>
</section>
);
}

View File

@ -0,0 +1,314 @@
import {useEffect, useState, 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 {MapPinIcon, PhoneIcon, TalkIcon} from './icons';
import '../kit/kit.css';
import './coral.css';
const HOUSE_WORD: Record<number, string> = {
[PlaceCategory.LODGING]: '펜션',
[PlaceCategory.CAFE]: '카페',
[PlaceCategory.RESTAURANT]: '가게',
[PlaceCategory.CLINIC]: '병원',
};
type PageId = 'home' | 'area' | 'stay' | 'story';
const PAGE_HEAD: Record<Exclude<PageId, 'home'>, {kicker: string; title: string; note: string}> = {
area: {
kicker: 'Travel',
title: '지역 소개',
note: '대문에서 걸어 닿는 자리와, 이 도시가 남긴 이야기입니다.',
},
stay: {
kicker: 'Reservation',
title: '이용안내 · 예약',
note: '객실 상세와 요금, 예약하는 방법입니다.',
},
story: {
kicker: '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<PageId>('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 booking = bookingLinks(payload)[0];
const stayBooking = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
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 links = publicLinks(payload);
const instagram = links.find((link) => /instagram\.com/i.test(link.url));
const kakao = payload.links.find((link) => link.confirmed && /(pf|open)\.kakao\.com/i.test(link.url));
const address = place.roadAddress ?? place.address;
const english = place.englishName?.trim();
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')],
['날씨', '#/', isSectionEnabled(payload, 'weather')],
] as [string, string, boolean][]
).filter(([, , on]) => on);
const barItems = [
address && (
<a key="dir" href={naverDirectionsUrl(place.name, place.latitude, place.longitude)} target="_blank" rel="noopener noreferrer nofollow">
<MapPinIcon />
길찾기
</a>
),
kakao && (
<a key="talk" href={kakao.url} target="_blank" rel="noopener noreferrer">
<TalkIcon />
카톡
</a>
),
place.phone && (
<a key="tel" href={`tel:${place.phone}`}>
<PhoneIcon />
전화
</a>
),
].filter(Boolean);
const cta = stayBooking ? (
<button type="button" className="cta" onClick={() => setBookingOpen(true)}>
예약하기
</button>
) : booking ? (
<a className="cta" href={booking.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(booking)}
</a>
) : null;
return (
<div className="w4-coral" data-page={page}>
<header className="top">
<div className="wrap">
{place.phone ? (
<a className="ico" href={`tel:${place.phone}`} aria-label="전화 걸기">
<PhoneIcon />
</a>
) : (
<span className="ico" />
)}
<a className={english ? 'logo' : 'logo logo--ko'} href="#/">
{english || place.name}
</a>
{stayBooking ? (
<button type="button" className="badge" onClick={() => setBookingOpen(true)}>
Book
<br />
Now
</button>
) : booking ? (
<a className="badge" href={booking.url} target="_blank" rel="noopener noreferrer">
Book
<br />
Now
</a>
) : (
<span className="ico" />
)}
</div>
<nav className="tabs" aria-label="주요 메뉴">
{tabs.map((tab) => (
<a
key={tab.page}
href={tab.page === 'home' ? '#/' : `#/${tab.page}`}
className={tab.page === page ? 'on' : undefined}
>
{tab.label}
</a>
))}
</nav>
<div className="hd-cta">
{place.phone && (
<a className="hd-tel" href={`tel:${place.phone}`}>
<PhoneIcon />
{place.phone}
</a>
)}
{cta}
</div>
</header>
{(Object.keys(PAGE_HEAD) as Exclude<PageId, 'home'>[]).map((id) => (
<div key={id} data-pg={id}>
<section className="sec pagehead">
<p className="kicker">{PAGE_HEAD[id].kicker}</p>
<h2>
{id === 'area'
? (place.addressLocality?.replace(/(시|군)$/, '') ?? PAGE_HEAD.area.title)
: PAGE_HEAD[id].title}
</h2>
<p className="lead">{PAGE_HEAD[id].note}</p>
{id === 'area' && areaPills.length > 0 && (
<div className="pills">
{areaPills.map(([label, href]) => (
<a key={href} className="pill" href={href}>
{label}
</a>
))}
</div>
)}
</section>
</div>
))}
{children}
{links.length > 0 && (
<div data-pg="stay">
<section className="sec" id="channels">
<p className="kicker">Channel</p>
<h2>공식 채널</h2>
<div className="list">
{links.map((link) => (
<a key={link.url} className="row" href={link.url} target="_blank" rel="noopener noreferrer">
<span>{channelLabel(link)}</span>
<span className="r">열기 ↗</span>
</a>
))}
</div>
</section>
</div>
)}
<footer>
<div className="wrap">
{place.phone && (
<a className="ftel" href={`tel:${place.phone}`}>
{place.phone}
</a>
)}
<b className="fname">{place.name}</b>
{address && <p>{address}</p>}
{hours.length > 0 && <p>{hours.join(' · ')}</p>}
{(place.phone || instagram) && (
<p>
{place.phone && <a href={`tel:${place.phone}`}>{place.phone}</a>}
{place.phone && instagram && ' · '}
{instagram && (
<a href={instagram.url} target="_blank" rel="noopener noreferrer">
instagram
</a>
)}
</p>
)}
{place.legal?.businessRegistrationNumber && (
<p>
{place.legal.representative && `대표 ${place.legal.representative} · `}
사업자등록번호 {place.legal.businessRegistrationNumber}
{place.legal.mailOrderNumber && ` · 통신판매업신고 ${place.legal.mailOrderNumber}`}
</p>
)}
<p className="byline credit">
<span className="author">작성·운영 {place.name}</span> · 최종 업데이트{' '}
<time dateTime={site.updatedAt}>{isoDate(site.updatedAt)}</time>
<br />
<a href="https://www.o2osolution.ai/" target="_blank" rel="noopener noreferrer">
AI O2O
</a>
의 Web4Ai로 만든 사이트입니다.
</p>
</div>
<p className={english ? 'giant' : 'giant giant--ko'} aria-hidden>
{english || place.name}
</p>
</footer>
<div className="player">
<SongPlayer />
</div>
{(barItems.length > 0 || cta) && (
<nav
className="tabbar"
aria-label="연락"
style={{gridTemplateColumns: [...barItems.map(() => '1fr'), ...(cta ? ['1.6fr'] : [])].join(' ')}}
>
{barItems}
{cta}
</nav>
)}
{stayBooking && (
<Sheet open={bookingOpen} onClose={() => setBookingOpen(false)} label="예약 요청" plain>
<div className="sheetbody">
<h3>예약 요청하기</h3>
<BookingForm />
</div>
</Sheet>
)}
</div>
);
}

View File

@ -0,0 +1,48 @@
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';
const FIRST = 5;
export function Gallery() {
const payload = useSite();
const images = galleryImages(payload);
const [open, setOpen] = useState<MediaItem | null>(null);
if (images.length === 0) return null;
const card = (image: MediaItem) => (
<button key={image.mediaId} type="button" onClick={() => setOpen(image)}>
<img src={image.url} alt={image.alt} loading="lazy" />
</button>
);
return (
<div data-pg="home">
<section className="sec" id="gallery" aria-labelledby="gallery-heading">
<p className="kicker">Landscape</p>
<h2 id="gallery-heading">
{sectionName(payload, 'photos', '머무는 장면들')} <span className="sub">{images.length}장</span>
</h2>
<div className="gal">{images.slice(0, FIRST).map(card)}</div>
{images.length > FIRST && (
<details>
<summary className="more">사진 {images.length - FIRST}장 더 보기</summary>
<div className="gal gal--rest">{images.slice(FIRST).map(card)}</div>
</details>
)}
</section>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.alt ?? ''}>
{open && (
<>
<img src={open.url} alt={open.alt} />
<h3>{open.alt}</h3>
{open.category && <p className="meta">{open.category}</p>}
</>
)}
</Sheet>
</div>
);
}

View File

@ -0,0 +1,95 @@
import {PlaceCategory} from '@o2o/shared';
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, heroFacts, isSectionEnabled, stayBookingView} from '@site/lib/derive';
import {Car} from '../paper/Car';
import {BOOK_EVENT} from '../paper/Rooms';
import {Weather} from './Weather';
const MAX_SLIDES = 5;
export function Hero() {
const payload = useSite();
const {place, narrative} = payload;
const slides = [...payload.media]
.filter((image) => image.alt?.trim())
.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary))
.slice(0, MAX_SLIDES);
const fixed = narrative.tagline ?? narrative.heroSubline;
const lodging = place.category === PlaceCategory.LODGING;
const region = place.addressLocality ?? place.addressRegion;
const booking = bookingLinks(payload)[0];
const stay = lodging ? stayBookingView(payload) : null;
const chips = heroFacts(payload).map((row) => `${row.label} ${row.value}`);
return (
<>
<div className="hero" id="top" data-pg="home">
<div className="hero-card">
{slides.length > 0 && (
<Car
label={`${place.name} 사진`}
autoplay={5000}
dots
style={{height: '100%'}}
slides={slides.map((image, i) => (
<img
key={image.mediaId}
src={image.url}
alt={image.alt}
fetchPriority={i === 0 ? 'high' : 'low'}
loading={i === 0 ? undefined : 'lazy'}
decoding="async"
/>
))}
/>
)}
<div className="hero-copy">
<h1 className="eyebrow">
{place.name}
{region && ` · ${region}`}
</h1>
{lodging ? (
<p className="hl">
<HeroCatchphrase />
</p>
) : (
fixed && <p className="hl in">{fixed}</p>
)}
{lodging && fixed && <p className="tagline">{fixed}</p>}
{chips.length > 0 && (
<div className="chips">
{chips.map((chip) => (
<span key={chip} className="chip">
{chip}
</span>
))}
</div>
)}
<div className="hero-cta">
{stay ? (
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
예약하기
</button>
) : (
booking && (
<a className="btn" href={booking.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(booking)}
</a>
)
)}
<a className="btn soft" href="#/stay">
이용안내 보기
</a>
</div>
</div>
</div>
</div>
{isSectionEnabled(payload, 'weather') && (
<div data-editor-id="weather" style={{display: 'contents'}}>
<Weather />
</div>
)}
</>
);
}

View File

@ -0,0 +1,81 @@
import {useSite} from '@site/lib/site-context';
import {galleryImages, heroFacts, sectionBody} from '@site/lib/derive';
import {ON_VALUES} from '@site/sections/EssentialInfoSection';
import {Blog} from '../paper/Intro';
const FLAG_VALUES = new Set(['가능', '불가', '있음', '없음']);
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 facts = heroFacts(payload);
const flags = facts.filter((row) => FLAG_VALUES.has(row.value));
const rest = facts.filter((row) => !FLAG_VALUES.has(row.value));
return (
<>
<div data-pg="story" className="blogwrap">
<Blog full />
</div>
<div data-pg="home">
<div className="blogwrap">
<Blog />
</div>
{(about.length > 0 || facts.length > 0) && (
<section className="sec" id="about" aria-labelledby="about-heading">
<p className="kicker">Prologue</p>
<h2 id="about-heading">{narrative.summary ?? `${place.name} 소개`}</h2>
{about[0] && <p className="lead">{about[0]}</p>}
{about.length > 1 && (
<details>
<summary className="more">더 읽기</summary>
{about.slice(1).map((paragraph, i) => (
<p key={i} className="lead">
{paragraph}
</p>
))}
</details>
)}
{flags.length > 0 && (
<ul className="amen">
{flags.map((row) => {
const on = ON_VALUES.has(row.value);
return (
<li key={row.key ?? row.label} className={on ? 'on' : 'off'}>
<i className="ic" aria-hidden>
{on ? '✓' : '✕'}
</i>
<span>
<b>{row.label}</b> {row.value}
</span>
</li>
);
})}
</ul>
)}
{rest.length > 0 && (
<div className="answer">
<dl>
{rest.map((row) => (
<div key={row.key ?? row.label} style={{display: 'contents'}}>
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
))}
</dl>
</div>
)}
{image && (
<figure className="photo">
<img src={image.url} alt={image.alt} loading="lazy" />
</figure>
)}
</section>
)}
</div>
</>
);
}

View File

@ -0,0 +1,75 @@
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';
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 (
<section className="sec" id="location" aria-labelledby="location-heading">
<p className="kicker">Location</p>
<h2 id="location-heading">오시는 길</h2>
<div className="loc">
{lat != null && lng != null && (
<iframe
className="map"
title={`${place.name} 위치 지도`}
src={osmEmbedUrl(lat, lng, 16 / 10)}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
)}
<div className="bd">
<address>
<b>{address}</b>
</address>
{lot && <p>지번 {lot}</p>}
{parking && <p>주차 {[parking.value, parking.note].filter(Boolean).join(' — ')}</p>}
</div>
</div>
{routes.length > 0 && (
<div className="list">
{routes.map((route) => (
<div key={route.destination} className="row">
<span>{route.destination}</span>
<span className="r">
{[route.byCar && `차 ${route.byCar}`, route.byTransit, route.distanceText].filter(Boolean).join(' · ')}
</span>
</div>
))}
</div>
)}
<div className="stack">
<button type="button" className="btn soft" onClick={copy}>
{copied ? '복사했습니다' : '주소 복사'}
</button>
<div className="row2">
<a className="btn soft" href={naverDirectionsUrl(place.name, lat, lng)} target="_blank" rel="noopener noreferrer nofollow">
네이버 지도
</a>
<a className="btn soft" href={kakaoDirectionsUrl(place.name, lat, lng)} target="_blank" rel="noopener noreferrer nofollow">
카카오맵
</a>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,135 @@
import {useState} from 'react';
import {factText, sanitizeUnits} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {sectionName, unitSpec, unitViews, type UnitView} from '@site/lib/derive';
import {Car} from '../paper/Car';
import {BOOK_EVENT} from '../paper/Rooms';
import {Sheet} from '../paper/Sheet';
function splitName(name: string): [string, string | undefined] {
const m = /^(.+?)\s*\((.+)\)\s*$/.exec(name);
return m ? [m[1], m[2]] : [name, undefined];
}
function specRows(unit: UnitView, facts: Parameters<typeof factText>[0]) {
const bed = [factText(facts, 'bed_type'), factText(facts, 'room_size')].filter(Boolean).join(' · ');
const std = factText(facts, 'standard_capacity');
const max = factText(facts, 'max_capacity');
const people = [std && `기준 ${std.replace(/명$/, '인')}`, max && `최대 ${max.replace(/명$/, '인')}`]
.filter(Boolean)
.join(' · ');
const price = unit.priceText ?? unit.prices.map((p) => `${p.label} ${p.value}`).join(' · ');
return [
['침대', bed],
['정원', people],
['요금', price],
].filter(([, value]) => value) as [string, string][];
}
const HOME_MAX = 4;
export function Rooms() {
const payload = useSite();
const units = unitViews(payload);
const spec = unitSpec(payload);
const raw = new Map(sanitizeUnits(payload.units).map((unit) => [unit.unitId, unit]));
const [open, setOpen] = useState<UnitView | null>(null);
if (units.length === 0) return null;
const rowsOf = (unit: UnitView) => specRows(unit, raw.get(unit.unitId)?.facts ?? []);
const people = (unit: UnitView) => rowsOf(unit).find(([label]) => label === '정원')?.[1];
return (
<>
<div data-pg="home">
<section className="sec" id="units" aria-labelledby="units-heading">
<p className="kicker">Room View</p>
<h2 id="units-heading">{sectionName(payload, spec.path, `${spec.label} 안내`)}</h2>
<div className="rooms">
{units.slice(0, HOME_MAX).map((unit, i) => (
<a key={unit.unitId} href="#/stay" className="room room--mini">
{unit.images[0] && (
<div className="ph">
<img src={unit.images[0].url} alt={unit.images[0].alt} loading="lazy" />
</div>
)}
<div className="bd">
<h3>
<span className="no">{String(i + 1).padStart(2, '0')}</span>
{unit.name}
</h3>
{people(unit) && <p>{people(unit)}</p>}
</div>
</a>
))}
</div>
<a href="#/stay" className="more">
이용안내에서 전체 보기
</a>
</section>
</div>
<div data-pg="stay">
{units.map((unit, i) => {
const [main, sub] = splitName(unit.name);
return (
<section key={unit.unitId} className="sec" id={`unit-${unit.slug}`}>
<p className="kicker">
Room {String(i + 1).padStart(2, '0')}
</p>
<h2>
{main} {sub && <span className="sub">{sub}</span>}
</h2>
<div className="room room--full">
{unit.images.length > 0 && (
<Car
label={`${main} 사진`}
arrows
count
dots
slides={unit.images.map((image) => (
<img key={image.mediaId} src={image.url} alt={image.alt} loading="lazy" />
))}
/>
)}
<div className="bd">
{unit.intro && <p className="intro">{unit.intro}</p>}
<dl className="spec">
{rowsOf(unit).map(([label, value]) => (
<div key={label} style={{display: 'contents'}}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
<div className="btnrow">
<button type="button" className="btn ghost" onClick={() => setOpen(unit)}>
자세히
</button>
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
예약 요청
</button>
</div>
</div>
</div>
</section>
);
})}
</div>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
{open && (
<>
{open.images[0] && <img src={open.images[0].url} alt={open.images[0].alt} />}
<h3>{open.name}</h3>
{open.intro && <p>{open.intro}</p>}
{open.rows.map((row) => (
<p key={row.label}>
{row.label} — {row.value}
</p>
))}
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,39 @@
import type {SectionHeadProps} from '@site/lib/layout';
const KICKER: Record<string, string> = {
about: 'Prologue',
info: 'Information',
units: 'Room View',
booking: 'Reservation',
gallery: 'Landscape',
guide: 'Travel',
weather: 'Today',
location: 'Location',
faq: 'Q & A',
video: 'Film',
blog: 'Journal',
festival: 'Festival',
itinerary: 'Course',
story: 'Story',
rules: 'Rules',
event: 'News',
reviews: 'Review',
'postcard-maker': 'Postcard',
};
export function SectionHead({id, title, lead, aside}: SectionHeadProps) {
return (
<div className="rhead">
{KICKER[id] && <p className="kicker">{KICKER[id]}</p>}
{aside ? (
<div className="head">
<h2 id={`${id}-heading`}>{title}</h2>
{aside}
</div>
) : (
<h2 id={`${id}-heading`}>{title}</h2>
)}
{lead && <p className="lead">{lead}</p>}
</div>
);
}

View File

@ -0,0 +1,78 @@
import type {WeatherSky} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {useLiveWeather} from '@site/lib/use-live-weather';
import {useWeatherNotes} from '@site/lib/use-weather-note';
import {weatherBand, weatherMood} from '@site/lib/derive';
import {CloudIcon, RainIcon, SnowIcon, SunIcon} from './icons';
const MOOD_ICON = {
맑음: SunIcon,
흐림: CloudIcon,
비: RainIcon,
눈: SnowIcon,
} as const;
const OBSERVED = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
});
function observed(iso: string): string | undefined {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return undefined;
const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value]));
return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`;
}
export function Weather() {
const payload = useSite();
const weather = useLiveWeather({
initial: payload.local.weather,
regionCode: payload.place.regionCode,
latitude: payload.place.latitude,
longitude: payload.place.longitude,
});
const mood = weatherMood(weather?.condition ?? '');
const band = weatherBand(weather?.temperature ?? 0);
const notes = payload.local.weather;
const condition = (weather?.condition ?? '') as WeatherSky;
const skyKey: WeatherSky = notes?.noteSets?.[condition] ? condition : mood;
const [line, bandLine] = useWeatherNotes(
notes?.noteSets?.[skyKey],
notes?.notes?.[mood] ?? weather?.note,
notes?.tempNoteSets?.[band],
notes?.tempNotes?.[band],
);
if (!weather) return null;
const Icon = MOOD_ICON[mood];
const region = payload.place.addressLocality?.replace(/(시|군)$/, '');
const at = observed(weather.observedAt);
return (
<section className="sec" id="weather" data-pg="home" aria-labelledby="weather-heading">
<p className="kicker">Today</p>
<h2 id="weather-heading">{region ? `지금 ${region}은` : '오늘의 날씨'}</h2>
<div className="weather" data-band={band}>
<Icon />
<div className="t">
<span className="deg">{Math.round(weather.temperature * 10) / 10}°</span>
<span className="cond">{weather.condition}</span>
</div>
{at && <p className="obs">{at}</p>}
{line && (
<p id="wnote" key={line} className="note">
{line}
</p>
)}
<p className="band">
<span>{band}</span>
{bandLine && <em key={bandLine}>{bandLine}</em>}
</p>
</div>
</section>
);
}

View File

@ -0,0 +1,449 @@
.w4-coral{--ink:var(--tpl-text,#191f28);--sub:var(--tpl-secondary,#4e5968);--mute:#5f6b78;--bg:var(--tpl-bg,#fff);--alt:var(--tpl-card,#f2f4f6);--line:#e5e8eb;--accent:var(--tpl-accent,#3182f6);--g:20px;--r-hero:32px;--r-card:22px;--r-sm:16px;--k-bg:var(--bg);--k-ink:var(--ink);--k-sub:var(--sub);--k-line:var(--line);--k-soft:var(--alt);--k-r:14px;--sy:56px;--sy-sub:40px;background:var(--bg);color:var(--ink);font:17px/1.6 'Noto Sans KR',system-ui,sans-serif;letter-spacing:-.01em;-webkit-font-smoothing:antialiased;word-break:keep-all;padding-bottom:96px;overflow-x:clip}
.w4-coral img{display:block;width:100%;height:100%;object-fit:cover}
.w4-coral a{color:inherit;text-decoration:none}
.w4-coral button{font:inherit;color:inherit}
.w4-coral h1,.w4-coral h2,.w4-coral h3,.w4-coral p,.w4-coral figure{margin:0}
.w4-coral .wrap{max-width:1120px;margin:0 auto;padding:0 var(--g)}
.w4-coral .top{position:sticky;top:0;z-index:30;background:rgba(255,255,255,.92);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
.w4-coral .top .wrap{height:56px;display:flex;align-items:center;justify-content:space-between}
.w4-coral .logo{font-weight:800;font-size:20px;letter-spacing:-.03em}
.w4-coral .ico{width:44px;height:44px;display:grid;place-items:center;margin-right:-10px}
.w4-coral .ico svg{width:22px;height:22px}
.w4-coral .tabs{display:flex;gap:6px;padding:0 var(--g) 10px;overflow-x:auto;scrollbar-width:none}
.w4-coral .tabs::-webkit-scrollbar{display:none}
.w4-coral .tabs a{flex:none;padding:8px 14px;border-radius:999px;font-size:15px;font-weight:600;color:var(--sub);background:var(--alt)}
.w4-coral .tabs a.on{background:var(--ink);color:var(--bg)}
.w4-coral .sec{max-width:1120px;margin:0 auto;padding:var(--sy) var(--g) 0}
.w4-coral :is(#songs,#people,#chronicle,#reading).sec{padding-top:var(--sy-sub)!important}
.w4-coral .pagehead{padding-top:40px}
.w4-coral .kicker,.w4-coral .eyebrow{font-size:15px;font-weight:700;color:var(--accent);margin-bottom:10px}
.w4-coral .sec h2{font-size:30px;font-weight:700;line-height:1.35;letter-spacing:-.02em;margin-bottom:24px}
.w4-coral .sec h2 .sub{font-size:16px;font-weight:600;color:var(--mute);margin-left:6px}
.w4-coral .lead{font-size:18px;color:var(--sub);line-height:1.7}
.w4-coral .note,.w4-coral .dim{color:var(--mute);font-size:15px}
.w4-coral .pills,.w4-coral .chips,.w4-coral .tabrow{display:flex;gap:8px;flex-wrap:wrap;margin-top:18px}
.w4-coral .pill,.w4-coral .tabrow button{padding:9px 16px;border-radius:999px;background:var(--alt);border:0;font-size:15px;font-weight:600;cursor:pointer}
.w4-coral .tabrow button[aria-pressed=true],.w4-coral .tabrow button[aria-current=true]{background:var(--ink);color:var(--bg)}
.w4-coral .hero{padding:4px var(--g) 0}
.w4-coral .hero-card{position:relative;border-radius:var(--r-hero);overflow:hidden;aspect-ratio:3/4;max-height:78vh;width:100%;background:var(--alt)}
.w4-coral .hero-card .car,.w4-coral .hero-card .vp,.w4-coral .hero-card .track,.w4-coral .hero-card .slide{height:100%}
.w4-coral .hero-card:after{content:'';position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 45%,rgba(0,0,0,.55));pointer-events:none}
.w4-coral .hero-copy{position:absolute;left:24px;right:24px;bottom:28px;z-index:1;color:#fff}
.w4-coral .hero-copy .eyebrow{color:#fff;opacity:.9;font-size:15px;font-weight:600}
.w4-coral .hero-copy .hl{font-size:40px;font-weight:700;line-height:1.28;letter-spacing:-.02em;min-height:2.56em}
.w4-coral .chip{background:rgba(255,255,255,.2);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border-radius:999px;padding:7px 14px;font-size:14px;font-weight:600}
.w4-coral .answer{background:var(--alt);border-radius:var(--r-card);padding:24px;margin-top:24px}
.w4-coral .answer dl{display:grid;gap:12px;margin:0;font-size:16px}
.w4-coral .answer dl div{display:grid;grid-template-columns:96px 1fr;gap:12px}
.w4-coral .answer dt{color:var(--mute);font-weight:500}
.w4-coral .answer dd{margin:0;font-weight:600}
.w4-coral .photo{margin-top:16px;border-radius:var(--r-card);overflow:hidden;aspect-ratio:4/3}
.w4-coral #about p:not([class]){font-size:18px;line-height:1.75;color:var(--sub);margin-bottom:12px}
.w4-coral .rooms{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.w4-coral .room--mini .bd{padding:12px 14px 14px}
.w4-coral .room--mini h3{font-size:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.w4-coral .room--mini p{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.w4-coral .room{display:block;border-radius:var(--r-card);overflow:hidden;background:var(--alt)}
.w4-coral .room .ph{aspect-ratio:3/2}
.w4-coral .room .bd{padding:20px 22px 24px}
.w4-coral .room h3{font-size:22px;font-weight:700;letter-spacing:-.02em}
.w4-coral .room p{color:var(--sub);font-size:16px;margin-top:4px}
.w4-coral .price{margin-top:14px;display:flex;justify-content:space-between;align-items:center;gap:10px}
.w4-coral .price b,.w4-coral .price strong{font-size:20px}
.w4-coral .room--full{background:transparent}
.w4-coral .room--full .car{border-radius:var(--r-card);overflow:hidden}
.w4-coral .room--full .slide img{aspect-ratio:4/3;height:auto}
.w4-coral .room--full .bd{padding:18px 0 0}
.w4-coral .car .arrow{position:absolute;top:50%;transform:translateY(-50%);width:40px;height:40px;border:0;border-radius:50%;background:rgba(255,255,255,.92);box-shadow:0 2px 8px rgba(0,0,0,.12);display:grid;place-items:center;cursor:pointer;font-size:18px}
.w4-coral .car .prev{left:10px}
.w4-coral .car .next{right:10px}
.w4-coral .car .count{position:absolute;right:12px;bottom:12px;padding:3px 10px;border-radius:999px;background:rgba(0,0,0,.55);color:#fff;font-size:13px}
.w4-coral .car--multi .track{gap:12px}
.w4-coral .car--200 .slide{flex:0 0 200px}
.w4-coral .spec{display:grid;gap:10px;margin:0}
.w4-coral .spec div{display:grid;grid-template-columns:96px 1fr;gap:12px;font-size:16px}
.w4-coral .spec dt{color:var(--mute)}
.w4-coral .spec dd{margin:0;font-weight:600}
.w4-coral .btn{display:inline-flex;align-items:center;justify-content:center;min-height:48px;padding:0 20px;border-radius:14px;border:0;background:var(--accent);color:#fff;font-weight:700;font-size:16px;cursor:pointer}
.w4-coral .btn.ghost{background:#fff;color:var(--ink)}
.w4-coral .btn.soft{background:var(--alt);color:var(--ink)}
.w4-coral .btnrow,.w4-coral .row2{display:flex;gap:8px;margin-top:18px}
.w4-coral .btnrow .btn,.w4-coral .row2 .btn{flex:1}
.w4-coral .room--full .btn.ghost{background:var(--alt)}
.w4-coral .stack{display:grid;gap:8px;margin-top:18px}
.w4-coral .infobar{margin-top:14px;padding:14px 18px;border-radius:var(--r-sm);background:var(--alt);font-size:16px;font-weight:600}
.w4-coral .row,.w4-coral .itemrow{display:flex;justify-content:space-between;gap:12px;width:100%;padding:18px 0;border:0;border-bottom:1px solid var(--line);background:none;text-align:left;font-size:17px;cursor:pointer}
.w4-coral .row .r,.w4-coral .itemrow .r{color:var(--mute);font-size:15px;flex:none}
.w4-coral .amen{list-style:none;padding:0;margin:20px 0 0;display:grid;grid-template-columns:1fr 1fr;gap:10px}
.w4-coral .amen li{display:flex;gap:10px;align-items:center;padding:16px;border-radius:var(--r-sm);background:var(--alt);font-size:15px;color:var(--mute)}
.w4-coral .amen li.on{color:var(--ink);font-weight:600}
.w4-coral .amen .ic{display:grid;place-items:center;width:32px;height:32px;border-radius:10px;background:#fff;color:var(--accent);flex:none;font-style:normal}
.w4-coral .amen svg{width:18px;height:18px}
.w4-coral .rule{height:0}
.w4-coral .book{border-radius:var(--r-card);background:var(--ink);color:#fff;padding:28px 24px}
.w4-coral .book h2{color:#fff;margin-bottom:10px}
.w4-coral .book p{color:#b0b8c1;font-size:16px}
.w4-coral .book .row{border:0;padding:0;margin-top:20px;gap:8px}
.w4-coral .book .row .btn{flex:1}
.w4-coral .book .btn.ghost{background:#fff;color:var(--ink)}
.w4-coral .gal{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.w4-coral .gal button{border:0;padding:0;border-radius:var(--r-sm);overflow:hidden;aspect-ratio:4/3;background:var(--alt);cursor:pointer}
.w4-coral .gal:not(.gal--rest) button:first-child{grid-column:span 2;aspect-ratio:2/1}
.w4-coral .gal--rest{margin-top:8px}
.w4-coral details>summary{list-style:none;cursor:pointer}
.w4-coral details>summary::-webkit-details-marker{display:none}
.w4-coral details:not([open])>:not(summary){display:none}
.w4-coral .more{display:flex;align-items:center;justify-content:center;height:48px;margin-top:12px;border-radius:14px;background:var(--alt);font-size:15px;font-weight:600;color:var(--sub)}
.w4-coral .loc{border-radius:var(--r-card);overflow:hidden;background:var(--alt)}
.w4-coral .loc .map{display:block;width:100%;aspect-ratio:16/10;border:0}
.w4-coral .loc .bd{padding:20px 22px}
.w4-coral .loc address{font-style:normal;font-size:18px;font-weight:700}
.w4-coral .loc p{font-size:15px;color:var(--sub)}
.w4-coral .head{display:flex;justify-content:space-between;align-items:flex-end}
.w4-coral .guide{display:flex;gap:12px;overflow-x:auto;scroll-snap-type:x mandatory;margin:0 calc(var(--g)*-1);padding:0 var(--g) 4px;scrollbar-width:none}
.w4-coral .guide::-webkit-scrollbar{display:none}
.w4-coral .place{flex:0 0 72%;scroll-snap-align:start;border-radius:var(--r-card);overflow:hidden;background:var(--alt);border:0;padding:0;text-align:left;cursor:pointer}
.w4-coral .place .ph{display:block;aspect-ratio:1}
.w4-coral .place .bd{display:block;padding:16px 18px 20px}
.w4-coral .place small{font-size:13px;color:var(--accent);font-weight:700}
.w4-coral .place b{display:block;font-size:18px;font-weight:700;margin-top:2px}
.w4-coral .place h3{font-size:16px;font-weight:700;line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.w4-coral .place small{display:block;margin-top:2px}
.w4-coral .place .bd p{font-size:14px;color:var(--sub);line-height:1.5;margin-top:6px}
.w4-coral .guide--3{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;overflow:visible;margin:0;padding:0}
.w4-coral .guide--3 .place .ph{aspect-ratio:3/2}
.w4-coral .guide--3 .place .bd{padding:10px 10px 12px}
.w4-coral .guide--3 .place h3{font-size:15px}
.w4-coral .cards2 .place .ph{aspect-ratio:3/2}
.w4-coral .cards2 .place .bd{padding:12px 14px 14px}
.w4-coral details .cards2{margin-top:10px}
.w4-coral .cards2,.w4-coral .grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:16px}
.w4-coral .grid3{grid-template-columns:1fr 1fr}
.w4-coral .itemcard{display:block;width:100%;border:0;padding:0;border-radius:var(--r-card);overflow:hidden;background:var(--alt);text-align:left;cursor:pointer}
.w4-coral .itemcard img{aspect-ratio:1;height:auto}
.w4-coral .itemcard .cap{padding:10px 14px 0;font-size:15px;font-weight:700}
.w4-coral .itemcard .cap.dim{padding:4px 14px 14px;font-weight:400;font-size:14px}
.w4-coral .car--multi .itemcard{height:100%}
.w4-coral #reading .itemcard{padding:18px;margin-top:10px}
.w4-coral .clamp{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.w4-coral .list{border-top:1px solid var(--line)}
.w4-coral .faq{border-top:1px solid var(--line)}
.w4-coral .faq details{border-bottom:1px solid var(--line)}
.w4-coral .faq summary{display:flex;justify-content:space-between;gap:12px;padding:20px 0;font-size:17px;font-weight:600}
.w4-coral .faq summary:after{content:'+';color:var(--mute);font-size:22px;line-height:1}
.w4-coral .faq details[open] summary:after{content:'−'}
.w4-coral .faq details p{padding:0 0 20px;color:var(--sub);font-size:16px}
.w4-coral .weather{border-radius:var(--r-card);background:linear-gradient(160deg,#e8f3ff,#f7fbff);padding:26px 24px 24px;position:relative;overflow:hidden}
.w4-coral .weather>svg{position:absolute;right:-30px;top:-30px;width:180px;height:180px;opacity:.12;color:var(--accent)}
.w4-coral .weather .t{display:flex;align-items:baseline;gap:10px}
.w4-coral .weather .deg{font-size:64px;font-weight:700;letter-spacing:-.04em;line-height:1}
.w4-coral .weather .cond{font-size:20px;font-weight:700}
.w4-coral .weather .obs{font-size:14px;color:var(--mute);margin-top:6px}
.w4-coral .weather .note{font-size:18px;line-height:1.65;margin-top:18px;font-weight:500;color:var(--ink)}
.w4-coral .weather .band{display:flex;gap:10px;align-items:center;margin-top:14px;font-size:15px;color:var(--sub)}
.w4-coral .weather .band em,.w4-coral .weather .band b{font-style:normal;border-radius:999px;background:#fff;padding:4px 12px;font-weight:700;color:var(--accent);flex:none}
.w4-coral .rhead{margin-bottom:16px}
.w4-coral #postcard-maker,.w4-coral #reviews{border:0;background:transparent;--section-space:var(--sy)}
.w4-coral footer{padding:64px 0 32px;color:var(--mute);font-size:14px}
.w4-coral footer .fname{display:block;font-size:18px;color:var(--ink);margin-bottom:8px}
.w4-coral footer p{margin-top:4px}
.w4-coral .byline a{text-decoration:underline}
.w4-coral .player{position:fixed;right:16px;bottom:96px;z-index:30;background:#fff;border-radius:999px;padding:4px 8px 4px 14px;box-shadow:0 8px 28px rgba(0,0,0,.14)}
.w4-coral #w4d-mini{display:flex;align-items:center;gap:2px}
.w4-coral #w4d-tape{width:24px;height:24px;margin-right:6px;color:var(--accent)}
.w4-coral #w4d-tape svg{width:100%;height:100%}
.w4-coral #w4d-now{max-width:110px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;font-weight:700}
.w4-coral #w4d-mini button{width:38px;height:38px;padding:9px;border:0;background:none;cursor:pointer}
.w4-coral #w4d-mini button svg{width:100%;height:100%}
.w4-coral #w4d-panel{background:#fff;border-radius:var(--r-card);box-shadow:0 12px 36px rgba(0,0,0,.16);color:var(--ink)}
.w4-coral .tabbar{position:fixed;left:12px;right:12px;bottom:max(12px,env(safe-area-inset-bottom));z-index:30;display:grid;gap:6px;background:#fff;border-radius:22px;padding:6px;box-shadow:0 8px 28px rgba(0,0,0,.12)}
.w4-coral .tabbar>a,.w4-coral .tabbar>button{height:52px;border-radius:16px;border:0;background:none;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;font-size:12px;font-weight:600;color:var(--sub);cursor:pointer}
.w4-coral .tabbar svg{width:20px;height:20px}
.w4-coral .tabbar .cta{background:var(--accent);color:#fff;font-size:16px;font-weight:700}
@media (min-width:700px){.w4-coral{--sy:64px;--side:max(0px,calc((100vw - 640px) / 2));max-width:640px;margin-left:auto;margin-right:auto;box-shadow:0 0 0 1px rgba(0,0,0,.06)}.w4-coral .player{right:calc(var(--side) + 16px)}.w4-coral .tabbar{left:calc(var(--side) + 12px);right:calc(var(--side) + 12px)}.w4-coral .top{position:sticky}}
.w4-coral .hd-cta,.w4-coral .hero-cta{display:none}
@media(min-width:1024px){.w4-coral{--g:32px;--sy:36px;--sy-sub:24px;max-width:none;margin:0;box-shadow:none;padding-bottom:0}
.w4-coral .wrap,.w4-coral .sec{max-width:1144px}
.w4-coral .lead,.w4-coral #about p:not([class]){font-size:17px}
.w4-coral .top{display:flex;align-items:center;gap:28px;height:68px;padding:0 max(var(--g),calc((100% - 1080px)/2));border-bottom:1px solid var(--line)}
.w4-coral .top .wrap{flex:none;height:auto;margin:0;padding:0}
.w4-coral .top .ico{display:none}
.w4-coral .tabs{flex:1;min-width:0;padding:0;gap:4px}
.w4-coral .tabs a{background:none;font-size:15px}
.w4-coral .tabs a.on{background:var(--alt);color:var(--ink)}
.w4-coral .hd-cta{display:flex;align-items:center;gap:8px;flex:none}
.w4-coral .hd-cta>a,.w4-coral .hd-cta>button{display:inline-flex;align-items:center;gap:6px;height:40px;padding:0 16px;border:0;border-radius:12px;font-size:15px;font-weight:700;cursor:pointer;background:var(--alt);color:var(--ink)}
.w4-coral .hd-cta svg{width:16px;height:16px}
.w4-coral .hd-cta .cta{background:var(--accent);color:#fff}
.w4-coral .tabbar{display:none}
.w4-coral .player{right:24px;bottom:24px}
.w4-coral .hero{max-width:1144px;margin:0 auto;padding:20px var(--g) 16px}
.w4-coral .hero-card{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr);gap:56px;align-items:center;aspect-ratio:auto;height:400px;max-height:none;border-radius:0;overflow:visible;background:none}
.w4-coral .hero-card:not(:has(.car)){grid-template-columns:1fr;height:auto}
.w4-coral .hero-card:after{display:none}
.w4-coral .hero-card .car{border-radius:var(--r-hero);overflow:hidden;background:var(--alt)}
.w4-coral .hero-copy{position:static;color:var(--ink)}
.w4-coral .hero-copy .eyebrow{color:var(--accent);opacity:1;font-size:16px;font-weight:700;margin-bottom:14px}
.w4-coral .hero-copy .hl{font-size:44px;min-height:0}
.w4-coral .hero-copy .tagline{margin-top:14px;font-size:17px;color:var(--sub)}
.w4-coral .hero-copy .chips{margin-top:20px}
.w4-coral .hero-copy .chip{background:var(--alt);color:var(--sub);backdrop-filter:none;-webkit-backdrop-filter:none}
.w4-coral .hero-cta{display:flex;gap:8px;margin-top:32px}
.w4-coral .hero-cta .btn{min-width:150px}
.w4-coral #weather.sec{display:grid;grid-template-columns:280px minmax(0,1fr);grid-template-rows:auto 1fr;column-gap:40px;align-items:start}
.w4-coral #weather>.kicker{grid-column:1}
.w4-coral #weather>h2{grid-column:1;margin:0}
.w4-coral #weather>.weather{grid-column:2;grid-row:1/span 2;display:grid;grid-template-columns:auto minmax(0,1fr);column-gap:40px;align-items:center;padding:22px 28px}
.w4-coral .weather .t,.w4-coral .weather .obs{grid-column:1}
.w4-coral .weather .note{grid-column:2;grid-row:1;margin:0;font-size:17px}
.w4-coral .weather .t{grid-row:1}
.w4-coral .weather .obs{grid-row:2}
.w4-coral .weather .band{grid-column:2;margin-top:10px}
.w4-coral .weather .deg{font-size:48px}
.w4-coral .rooms{grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}
.w4-coral .more{max-width:320px;margin-left:auto;margin-right:auto}
.w4-coral .book{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.3fr);column-gap:40px;align-items:center;padding:22px 32px}
.w4-coral .book>h2,.w4-coral .book>p{grid-column:1}
.w4-coral .book>.row,.w4-coral .book>.stack{grid-column:2;grid-row:1/span 2;margin:0}
.w4-coral .book>.stack{grid-template-columns:repeat(2,minmax(0,1fr))}
.w4-coral .gal:not(.gal--rest){grid-template-columns:2fr 1fr 1fr;grid-template-rows:130px 130px;gap:10px}
.w4-coral .gal:not(.gal--rest) button{aspect-ratio:auto}
.w4-coral .gal:not(.gal--rest) button:first-child{grid-column:1;grid-row:1/span 2;aspect-ratio:auto}
.w4-coral .gal--rest{grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}
.w4-coral .gal--rest button{aspect-ratio:3/2}
.w4-coral .guide--3,.w4-coral .cards2,.w4-coral .grid2.grid3{grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}
.w4-coral .guide--3 .place .bd{padding:12px 14px 14px}
.w4-coral #eat .list,.w4-coral #eat .list details[open],.w4-coral #channels .list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:48px}
.w4-coral #eat .list details{grid-column:1/-1}
.w4-coral #eat .list details>summary{grid-column:1/-1}
.w4-coral :is(#songs,#chronicle,#reading).sec{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:48px;align-content:start}
.w4-coral :is(#songs,#chronicle,#reading)>:is(.eyebrow,.kicker,h2,.note,details){grid-column:1/-1}
.w4-coral #reading .itemcard{margin-top:0;margin-bottom:8px;padding:14px 18px}
.w4-coral[data-page=stay] [data-pg=stay]:has(>[id^=unit-]){display:grid;grid-template-columns:repeat(3,minmax(0,1fr));column-gap:24px;max-width:1144px;margin:0 auto;padding:0 var(--g)}
.w4-coral[data-page=stay] [data-pg=stay]:has(>[id^=unit-])>.sec{padding-left:0;padding-right:0;min-width:0}
.w4-coral .room--full .slide img{aspect-ratio:3/2}
.w4-coral .room--full .sec h2,.w4-coral [id^=unit-] h2{font-size:24px;margin-bottom:14px}
.w4-coral .amen{grid-template-columns:repeat(4,minmax(0,1fr))}
.w4-coral .faq{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:48px;align-items:start}
.w4-coral .faq details.rest{grid-column:1/-1}
.w4-coral .faq details.rest[open]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:48px;border:0}
.w4-coral .faq details.rest>summary{grid-column:1/-1}
.w4-coral #location.sec{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(0,1fr);column-gap:40px;align-content:start}
.w4-coral #location>:is(.kicker,h2){grid-column:1/-1}
.w4-coral #location>.loc{grid-column:1;grid-row:3/span 2}
.w4-coral #location>:is(.list,.stack){grid-column:2}
.w4-coral #location>.stack{margin-top:0;align-self:start}
.w4-coral .loc address{font-size:17px}
.w4-coral footer{padding:36px 0 32px}
.w4-coral footer .wrap{display:flex;flex-wrap:wrap;column-gap:20px;max-width:1144px}
.w4-coral footer .fname{flex-basis:100%}
.w4-coral .sec h2{font-size:28px;line-height:1.3;margin-bottom:14px}
.w4-coral :is(.kicker,.eyebrow){margin-bottom:6px}
.w4-coral .pagehead{padding-top:24px}
.w4-coral .pagehead .pills{margin-top:12px}
.w4-coral .cards2{row-gap:12px}
.w4-coral :is(.row,.itemrow){padding:10px 0}
.w4-coral #around>a.more{display:none}
.w4-coral main{display:grid;grid-template-columns:minmax(var(--g),1fr) minmax(0,540px) minmax(0,540px) minmax(var(--g),1fr);align-items:start}
.w4-coral main>*,.w4-coral main>*>*{grid-column:1/-1;min-width:0;width:100%}
.w4-coral[data-page=story] main:has(>[data-editor-id=intro]>.blogwrap #blog):has(>#reviews){grid-auto-flow:row dense}
.w4-coral[data-page=story] main:has(>[data-editor-id=intro]>.blogwrap #blog):has(>#reviews)>[data-editor-id=intro]>.blogwrap{grid-column:2;padding-right:24px}
.w4-coral[data-page=story] main:has(>[data-editor-id=intro]>.blogwrap #blog):has(>#reviews) #blog{padding-left:0;padding-right:0}
.w4-coral[data-page=story] main:has(>[data-editor-id=intro]>.blogwrap #blog):has(>#reviews)>#reviews{grid-column:3;width:auto;margin-top:0}
.w4-coral[data-page=area] main:has(>[data-editor-id=story]>#people):has(>[data-editor-id=story]>#reading)>[data-editor-id=story]>:is(#songs,#people,#chronicle,#reading){display:block;max-width:none;margin:0;min-width:0}
.w4-coral[data-page=area] main:has(>[data-editor-id=story]>#people):has(>[data-editor-id=story]>#reading)>[data-editor-id=story]>:is(#songs,#chronicle){grid-column:2;padding:32px 24px 0 0!important}
.w4-coral[data-page=area] main:has(>[data-editor-id=story]>#people):has(>[data-editor-id=story]>#reading)>[data-editor-id=story]>:is(#people,#reading){grid-column:3;padding:32px 0 0 24px!important}
.w4-coral[data-page=area] main:has(>[data-editor-id=story]>#people):has(>[data-editor-id=story]>#reading)>[data-editor-id=story]>#people .grid2{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(min-width:1024px){.w4-coral{--edge:max(32px,calc((100vw - 1080px) / 2))}
.w4-coral .hero{max-width:none;margin:0;padding:0}
.w4-coral .hero-card{display:block;position:relative;height:min(600px,calc(100svh - 68px));min-height:480px;border-radius:0;overflow:hidden;background:var(--alt)}
.w4-coral .hero-card:not(:has(.car)){height:auto;min-height:0;padding:72px var(--edge)}
.w4-coral .hero-card .car{position:absolute;inset:0;border-radius:0}
.w4-coral .hero-card .car .vp,.w4-coral .hero-card .car .track,.w4-coral .hero-card .car .slide{height:100%}
.w4-coral .hero-card:has(.car):after{display:block;content:'';position:absolute;inset:0;background:linear-gradient(90deg,rgba(0,0,0,.55),rgba(0,0,0,.15) 55%,rgba(0,0,0,0));pointer-events:none}
.w4-coral .hero-card:has(.car) .hero-copy{position:absolute;left:var(--edge);right:auto;bottom:64px;max-width:560px;z-index:1;color:#fff}
.w4-coral .hero-card:has(.car) .hero-copy .eyebrow{color:#fff;opacity:.9}
.w4-coral .hero-card:has(.car) .hero-copy .tagline{color:rgba(255,255,255,.85)}
.w4-coral .hero-card:has(.car) .hero-copy .chip{background:rgba(255,255,255,.2);color:#fff;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px)}
.w4-coral .hero-card:has(.car) .hero-cta .btn.soft{background:#fff;color:var(--ink)}
.w4-coral .hero-copy .hl{font-size:48px}}
.w4-coral .itemcard .cap.dim.clamp{padding-bottom:0;margin-bottom:14px}
.w4-coral .weather .band{flex-wrap:wrap;row-gap:6px}
.w4-coral .weather .band em,.w4-coral .weather .band b{white-space:normal;flex:0 1 auto}
.w4-coral .book .row{flex-direction:column}
.w4-coral #reviews .rhead h2,.w4-coral #postcard-maker .rhead h2{font-size:26px;font-weight:700;line-height:1.35;letter-spacing:-.02em;margin-bottom:8px}
.w4-coral #reviews .rhead .lead,.w4-coral #postcard-maker .rhead .lead{font-size:16px}
.w4-coral #reviews ul>li{border-radius:var(--r-card);background:var(--bg);box-shadow:none}
.w4-coral #reviews>.shell>button{height:48px;border:0;border-radius:14px;background:var(--bg);color:var(--sub);font-size:15px;font-weight:600}
@media(min-width:1024px){.w4-coral .book .row{flex-direction:row}
.w4-coral .rooms{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.w4-coral .amen{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.w4-coral #video .car--150 .slide{flex:0 0 100%}
.w4-coral #video .itemcard img{aspect-ratio:16/9}
.w4-coral #reviews ul{grid-template-columns:repeat(auto-fill,minmax(220px,1fr))!important}
.w4-coral #reviews .rhead h2,.w4-coral #postcard-maker .rhead h2{font-size:28px}
.w4-coral #around .head{align-items:flex-end}}
.w4-coral .room,.w4-coral .place{background:none}
.w4-coral .room .bd,.w4-coral .room--mini .bd{padding:12px 2px 0}
.w4-coral .room .ph,.w4-coral .place .ph{border-radius:var(--r-card);overflow:hidden}
.w4-coral .place .bd,.w4-coral .guide--3 .place .bd,.w4-coral .cards2 .place .bd{padding:10px 2px 0}
.w4-coral .more{display:flex;width:max-content;min-width:200px;max-width:100%;height:44px;margin:20px auto 0;padding:0 24px;border:1px solid var(--line);border-radius:999px;background:var(--bg)}
.w4-coral #about .answer{background:none;padding:0;border-top:1px solid var(--line);border-radius:0;padding-top:18px}
@media(min-width:1024px){.w4-coral{--sy:80px}
.w4-coral .sec h2{font-size:30px;margin-bottom:24px}
.w4-coral #weather.sec{padding-top:56px}
.w4-coral #about.sec{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.1fr);grid-auto-rows:min-content;column-gap:72px;align-items:start}
.w4-coral #about>:not(.photo){grid-column:1}
.w4-coral #about>.photo{grid-column:2;grid-row:1/span 8;aspect-ratio:4/3;height:auto;margin:0}
.w4-coral #about .answer dl{grid-template-columns:repeat(2,minmax(0,1fr));column-gap:24px}
.w4-coral .blogwrap #blog,.w4-coral .blogwrap>.sec{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.1fr);column-gap:72px;align-items:start}
.w4-coral .blogwrap>.sec>:is(.eyebrow,h2,.note){grid-column:1}
.w4-coral .blogwrap>.sec>:not(.eyebrow,h2,.note){grid-column:2}
.w4-coral .blogwrap>.sec>.eyebrow+h2+.note+*{grid-row:1/span 3}
.w4-coral .blogwrap>.sec .more{margin-left:0}
.w4-coral .rooms{gap:24px}
.w4-coral #video .car--150 .slide{flex:0 0 calc((100% - 24px) / 3)}
.w4-coral .gal:not(.gal--rest){grid-template-rows:180px 180px;gap:12px}
.w4-coral .guide--3{gap:24px}}
@media(min-width:1024px){.w4-coral{--sy:64px}
.w4-coral #about>.photo{aspect-ratio:16/10}
.w4-coral[data-page=home] #video.sec{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.1fr);column-gap:72px;align-items:start}
.w4-coral[data-page=home] #video>:is(.eyebrow,.kicker,h2,.note){grid-column:1}
.w4-coral[data-page=home] #video>:not(.eyebrow,.kicker,h2,.note){grid-column:2;grid-row:1/span 4}
.w4-coral[data-page=home] #video .track{padding:0}
.w4-coral[data-page=home] #video .car--150 .slide{flex:0 0 100%}
.w4-coral[data-page=home] #video .itemcard{background:none}
.w4-coral[data-page=home] #video .itemcard img{border-radius:var(--r-card)}}
.w4-coral{--ink:var(--tpl-text,#26323a);--sub:var(--tpl-secondary,#5f6b73);--mute:#5f6d74;--bg:var(--tpl-bg,#f7f9fb);--alt:var(--tpl-card,#eaf2f2);--teal:var(--tpl-accent,#6fa8a8);--accent:var(--teal);--line:#dde6e8;--card:#fff;--en:'Aboreto','Marcellus',serif;--r-hero:0;--r-card:0;--r-sm:0;--k-r:0;font-family:'Gothic A1','Noto Sans KR',sans-serif;font-weight:400}
.w4-coral .top{background:var(--teal);color:#fff;backdrop-filter:none;-webkit-backdrop-filter:none}
.w4-coral .top .wrap{display:grid;grid-template-columns:52px 1fr 52px;align-items:center;height:60px}
.w4-coral .ico{margin:0;color:#fff}
.w4-coral .logo{justify-self:center;font-family:var(--en);font-weight:400;font-size:17px;letter-spacing:.16em;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.w4-coral .logo--ko{font-family:inherit;font-size:17px;letter-spacing:.12em}
.w4-coral .badge{justify-self:end;display:grid;place-items:center;width:52px;height:52px;border-radius:50%;border:1px solid rgba(255,255,255,.7);background:rgba(255,255,255,.12);color:#fff;font-family:var(--en);font-size:10px;line-height:1.25;letter-spacing:.1em;text-transform:uppercase;text-align:center;cursor:pointer;padding:0}
.w4-coral .tabs{gap:0;padding:0 var(--g) 0;border-top:1px solid rgba(255,255,255,.25)}
.w4-coral .tabs a{padding:12px 12px 11px;border-radius:0;background:none;color:rgba(255,255,255,.78);font-size:15px;font-weight:400}
.w4-coral .tabs a.on{background:none;color:#fff;box-shadow:inset 0 -2px #fff}
.w4-coral .kicker,.w4-coral .eyebrow{font-family:var(--en);font-size:13px;font-weight:400;letter-spacing:.22em;text-transform:uppercase;color:var(--teal)}
.w4-coral .sec h2{font-weight:300;font-size:26px;letter-spacing:-.01em}
.w4-coral .lead{font-size:17px;font-weight:300;color:var(--sub)}
.w4-coral .hero{padding:0}
.w4-coral .hero-card{aspect-ratio:auto;height:calc(100svh - 110px);min-height:460px;max-height:720px;border-radius:0}
.w4-coral .hero-card:after{background:linear-gradient(180deg,rgba(0,0,0,0) 40%,rgba(20,40,45,.55))}
.w4-coral .hero-copy{left:var(--g);right:var(--g);bottom:44px;text-align:center}
.w4-coral .hero-copy .eyebrow{color:#fff;font-size:12px;letter-spacing:.3em;margin-bottom:12px}
.w4-coral .hero-copy .hl{font-size:28px;font-weight:300;line-height:1.45;min-height:0}
.w4-coral .hero-copy .tagline{margin-top:10px;font-size:15px;font-weight:300;opacity:.9}
.w4-coral .hero-copy .chips{justify-content:center}
.w4-coral .chip{border-radius:0;background:transparent;border:1px solid rgba(255,255,255,.55);backdrop-filter:none;-webkit-backdrop-filter:none;font-weight:400}
.w4-coral .hero .car .dots{position:absolute;left:0;right:0;bottom:18px;z-index:2;padding:0}
.w4-coral .car .dots button{width:18px;height:1px;border-radius:0;background:rgba(255,255,255,.5)}
.w4-coral .car .dots button.on{width:40px;height:1px;border-radius:0;background:#fff}
.w4-coral .sec:not(.hero) .car .dots button{background:var(--line)}
.w4-coral .sec:not(.hero) .car .dots button.on{background:var(--teal)}
.w4-coral #about{text-align:center}
.w4-coral #about .answer{text-align:left}
.w4-coral .answer,.w4-coral .room,.w4-coral .place,.w4-coral .itemcard,.w4-coral .loc,.w4-coral .amen li,.w4-coral .weather{background:var(--card);border-radius:0}
.w4-coral .answer dd,.w4-coral .spec dd{font-weight:400}
.w4-coral .photo,.w4-coral .gal button,.w4-coral .room--full .car{border-radius:0}
.w4-coral #units{box-shadow:0 0 0 100vmax var(--teal);clip-path:inset(0 -100vmax);background:var(--teal);color:#fff;margin-top:var(--sy);padding-top:44px;padding-bottom:44px}
.w4-coral #units .kicker{color:rgba(255,255,255,.85)}
.w4-coral #units h2{color:#fff}
.w4-coral .room--mini{position:relative;background:none}
.w4-coral .room--mini .ph{aspect-ratio:4/5}
.w4-coral .room--mini:after{content:'';position:absolute;inset:0;background:rgba(20,40,45,.28)}
.w4-coral .room--mini .bd{position:absolute;inset:0;z-index:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:10px;text-align:center;color:#fff}
.w4-coral .room--mini h3{font-weight:400;font-size:15px;white-space:normal}
.w4-coral .room--mini h3 .no{display:block;font-family:var(--en);font-size:30px;line-height:1.1;letter-spacing:.04em;margin-bottom:4px}
.w4-coral .room--mini p{color:rgba(255,255,255,.85);font-size:13px}
.w4-coral #units .more{background:transparent;border:1px solid rgba(255,255,255,.6);color:#fff}
.w4-coral .more{border-radius:0;background:transparent;border:1px solid var(--line);font-weight:400;color:var(--sub)}
.w4-coral .btn{border-radius:0;font-weight:500;letter-spacing:.02em;background:var(--teal)}
.w4-coral .btn.ghost,.w4-coral .room--full .btn.ghost,.w4-coral .btn.soft{background:transparent;border:1px solid var(--teal);color:var(--teal)}
.w4-coral .pill,.w4-coral .tabrow button{border-radius:0;background:transparent;border:1px solid var(--line);font-weight:400}
.w4-coral .tabrow button[aria-pressed=true],.w4-coral .tabrow button[aria-current=true]{background:var(--teal);border-color:var(--teal);color:#fff}
.w4-coral .place small{color:var(--teal);font-weight:400}
.w4-coral .place b,.w4-coral .place h3,.w4-coral .room h3,.w4-coral .itemcard .cap{font-weight:500}
.w4-coral .room--full h2,.w4-coral [id^=unit-] h2{font-weight:300}
.w4-coral .weather{background:var(--card);border-top:1px solid var(--teal)}
.w4-coral .weather>svg{color:var(--teal)}
.w4-coral .weather .deg{font-family:var(--en);font-weight:400;letter-spacing:0}
.w4-coral .weather .cond{font-weight:400}
.w4-coral .weather .note{font-weight:300}
.w4-coral .weather .band em,.w4-coral .weather .band b{border-radius:0;background:var(--alt);color:var(--teal);font-weight:500}
.w4-coral .amen .ic{border-radius:0;background:var(--alt);color:var(--teal)}
.w4-coral .amen li.on{font-weight:500}
.w4-coral .book{border-radius:0;background:var(--teal)}
.w4-coral .book p{color:rgba(255,255,255,.85)}
.w4-coral .book .btn{background:#fff;color:var(--teal)}
.w4-coral .book .btn.ghost{background:transparent;border-color:rgba(255,255,255,.7);color:#fff}
.w4-coral .faq summary{font-weight:400}
.w4-coral .faq summary:after{color:var(--teal)}
.w4-coral footer{background:var(--card);margin-top:var(--sy);padding-bottom:0;overflow:hidden}
.w4-coral footer .ftel{display:block;margin-bottom:20px;color:var(--teal);font-size:26px;font-weight:300;letter-spacing:.02em}
.w4-coral footer .fname{font-weight:500}
.w4-coral .giant{margin:36px 0 0;padding:0 8px;font-family:var(--en);font-size:min(15vw,150px);line-height:.82;letter-spacing:.02em;text-transform:uppercase;text-align:center;color:var(--teal);white-space:nowrap;overflow:hidden;opacity:.9;transform:translateY(12%)}
.w4-coral .giant--ko{font-family:inherit;font-weight:200;font-size:min(13vw,120px);letter-spacing:.06em}
.w4-coral .player{border-radius:0;box-shadow:0 6px 20px rgba(20,40,45,.14)}
.w4-coral #w4d-tape{color:var(--teal)}
.w4-coral #w4d-now{font-weight:500}
.w4-coral #w4d-panel{border-radius:0}
.w4-coral .tabbar{left:0;right:0;bottom:0;gap:0;padding:0 0 env(safe-area-inset-bottom);border-radius:0;box-shadow:0 -1px 0 var(--line)}
.w4-coral .tabbar>a,.w4-coral .tabbar>button{height:58px;border-radius:0;font-weight:400}
.w4-coral .tabbar .cta{background:var(--teal);font-weight:500;letter-spacing:.04em}
@media (min-width:700px) and (max-width:1023px){.w4-coral .tabbar{left:var(--side);right:var(--side)}}
@media(min-width:1024px){.w4-coral .top{display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;column-gap:24px;height:76px;padding:0 32px;border-bottom:0}
.w4-coral .top .wrap{display:contents}
.w4-coral .top .ico{display:none}
.w4-coral .logo{grid-column:2;grid-row:1;font-size:20px}
.w4-coral .tabs{grid-column:1;grid-row:1;border:0;padding:0;gap:4px}
.w4-coral .tabs a{padding:8px 10px;font-size:15px}
.w4-coral .tabs a.on{box-shadow:inset 0 -1px #fff}
.w4-coral .hd-cta{grid-column:3;grid-row:1;justify-self:end;margin-right:72px}
.w4-coral .hd-cta .cta{display:none}
.w4-coral .hd-cta>a{background:none;color:#fff;font-weight:400;padding:0 8px}
.w4-coral .badge{grid-column:3;grid-row:1;width:60px;height:60px;font-size:11px}
.w4-coral .hero-card{height:min(680px,calc(100svh - 76px))}
.w4-coral .hero-card:has(.car):after{background:linear-gradient(180deg,rgba(0,0,0,0) 45%,rgba(20,40,45,.55))}
.w4-coral .hero-card:has(.car) .hero-copy{left:0;right:0;bottom:72px;max-width:760px;margin:0 auto;text-align:center}
.w4-coral .hero-copy .hl{font-size:40px;font-weight:300}
.w4-coral .hero-copy .eyebrow{font-size:13px}
.w4-coral .hero-cta{justify-content:center}
.w4-coral .hero-card:has(.car) .hero-cta .btn{background:#fff;color:var(--teal)}
.w4-coral .hero-card:has(.car) .hero-cta .btn.soft{background:transparent;border:1px solid rgba(255,255,255,.7);color:#fff}
.w4-coral .sec h2{font-weight:300;font-size:28px}
.w4-coral #units{display:grid;grid-template-columns:minmax(0,4fr) minmax(0,7fr);grid-template-rows:auto auto 1fr;column-gap:64px;align-items:start;padding-top:64px;padding-bottom:64px}
.w4-coral #units>.kicker{grid-column:1;grid-row:1;position:sticky;top:100px}
.w4-coral #units>h2{grid-column:1;grid-row:2;position:sticky;top:128px;font-size:34px}
.w4-coral #units>.rooms{grid-column:2;grid-row:1/span 3;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}
.w4-coral #units>.more{grid-column:1;grid-row:3;margin:24px 0 0;max-width:240px;align-self:start;position:sticky;top:200px}
.w4-coral .room--mini .ph{aspect-ratio:4/3}
.w4-coral .room--mini .bd{justify-content:flex-end;align-items:flex-start;text-align:left;padding:22px 24px}
.w4-coral .room--mini h3{font-size:16px}
.w4-coral .room--mini h3 .no{font-size:44px}
.w4-coral footer .ftel{font-size:30px;flex-basis:100%}
.w4-coral footer .wrap{padding-top:24px}}
.w4-coral .rooms>.room--mini:nth-child(odd):last-child{grid-column:1/-1}
.w4-coral .rooms>.room--mini:nth-child(odd):last-child .ph{aspect-ratio:2/1}
.w4-coral .giant{font-size:min(12vw,120px);transform:none;margin-bottom:-.14em}
.w4-coral .hero-copy .eyebrow{letter-spacing:.14em}
.w4-coral .room--mini:after{background:rgba(20,40,45,.34)}
@media(min-width:1024px){.w4-coral #about{text-align:left}}
@media(min-width:1024px){.w4-coral .blogwrap #blog,.w4-coral .blogwrap>.sec,.w4-coral[data-page=home] #video.sec,.w4-coral #about.sec,.w4-coral #weather.sec{display:block}
.w4-coral .blogwrap>.sec>:not(.eyebrow,h2,.note){max-width:760px}
.w4-coral .blogwrap>.sec .more{margin-left:0}
.w4-coral[data-page=home] #video .car{max-width:640px}
.w4-coral #about>.photo{aspect-ratio:21/9;margin-top:24px}
.w4-coral #about.sec:has(>.lead):has(>.photo){display:grid}}
@media(min-width:1024px){.w4-coral #weather>h2{margin-bottom:16px}
.w4-coral #about .answer{max-width:560px}}
.w4-coral #about .amen{grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:2px 16px;margin:16px 0 0}
.w4-coral #about .amen li{padding:8px 0;border:0;border-radius:0;background:none;font-size:16px;color:var(--ink);font-weight:400}
.w4-coral #about .amen li b{font-weight:700}
.w4-coral #about .amen .ic{width:auto;height:auto;background:none;border-radius:0;font-style:normal}
.w4-coral #about .amen li.on .ic{color:#15803d}
.w4-coral #about .amen li.off .ic{color:#c2410c}
.w4-coral #about .amen li.off{color:var(--sub)}
.w4-coral :is(h1,h2,h3){font-family:var(--tpl-font-heading)}

View File

@ -0,0 +1,69 @@
const base = {
viewBox: '0 0 24 24',
width: 20,
height: 20,
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.8,
'aria-hidden': true,
} as const;
export function MapPinIcon() {
return (
<svg {...base}>
<path d="M12 21s-7-6.2-7-11.5A7 7 0 0 1 19 9.5C19 14.8 12 21 12 21z" />
<circle cx="12" cy="9.5" r="2.5" />
</svg>
);
}
export function TalkIcon() {
return (
<svg {...base}>
<path d="M12 4C7 4 3 7.1 3 11c0 2.5 1.7 4.7 4.2 5.9L6.5 20l3.6-2.3c.6.1 1.2.1 1.9.1 5 0 9-3.1 9-6.9S17 4 12 4z" />
</svg>
);
}
export function PhoneIcon() {
return (
<svg {...base}>
<path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2" />
</svg>
);
}
export function SunIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" aria-hidden>
<circle cx="12" cy="12" r="5" />
<path d="M12 1v3M12 20v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M1 12h3M20 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1" />
</svg>
);
}
export function CloudIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" aria-hidden>
<path d="M7 18h10a4 4 0 0 0 .6-8A6 6 0 0 0 6 9.5 4.3 4.3 0 0 0 7 18z" />
</svg>
);
}
export function RainIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" aria-hidden>
<path d="M7 15h10a4 4 0 0 0 .6-8A6 6 0 0 0 6 6.5 4.3 4.3 0 0 0 7 15z" />
<path d="M8 18l-1 3M12 18l-1 3M16 18l-1 3" />
</svg>
);
}
export function SnowIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" aria-hidden>
<path d="M12 2v20M3.3 7l17.4 10M3.3 17L20.7 7" />
<path d="M9.5 3.5L12 6l2.5-2.5M9.5 20.5L12 18l2.5 2.5" />
</svg>
);
}

View File

@ -0,0 +1,45 @@
import type {Layout} from '@site/lib/layout';
import {Festival} from '../paper/Festival';
import {Info} from '../paper/Info';
import {Itinerary} from '../paper/Itinerary';
import {Story} from '../paper/Story';
import {StoryTail} from '../paper/StoryTail';
import {Video} from '../paper/Video';
import {Around} from './Around';
import {Booking} from './Booking';
import {Faq} from './Faq';
import {Frame} from './Frame';
import {Gallery} from './Gallery';
import {Hero} from './Hero';
import {Intro} from './Intro';
import {Location} from './Location';
import {Rooms} from './Rooms';
import {SectionHead} from './SectionHead';
const Nothing = () => null;
export const layout: Layout = {
Frame,
SectionHead,
sections: {
hero: Hero,
weather: Nothing,
intro: Intro,
blog: Nothing,
rooms: Rooms,
menu: Rooms,
programs: Rooms,
photos: Gallery,
video: Video,
local: Around,
booking: Booking,
info: Info,
map: Location,
faq: Faq,
festival: Festival,
itinerary: Itinerary,
story: Story,
reviews: StoryTail,
postcardMaker: Nothing,
},
};

View File

@ -0,0 +1,13 @@
import {useEffect, useState} from 'react';
export function useWide(): boolean {
const [wide, setWide] = useState(false);
useEffect(() => {
const query = window.matchMedia('(min-width: 1024px)');
const sync = () => setWide(query.matches);
sync();
query.addEventListener('change', sync);
return () => query.removeEventListener('change', sync);
}, []);
return wide;
}

View File

@ -0,0 +1,163 @@
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 {SpotIcon} from './Art';
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 FIRST_SPOTS = 6;
const FIRST_EATS = 5;
const HOME_ROWS = 3;
const TILE = ['#fff1c9', '#dff0e4', '#ffe4d6'];
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, value, onChange}: {places: LocalPlace[]; value: string; onChange: (id: string) => void}) {
const tabs = RANGES.filter((r, i) => {
const n = places.filter((p) => r.test(meters(p))).length;
return i === 0 || (n > 0 && n < places.length);
});
if (tabs.length < 2) return null;
return (
<div className="tabrow" role="tablist">
{tabs.map((r) => (
<button key={r.id} type="button" role="tab" aria-current={r.id === value} onClick={() => onChange(r.id)}>
{r.label} {places.filter((p) => r.test(meters(p))).length}
</button>
))}
</div>
);
}
export function Around() {
const payload = useSite();
const {local} = payload;
const spots = [...local.attractions].sort(byDistance);
const eats = [...local.restaurants].sort(byDistance);
const food = new Set(eats);
const nearest = [...spots, ...eats].sort(byDistance);
const [open, setOpen] = useState<LocalPlace | null>(null);
const [spotRange, setSpotRange] = useState('all');
const [eatRange, setEatRange] = useState('all');
const firstSpots = useWide() ? 8 : FIRST_SPOTS;
if (nearest.length === 0) return null;
const row = (place: LocalPlace, i: number, compact?: boolean) => (
<button key={place.name} type="button" className={compact ? 'gitem gitem--s' : 'gitem'} onClick={() => setOpen(place)}>
<span className="ic" style={{background: TILE[i % TILE.length]}}>
{place.imageUrl ? <img src={place.imageUrl} alt="" loading="lazy" /> : <SpotIcon food={food.has(place)} />}
</span>
<span className="gt">
<b>{place.name}</b>
{!compact && (place.description || place.category) && <span className="clamp">{place.description ?? place.category}</span>}
</span>
{place.distanceText && <small>{place.distanceText}</small>}
</button>
);
return (
<>
<div data-pg="home">
<section className="sec" id="around" aria-labelledby="around-heading">
<div className="head">
<div>
<span className="bub">주변</span>
<h2 id="around-heading" className="disp">
주변 안내
</h2>
</div>
<a href="#/area">지역 소개 →</a>
</div>
<div className="guide">{nearest.slice(0, HOME_ROWS).map((p, i) => row(p, i, true))}</div>
<a className="more" href="#/area">
지역 소개에서 모두 보기
</a>
</section>
</div>
<div data-pg="area" id="guide">
{spots.length > 0 && (
<section className="sec" id="walk">
<span className="bub">명소</span>
<h2 className="disp">
주변 명소 <span>{spots.length}곳</span>
</h2>
<p className="note">걸어서 닿는 곳부터 차로 가는 곳까지 묶었습니다.</p>
<RangeTabs places={spots} value={spotRange} onChange={setSpotRange} />
{(() => {
const shown = spots.filter((p) => inRange(spotRange)(meters(p)));
return (
<>
<div className="guide guide--card">{shown.slice(0, firstSpots).map((p, i) => row(p, i))}</div>
{shown.length > firstSpots && (
<details>
<summary className="more">{shown.length - firstSpots}곳 더 보기</summary>
<div className="guide guide--card">{shown.slice(firstSpots).map((p, i) => row(p, i + firstSpots))}</div>
</details>
)}
</>
);
})()}
</section>
)}
{eats.length > 0 && (
<section className="sec" id="eat">
<span className="bub">맛집</span>
<h2 className="disp">
주변 맛집 <span>{eats.length}곳</span>
</h2>
<RangeTabs places={eats} value={eatRange} onChange={setEatRange} />
{(() => {
const shown = eats.filter((p) => inRange(eatRange)(meters(p)));
return (
<>
<div className="guide guide--two">{shown.slice(0, FIRST_EATS).map((p, i) => row(p, i))}</div>
{shown.length > FIRST_EATS && (
<details>
<summary className="more">{shown.length - FIRST_EATS}곳 더 보기</summary>
<div className="guide guide--two">{shown.slice(FIRST_EATS).map((p, i) => row(p, i + FIRST_EATS))}</div>
</details>
)}
</>
);
})()}
</section>
)}
</div>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
{open && (
<>
{open.imageUrl && <img src={open.imageUrl} alt={open.name} style={{aspectRatio: '3 / 2'}} />}
<h3>{open.name}</h3>
<p className="meta">{[open.distanceText, open.category].filter(Boolean).join(' · ')}</p>
{open.description && <p>{open.description}</p>}
<a
className="out"
href={naverMapSearchUrl(open.searchQuery ?? open.name)}
target="_blank"
rel="noopener noreferrer"
>
네이버에서 보기
</a>
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,255 @@
import type {CSSProperties, ReactNode} from 'react';
const STARS = Array.from({length: 24}, (_, i) => ({
left: `${(i * 37 + 11) % 100}%`,
top: `${(i * 53 + 7) % 30}%`,
animationDelay: `${((i * 0.37) % 3).toFixed(2)}s`,
}));
const CLOUDS: CSSProperties[] = [
{top: '14%', width: 90, animationDuration: '38s'},
{top: '30%', width: 130, animationDuration: '52s', animationDelay: '-20s'},
{top: '44%', width: 70, animationDuration: '44s', animationDelay: '-33s'},
];
export function Sky() {
return (
<>
<div className="sun" aria-hidden />
{STARS.map((style, i) => (
<i key={i} className="star" style={style} aria-hidden />
))}
{CLOUDS.map((style, i) => (
<div key={i} className="cloud" style={style} aria-hidden />
))}
</>
);
}
export function Hills() {
return (
<svg className="hills" viewBox="0 0 390 300" preserveAspectRatio="none" aria-hidden>
<path fill="#4b8a66" d="M0 120 C60 80 120 90 180 110 S300 70 390 100 V300 H0Z" />
<path className="f-h1" d="M0 170 C80 130 150 150 220 165 S340 130 390 150 V300 H0Z" />
<g className="f-h2">
<path d="M40 190 l18 -60 l18 60z" />
<path d="M70 200 l14 -48 l14 48z" />
<path d="M300 180 l20 -66 l20 66z" />
<path d="M335 195 l13 -44 l13 44z" />
</g>
<path className="f-h2" d="M0 220 C90 190 170 205 240 215 S350 195 390 205 V300 H0Z" />
<g transform="translate(168 180)">
<path fill="#fff7ec" d="M0 30 L26 8 L52 30 V56 H0Z" />
<path className="f-ac" d="M-6 32 L26 4 L58 32 L52 34 L26 12 L0 34Z" />
<rect className="f-h3" x="20" y="36" width="12" height="20" />
<rect className="glow" x="36" y="36" width="10" height="9" fill="#ffd36b" />
</g>
<path className="f-h3" d="M0 260 C100 240 200 255 270 250 S360 240 390 245 V300 H0Z" />
</svg>
);
}
export function RoomArt({night}: {night?: boolean}) {
if (night) {
return (
<div className="art a2" aria-hidden>
<svg viewBox="0 0 350 210" preserveAspectRatio="xMidYMax slice">
<circle cx="60" cy="40" r="16" fill="#fff3c4" />
<g fill="#fff">
<circle className="tw" cx="150" cy="30" r="2" />
<circle className="tw" cx="220" cy="50" r="1.5" style={{animationDelay: '.8s'}} />
<circle className="tw" cx="300" cy="24" r="2" style={{animationDelay: '1.6s'}} />
<circle className="tw" cx="260" cy="80" r="1.5" style={{animationDelay: '2.2s'}} />
</g>
<path d="M0 160 C90 130 200 150 350 130 V210 H0Z" className="f-h2" />
<g transform="translate(120 96)">
<path d="M0 50 L55 10 L110 50 V110 H0Z" fill="#3b3a7a" />
<path d="M-10 52 L55 2 L120 52" stroke="#ffd36b" strokeWidth="10" fill="none" strokeLinejoin="round" />
<rect className="glow glow--slow" x="20" y="62" width="22" height="20" fill="#ffd36b" />
</g>
</svg>
</div>
);
}
return (
<div className="art a1" aria-hidden>
<svg viewBox="0 0 350 210" preserveAspectRatio="xMidYMax slice">
<circle cx="280" cy="50" r="26" fill="#ffd36b" />
<path d="M0 150 C80 120 180 140 350 120 V210 H0Z" fill="#8fd1a4" />
<g transform="translate(120 90)">
<path d="M0 50 L55 10 L110 50 V110 H0Z" fill="#fff" />
<path d="M-10 52 L55 2 L120 52" className="s-ac" strokeWidth="10" fill="none" strokeLinejoin="round" />
<rect x="42" y="66" width="26" height="44" rx="4" className="f-h1" />
</g>
</svg>
</div>
);
}
const ROAD = 'M-10 220 C80 200 110 120 200 110 S320 60 360 40';
export function MapArt({children}: {children?: ReactNode}) {
return (
<div className="map">
<svg className="road" viewBox="0 0 350 280" preserveAspectRatio="none" aria-hidden>
<path d={ROAD} stroke="#fff" strokeWidth="18" fill="none" strokeLinecap="round" />
<path className="dash s-ac" d={ROAD} strokeWidth="3" strokeDasharray="8 10" fill="none" />
</svg>
<div className="pin me" aria-hidden />
{children}
</div>
);
}
export function Fire({side, delay}: {side: 'left' | 'right'; delay?: string}) {
return (
<svg className="fire" style={{[side]: 18}} viewBox="0 0 60 60" aria-hidden>
<g className="flame" style={delay ? {animationDelay: delay} : undefined}>
<path
className="f-ac"
d="M30 8c8 10 14 16 14 26a14 14 0 0 1-28 0c0-7 4-11 7-15 1 5 4 8 7 8-3-8 0-14 0-19z"
/>
<path d="M30 30c4 5 6 8 6 12a6 6 0 0 1-12 0c0-4 3-6 6-12z" fill="#ffd36b" />
</g>
</svg>
);
}
type IconKind = 'fire' | 'tub' | 'star' | 'lock' | 'car' | 'wifi' | 'paw' | 'pot' | 'leaf';
const ICON_RULES: [RegExp, IconKind][] = [
[/불|바비큐|바베큐|그릴|화로|캠프|장작/, 'fire'],
[/욕조|스파|온수|수영|풀|자쿠지|월풀|목욕/, 'tub'],
[/별|전망|테라스|정원|마당|바다|뷰/, 'star'],
[/체크인|도어락|셀프|보안|키/, 'lock'],
[/주차/, 'car'],
[/와이파이|wi-?fi|인터넷/i, 'wifi'],
[/반려|애견|펫/, 'paw'],
[/취사|주방|조리|식기|냉장고|전자레인지|조식/, 'pot'],
];
export function iconKind(label: string): IconKind {
return ICON_RULES.find(([re]) => re.test(label))?.[1] ?? 'leaf';
}
export function AmenityIcon({label, delay}: {label: string; delay?: number}) {
const style = delay ? {animationDelay: `${delay}s`} : undefined;
const kind = iconKind(label);
return (
<svg className="float" style={style} viewBox="0 0 48 48" aria-hidden>
{kind === 'fire' && (
<>
<path
d="M24 6c6 8 10 12 10 20a10 10 0 0 1-20 0c0-5 3-8 5-11 1 4 3 6 5 6-2-6 0-11 0-15z"
className="f-ac"
/>
<path d="M24 22c3 4 5 6 5 10a5 5 0 0 1-10 0c0-3 3-5 5-10z" fill="#ffd36b" />
<rect x="10" y="38" width="28" height="4" rx="2" fill="#6b4a2f" />
</>
)}
{kind === 'tub' && (
<>
<rect x="8" y="20" width="32" height="16" rx="8" fill="#6aa9ff" />
<path d="M12 20c0-5 5-8 12-8s12 3 12 8" fill="none" stroke="#1d2b24" strokeWidth="3" />
<path d="M14 30c3-2 6 2 10 0s7-2 10 0" stroke="#fff" strokeWidth="2" fill="none" />
</>
)}
{kind === 'star' && (
<>
<circle cx="18" cy="22" r="3" fill="#ffd36b" />
<circle cx="32" cy="14" r="2" fill="#ffd36b" />
<circle cx="36" cy="28" r="2.5" fill="#ffd36b" />
<path d="M8 40c6-10 26-10 32 0" className="f-h1" />
</>
)}
{kind === 'lock' && (
<>
<rect x="10" y="22" width="28" height="16" rx="4" className="f-ac" />
<path d="M14 22v-4a10 10 0 0 1 20 0v4" stroke="#1d2b24" strokeWidth="3" fill="none" />
<circle cx="24" cy="30" r="3" fill="#fff" />
</>
)}
{kind === 'car' && (
<>
<path d="M9 30l4-10a4 4 0 0 1 4-3h14a4 4 0 0 1 4 3l4 10v7H9z" fill="#6aa9ff" />
<path d="M15 24l2-4h14l2 4z" fill="#fff" />
<circle cx="16" cy="37" r="4" fill="#1d2b24" />
<circle cx="32" cy="37" r="4" fill="#1d2b24" />
</>
)}
{kind === 'wifi' && (
<>
<path d="M8 20a22 22 0 0 1 32 0" className="s-ac" strokeWidth="4" fill="none" strokeLinecap="round" />
<path d="M14 27a13 13 0 0 1 20 0" stroke="#ffd36b" strokeWidth="4" fill="none" strokeLinecap="round" />
<circle cx="24" cy="35" r="4" className="f-h1" />
</>
)}
{kind === 'paw' && (
<>
<ellipse cx="24" cy="31" rx="9" ry="7" className="f-ac" />
<circle cx="13" cy="21" r="4" fill="#ffd36b" />
<circle cx="20" cy="14" r="4" fill="#ffd36b" />
<circle cx="28" cy="14" r="4" fill="#ffd36b" />
<circle cx="35" cy="21" r="4" fill="#ffd36b" />
</>
)}
{kind === 'pot' && (
<>
<path d="M10 22h28v8a10 10 0 0 1-10 10h-8a10 10 0 0 1-10-10z" className="f-ac" />
<rect x="6" y="19" width="36" height="4" rx="2" fill="#1d2b24" />
<path d="M19 14c0-3 3-3 3-6M27 14c0-3 3-3 3-6" stroke="#6aa9ff" strokeWidth="2.5" fill="none" strokeLinecap="round" />
</>
)}
{kind === 'leaf' && (
<>
<path d="M24 40C12 34 10 20 16 8c10 4 18 12 16 24-1 5-4 8-8 8z" className="f-h1" />
<path d="M24 40c-2-10-2-18-6-26" stroke="#ffd36b" strokeWidth="2.5" fill="none" strokeLinecap="round" />
</>
)}
</svg>
);
}
export function SpotIcon({food}: {food?: boolean}) {
if (food) {
return (
<svg viewBox="0 0 48 48" aria-hidden>
<path d="M8 24h32a16 16 0 0 1-32 0z" className="f-ac" />
<rect x="6" y="21" width="36" height="4" rx="2" fill="#1d2b24" />
<path d="M18 16c0-3 3-3 3-6M27 16c0-3 3-3 3-6" stroke="#6aa9ff" strokeWidth="2.5" fill="none" strokeLinecap="round" />
</svg>
);
}
return (
<svg viewBox="0 0 48 48" aria-hidden>
<path d="M4 40l14-22 8 11 6-8 12 19z" className="f-h1" />
<path d="M18 18l4 6-4-2-4 2z" fill="#fff" />
<circle cx="36" cy="12" r="5" fill="#ffd36b" />
</svg>
);
}
export function PinIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M12 21s-7-6.2-7-12a7 7 0 0 1 14 0c0 5.8-7 12-7 12z" />
<circle cx="12" cy="9" r="2.5" />
</svg>
);
}
export function ChatIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M12 4c5 0 9 3.1 9 7s-4 7-9 7c-1 0-2-.1-2.9-.4L5 19l1.1-3.4C4.2 14.3 3 12.7 3 11c0-3.9 4-7 9-7z" />
</svg>
);
}
export function PhoneIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2" />
</svg>
);
}

View File

@ -0,0 +1,61 @@
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';
import {Fire} from './Art';
export function Booking() {
const payload = useSite();
const links = bookingLinks(payload);
const stay = payload.place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
const phone = payload.place.phone;
const book = () => window.dispatchEvent(new Event(BOOK_EVENT));
const box = (full: boolean) => (
<div className="book">
<Fire side="left" />
<Fire side="right" delay=".2s" />
<h2 id={full ? 'booking-heading' : 'booking-home-heading'} className="disp">
이용안내 및 예약
</h2>
<p>요금과 예약 방법을 안내합니다.</p>
<div className="btns">
{stay && (
<button type="button" className="btn disp" onClick={book}>
예약하기
</button>
)}
{(full ? links : links.slice(0, 1)).map((link) => (
<a key={link.url} className="btn btn--line" href={link.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(link)}
</a>
))}
{full && phone && (
<a className="btn btn--line" href={`tel:${phone}`}>
{phone}
</a>
)}
{!full && (
<a className="btn btn--line" href="#/stay">
이용안내 보기
</a>
)}
</div>
</div>
);
return (
<>
<div data-pg="home">
<section className="sec sec--box" aria-labelledby="booking-home-heading">
{box(false)}
</section>
</div>
<div data-pg="stay">
<section className="sec sec--box" id="booking" aria-labelledby="booking-heading">
{box(true)}
</section>
</div>
</>
);
}

View File

@ -0,0 +1,37 @@
import {SourceType} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {faqList} from '@site/lib/derive';
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]) => (
<details key={faq.faqId}>
<summary>{faq.question}</summary>
<p>{faq.answer}</p>
</details>
);
return (
<section className="sec" id="faq" aria-labelledby="faq-heading">
<span className="bub">궁금해요</span>
<h2 id="faq-heading" className="disp">
자주 묻는 질문 <span>{faqs.length}</span>
</h2>
{verified && <p className="note">아래 답변은 모두 사업자가 확인한 내용입니다.</p>}
<div className="faq">
{faqs.slice(0, FIRST).map(item)}
{faqs.length > FIRST && (
<details className="rest">
<summary className="more">{faqs.length - FIRST}개 더 보기</summary>
{faqs.slice(FIRST).map(item)}
</details>
)}
</div>
</section>
);
}

View File

@ -0,0 +1,75 @@
import {useState} from 'react';
import type {FestivalEntry} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverSearchUrl} from '@site/lib/format';
import {Sheet} from '../paper/Sheet';
const SEASONS = ['봄', '여름', '가을', '겨울'];
export function Festival() {
const payload = useSite();
const festivals = payload.local.festivals;
const [season, setSeason] = useState('');
const [open, setOpen] = useState<FestivalEntry | null>(null);
if (festivals.length === 0) return null;
const tabs = SEASONS.filter((s) => festivals.some((f) => f.season === s));
const shown = festivals.filter((f) => !season || f.season === season);
return (
<>
<section className="wrap" id="festival" aria-labelledby="festival-heading">
<p className="label">SEASON</p>
<h2 id="festival-heading">
계절별 축제 <span className="cnt">{festivals.length}</span>
</h2>
<p className="lead">{payload.place.addressLocality ?? '이 지역'}의 축제와 행사를 계절로 묶었습니다.</p>
{tabs.length > 1 && (
<div className="chips" role="tablist">
{['', ...tabs].map((s) => (
<button
key={s || 'all'}
type="button"
role="tab"
aria-current={s === season}
onClick={() => setSeason(s)}
>
{s || '전체'} {festivals.filter((f) => !s || f.season === s).length}
</button>
))}
</div>
)}
<div className="rail">
{shown.map((f) => (
<button key={f.name} type="button" className="card" onClick={() => setOpen(f)}>
{f.imageUrl ? (
<img src={f.imageUrl} alt={f.name} loading="lazy" />
) : (
<span className="noimg">{f.name}</span>
)}
<b className="nm">{f.name}</b>
<span className="ds">{[f.month, f.location].filter(Boolean).join(' · ')}</span>
</button>
))}
</div>
</section>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
{open && (
<>
{open.imageUrl && <img src={open.imageUrl} alt={open.name} />}
<h3>{open.name}</h3>
<p className="meta">{[open.period ?? open.month, open.location].filter(Boolean).join(' · ')}</p>
{open.description && <p>{open.description}</p>}
<a
className="out"
href={open.officialUrl ?? naverSearchUrl(open.searchQuery)}
target="_blank"
rel="noopener noreferrer"
>
자세히 보기
</a>
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,286 @@
import {useEffect, useState, 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 {ChatIcon, PhoneIcon, PinIcon} from './Art';
import '../kit/kit.css';
import './graphic.css';
const HOUSE_WORD: Record<number, string> = {
[PlaceCategory.LODGING]: '펜션',
[PlaceCategory.CAFE]: '카페',
[PlaceCategory.RESTAURANT]: '가게',
[PlaceCategory.CLINIC]: '병원',
};
type PageId = 'home' | 'area' | 'stay' | 'story';
const PAGE_HEAD: Record<Exclude<PageId, 'home'>, {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<PageId>('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 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 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.5fr'] : [])].join(' ');
return (
<div className="w4-graphic" data-page={page}>
<div className="hdr">
<header className="top">
<a className="disp" href="#/">
{place.name}
</a>
{place.phone && (
<a className="ico" href={`tel:${place.phone}`} aria-label="전화">
<PhoneIcon />
</a>
)}
</header>
<nav className="tabs" aria-label="페이지">
{tabs.map((tab) => (
<a
key={tab.page}
href={tab.page === 'home' ? '#/' : `#/${tab.page}`}
className={tab.page === page ? 'on' : undefined}
aria-current={tab.page === page ? 'page' : undefined}
>
{tab.label}
</a>
))}
</nav>
{(place.phone || hasCta) && (
<div className="hact">
{place.phone && (
<a className="hbtn" href={`tel:${place.phone}`}>
<PhoneIcon />
{place.phone}
</a>
)}
{stayBooking ? (
<button type="button" className="hbtn on" onClick={() => setBookingOpen(true)}>
예약하기
</button>
) : (
booking && (
<a className="hbtn on" href={booking.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(booking)}
</a>
)
)}
</div>
)}
</div>
{(Object.keys(PAGE_HEAD) as Exclude<PageId, 'home'>[]).map((id) => (
<section key={id} data-pg={id} className="sec pagehead">
<span className="bub">{PAGE_HEAD[id].title}</span>
<h2 className="disp">
{id === 'area' ? (place.addressLocality?.replace(/(시|군)$/, '') ?? PAGE_HEAD.area.title) : PAGE_HEAD[id].title}
</h2>
<p className="lead">{PAGE_HEAD[id].note}</p>
{id === 'area' && areaPills.length > 0 && (
<div className="pills">
{areaPills.map(([label, href]) => (
<a key={href} href={href}>
{label}
</a>
))}
</div>
)}
</section>
))}
{children}
{channels.length > 0 && (
<div data-pg="stay">
<section id="channels" className="sec">
<span className="bub">채널</span>
<h2 className="disp">공식 채널</h2>
<div className="list">
{channels.map((link) => (
<a key={link.url} className="row" href={link.url} target="_blank" rel="noopener noreferrer">
<span>{channelLabel(link)}</span>
<span className="r">열기 ↗</span>
</a>
))}
</div>
</section>
</div>
)}
<footer>
<svg className="hill" viewBox="0 0 390 60" preserveAspectRatio="none" aria-hidden>
<path d="M0 40 C80 10 160 30 230 25 S350 5 390 20 V60 H0Z" />
</svg>
<p className="fname disp">{place.name}</p>
{address && <p>{address}</p>}
{hours.length > 0 && <p>{hours.join(' · ')}</p>}
{(place.phone || instagram) && (
<p>
{place.phone && <a href={`tel:${place.phone}`}>{place.phone}</a>}
{place.phone && instagram && ' · '}
{instagram && (
<a href={instagram.url} target="_blank" rel="noopener noreferrer">
instagram
</a>
)}
</p>
)}
{place.legal?.businessRegistrationNumber && (
<p>
{place.legal.representative && `대표 ${place.legal.representative} · `}
사업자등록번호 {place.legal.businessRegistrationNumber}
{place.legal.mailOrderNumber && ` · 통신판매업신고 ${place.legal.mailOrderNumber}`}
</p>
)}
<p className="byline credit">
<span className="author">작성·운영 {place.name}</span> · 최종 업데이트{' '}
<time dateTime={site.updatedAt}>{isoDate(site.updatedAt)}</time> ·{' '}
<a href="https://www.o2osolution.ai/" target="_blank" rel="noopener noreferrer">
AI O2O
</a>
의 Web4Ai로 만든 사이트
</p>
</footer>
{hasSongs && (
<div className={showBar ? 'player' : 'player low'}>
<SongPlayer />
</div>
)}
{showBar && (
<nav className="tabbar" aria-label="연락 · 예약" style={{gridTemplateColumns: columns}}>
{address && (
<a href={naverDirectionsUrl(place.name, place.latitude, place.longitude)} target="_blank" rel="noopener noreferrer nofollow">
<PinIcon />
길찾기
</a>
)}
{kakao && (
<a href={kakao.url} target="_blank" rel="noopener noreferrer">
<ChatIcon />
카톡
</a>
)}
{place.phone && (
<a href={`tel:${place.phone}`}>
<PhoneIcon />
전화
</a>
)}
{stayBooking ? (
<button type="button" className="cta" onClick={() => setBookingOpen(true)}>
예약하기
</button>
) : (
booking && (
<a className="cta" href={booking.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(booking)}
</a>
)
)}
</nav>
)}
{stayBooking && (
<Sheet open={bookingOpen} onClose={() => setBookingOpen(false)} label="예약 요청" plain>
<div className="sform">
<h3>예약 요청</h3>
<BookingForm />
</div>
</Sheet>
)}
</div>
);
}

View File

@ -0,0 +1,47 @@
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';
const FIRST = 5;
export function Gallery() {
const payload = useSite();
const images = galleryImages(payload);
const [open, setOpen] = useState<MediaItem | null>(null);
if (images.length === 0) return null;
const card = (image: MediaItem) => (
<button key={image.mediaId} type="button" className="shot" onClick={() => setOpen(image)}>
<img src={image.url} alt={image.alt} loading="lazy" />
</button>
);
return (
<div data-pg="home">
<section className="sec" id="gallery" aria-labelledby="gallery-heading">
<span className="bub">사진</span>
<h2 id="gallery-heading" className="disp">
{sectionName(payload, 'photos', '사진 갤러리')} <span>{images.length}장</span>
</h2>
<div className="shots shots--lead">{images.slice(0, FIRST).map(card)}</div>
{images.length > FIRST && (
<details>
<summary className="more">사진 {images.length - FIRST}장 더 보기</summary>
<div className="shots">{images.slice(FIRST).map(card)}</div>
</details>
)}
</section>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.alt ?? ''}>
{open && (
<>
<img src={open.url} alt={open.alt} style={{aspectRatio: '3 / 2'}} />
<h3>{open.alt}</h3>
{open.category && <p className="meta">{open.category}</p>}
</>
)}
</Sheet>
</div>
);
}

View File

@ -0,0 +1,156 @@
import {useEffect, useState, type CSSProperties} from 'react';
import {PlaceCategory, type WeatherSky} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {useLiveWeather} from '@site/lib/use-live-weather';
import {useWeatherNotes} from '@site/lib/use-weather-note';
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView, weatherBand, weatherMood} from '@site/lib/derive';
import {BOOK_EVENT} from '../paper/Rooms';
import lines from '@site/lib/lodging-catchphrases.json';
import {Hills, Sky} from './Art';
const STEP_MS = 35;
const ROTATE_MS = 4500;
export function Pop({text}: {text: string}) {
const words = text.split(/\s+/).map((word) => [...word]);
const starts = words.map((_, w) => words.slice(0, w).reduce((sum, chars) => sum + chars.length, 0));
return (
<span className="in">
{words.map((chars, w) => (
<span key={w} className="wd">
{w > 0 && ' '}
<span className="wn">
{chars.map((ch, c) => (
<span key={c} style={{animationDelay: `${(starts[w] + c) * STEP_MS}ms`} as CSSProperties}>
{ch}
</span>
))}
</span>
</span>
))}
</span>
);
}
function useCatchphrase(): string | undefined {
const {place, narrative} = useSite();
const lodging = place.category === PlaceCategory.LODGING;
const own = (narrative.catchphrases?.items ?? [])
.filter((item) => (item.kind ?? 'general') === 'general')
.map((item) => item.text.trim())
.filter(Boolean);
const phrases = own.length > 0 ? own : lodging ? lines : [];
const [index, setIndex] = useState(0);
useEffect(() => {
if (phrases.length < 2) return;
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
const timer = window.setInterval(() => {
if (!document.hidden && !motion.matches) setIndex((value) => (value + 1) % phrases.length);
}, ROTATE_MS);
return () => window.clearInterval(timer);
}, [phrases.length]);
return phrases.length > 0 ? phrases[index % phrases.length] : undefined;
}
function WeatherCard() {
const payload = useSite();
const weather = useLiveWeather({
initial: payload.local.weather,
regionCode: payload.place.regionCode,
latitude: payload.place.latitude,
longitude: payload.place.longitude,
});
const mood = weatherMood(weather?.condition ?? '');
const band = weatherBand(weather?.temperature ?? 0);
const notes = payload.local.weather;
const condition = (weather?.condition ?? '') as WeatherSky;
const skyKey: WeatherSky = notes?.noteSets?.[condition] ? condition : mood;
const [line, bandLine] = useWeatherNotes(
notes?.noteSets?.[skyKey],
notes?.notes?.[mood] ?? weather?.note,
notes?.tempNoteSets?.[band],
notes?.tempNotes?.[band],
);
if (!weather) return null;
return (
<div className="wx" id="weather" data-band={band}>
<span className="deg disp">{Math.round(weather.temperature)}°</span>
<p>
<span className="sr">오늘의 날씨 </span>
{weather.condition}
{line && (
<>
{' · '}
<span key={line} className="wline">
{line}
</span>
</>
)}
{bandLine && (
<>
<br />
<small key={bandLine}>{bandLine}</small>
</>
)}
</p>
</div>
);
}
export function Hero() {
const payload = useSite();
const {place, narrative} = payload;
const phrase = useCatchphrase();
const region = [place.addressRegion, place.addressLocality].filter(Boolean).join(' ');
const fixed = narrative.tagline ?? narrative.heroSubline;
const stay = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
const link = bookingLinks(payload)[0];
return (
<section className="scene" id="top" data-pg="home" aria-labelledby="hero-heading">
<Sky />
<div className="hero-copy">
{region && <span className="tag">{region}</span>}
<h1 id="hero-heading" className="disp">
<Pop text={place.name} />
</h1>
{phrase && (
<p className="cp disp" key={phrase}>
<Pop text={phrase} />
</p>
)}
{fixed && <p className="sub">{fixed}</p>}
{(stay || link || place.phone) && (
<div className="hgo">
{stay ? (
<button type="button" className="btn disp" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
예약하기
</button>
) : (
link && (
<a className="btn disp" href={link.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(link)}
</a>
)
)}
{place.phone && (
<a className="btn btn--line" href={`tel:${place.phone}`}>
전화 {place.phone}
</a>
)}
</div>
)}
</div>
<Hills />
<div className="hills2" aria-hidden>
<Hills />
</div>
{isSectionEnabled(payload, 'weather') && (
<div data-editor-id="weather" style={{display: 'contents'}}>
<WeatherCard />
</div>
)}
</section>
);
}

View File

@ -0,0 +1,87 @@
import {useSite} from '@site/lib/site-context';
import type {InfoRow} from '@site/lib/derive';
import {infoView, ON_VALUES} from '@site/sections/EssentialInfoSection';
import {AmenityIcon} from './Art';
const SHORT = 24;
function Row({row}: {row: InfoRow}) {
const long = Boolean(row.note) || row.value.length > SHORT;
return (
<div className={long ? 'long' : undefined}>
<span>{row.label}</span>
<b>
{row.value}
{row.note && ` — ${row.note}`}
</b>
</div>
);
}
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 <= SHORT)
.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 > SHORT);
const sorted = [...amenities].sort((a, b) => Number(ON_VALUES.has(b.value)) - Number(ON_VALUES.has(a.value)));
return (
<section className="sec" id="info" aria-labelledby="info-heading">
<span className="bub">이용안내</span>
<h2 id="info-heading" className="disp">
이용안내
</h2>
<p className="note">방문 전 확인이 필요한 운영 규정과 시설 안내입니다.</p>
{ruleRows.length > 0 && (
<>
<p className="sub-h">예약 전 확인</p>
<div className="checks">
{[...short, ...long].map((row) => (
<Row key={row.key ?? row.label} row={row} />
))}
</div>
</>
)}
{detailRows.length + sorted.length > 0 && (
<>
<p className="sub-h">시설 · 편의</p>
{detailRows.length > 0 && (
<div className="checks">
{detailRows.map((row) => (
<Row key={row.key ?? row.label} row={row} />
))}
</div>
)}
{sorted.length > 0 && (
<div className="icons">
{sorted.map((row, i) => {
const on = ON_VALUES.has(row.value);
return (
<div key={row.key ?? row.label} className={on ? undefined : 'off'}>
<AmenityIcon label={row.label} delay={on ? (i % 4) * 0.4 : undefined} />
<b>{row.label}</b>
<span>{row.note ?? row.value}</span>
</div>
);
})}
</div>
)}
</>
)}
{notices.length > 0 && (
<div className="faq">
{notices.map((text) => (
<details key={text.slice(0, 32)}>
<summary>예약 공지</summary>
<p style={{whiteSpace: 'pre-line'}}>{text}</p>
</details>
))}
</div>
)}
</section>
);
}

View File

@ -0,0 +1,69 @@
import {useSite} from '@site/lib/site-context';
import {galleryImages, sectionBody} from '@site/lib/derive';
import {infoView, ON_VALUES} from '@site/sections/EssentialInfoSection';
import {Blog} from '../paper/Intro';
import {AmenityIcon} from './Art';
const MAX_ICONS = 6;
export function topic(name: string): string {
const code = name.charCodeAt(name.length - 1) - 0xac00;
if (code < 0 || code > 11171) return `${name}은요`;
return `${name}${code % 28 === 0 ? '는' : '은'}요`;
}
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 amenities = (infoView(payload)?.amenities ?? []).filter((row) => ON_VALUES.has(row.value)).slice(0, MAX_ICONS);
return (
<>
<div data-pg="story">
<Blog full />
</div>
<div data-pg="home" className="intro">
<Blog />
{(about.length > 0 || amenities.length > 0 || narrative.summary) && (
<section className="sec" id="about" aria-labelledby="about-heading">
<span className="bub">{topic(place.name)}</span>
<h2 id="about-heading" className="disp">
{place.name} 소개
</h2>
{narrative.summary && <p className="lead">{narrative.summary}</p>}
{about[0] && <p className="body">{about[0]}</p>}
{about.length > 1 && (
<details>
<summary className="more">더 읽기</summary>
{about.slice(1).map((paragraph, i) => (
<p key={i} className="body">
{paragraph}
</p>
))}
</details>
)}
{amenities.length > 0 && (
<div className="icons">
{amenities.map((row, i) => (
<div key={row.key ?? row.label}>
<AmenityIcon label={row.label} delay={i * 0.4} />
<b>{row.label}</b>
<span>{row.note ?? row.value}</span>
</div>
))}
</div>
)}
</section>
)}
{image && (
<figure className="sec pic">
<img src={image.url} alt={image.alt} loading="lazy" />
</figure>
)}
</div>
</>
);
}

View File

@ -0,0 +1,90 @@
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';
const FIRST = 4;
function stopsOf(item: ItineraryItem): PlannerStop[] {
return item.days?.[0]?.stops ?? item.stops ?? [];
}
export function Itinerary() {
const payload = useSite();
const parsed = sectionItems<ItineraryItem>(payload, 'itinerary');
const [tag, setTag] = useState('');
const [open, setOpen] = useState<ItineraryItem | null>(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) => (
<button key={item.name} type="button" className="course glass" onClick={() => setOpen(item)}>
<b className="nm">{item.name}</b>
<span className="meta">{[item.duration, item.audience].filter(Boolean).join(' · ')}</span>
{item.why && <span className="ds">{item.why}</span>}
<span className="path">
{stopsOf(item)
.slice(0, 4)
.map((stop) => [stop.name.split(' · ').pop(), stop.minutes && `${stop.minutes}분`].filter(Boolean).join(' '))
.join(' → ')}
</span>
</button>
);
return (
<>
<section className="wrap" id="itinerary" aria-labelledby="itinerary-heading">
<p className="label">ITINERARY</p>
<h2 id="itinerary-heading">
{parsed.title || sectionName(payload, 'itinerary', '추천 일정')} <span className="cnt">{items.length}</span>
</h2>
<p className="lead">{parsed.subtitle || '하루를 어떻게 쓸지 미리 짜 두었습니다.'}</p>
{tags.length > 1 && (
<div className="chips" role="tablist">
{['', ...tags].map((t) => (
<button key={t || 'all'} type="button" role="tab" aria-current={t === tag} onClick={() => setTag(t)}>
{t || '전체'} {items.filter((item) => !t || item.duration === t).length}
</button>
))}
</div>
)}
<div className="courses">{shown.slice(0, FIRST).map(card)}</div>
{shown.length > FIRST && (
<details className="fold">
<summary className="link">{shown.length - FIRST}개 더 보기</summary>
<div className="courses">{shown.slice(FIRST).map(card)}</div>
</details>
)}
</section>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
{open && (
<>
<h3>{open.name}</h3>
<p className="meta">{[open.duration, open.audience].filter(Boolean).join(' · ')}</p>
{open.why && <p>{open.why}</p>}
{(open.days?.length ? open.days : [{label: undefined, stops: open.stops}]).map((day, i) => (
<div key={i}>
{day.label && <p className="meta">{day.label}</p>}
<ul className="stops">
{(day.stops ?? []).map((stop, j) => (
<li key={j}>
<b>{stop.name}</b>
{(stop.note || stop.minutes) && (
<span>
{[stop.minutes && `${stop.minutes}분 머뭅니다`, stop.note].filter(Boolean).join(' · ')}
</span>
)}
</li>
))}
</ul>
</div>
))}
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,83 @@
import {useState} from 'react';
import {useSite} from '@site/lib/site-context';
import {essentialRows} from '@site/lib/derive';
import {kakaoDirectionsUrl, naverDirectionsUrl} from '@site/lib/format';
import {MapArt} from './Art';
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 rows: [string, string][] = [
['주소', address],
...(place.roadAddress && place.address && place.address !== place.roadAddress
? [['지번', place.address] as [string, string]]
: []),
...(parking ? [['주차', [parking.value, parking.note].filter(Boolean).join(' — ')] as [string, string]] : []),
];
const copy = () => {
navigator.clipboard.writeText(address).then(
() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
},
() => setCopied(false),
);
};
return (
<section className="sec" id="location" aria-labelledby="location-heading">
<span className="bub">오시는 길</span>
<h2 id="location-heading" className="disp">
오시는 길
</h2>
<p className="note">주소와 주요 거점까지의 이동 시간을 안내합니다.</p>
<MapArt>
<span className="spot" style={{left: '52%', top: '62%'}}>
{place.name}
</span>
{routes[0] && (
<span className="spot" style={{left: '8%', top: '62%'}}>
{[routes[0].destination, routes[0].byCar].filter(Boolean).join(' ')}
</span>
)}
</MapArt>
<dl className="spec card">
{rows.map(([label, value]) => (
<div key={label} style={{display: 'contents'}}>
<dt>{label}</dt>
<dd>{label === '주소' ? <address style={{fontStyle: 'normal'}}>{value}</address> : value}</dd>
</div>
))}
</dl>
{routes.length > 0 && (
<div className="checks">
{routes.map((route) => (
<div key={route.destination}>
<span>{route.destination}</span>
<b>
{[route.byCar && `차 ${route.byCar}`, route.byTransit, route.distanceText].filter(Boolean).join(' · ')}
</b>
</div>
))}
</div>
)}
<div className="btns">
<button type="button" className="cta ghost" onClick={copy}>
{copied ? '복사했습니다' : '주소 복사'}
</button>
<a className="cta ghost" href={naverDirectionsUrl(place.name, lat, lng)} target="_blank" rel="noopener noreferrer nofollow">
네이버 지도 길찾기
</a>
<a className="cta ghost" href={kakaoDirectionsUrl(place.name, lat, lng)} target="_blank" rel="noopener noreferrer nofollow">
카카오맵 길찾기
</a>
</div>
</section>
);
}

View File

@ -0,0 +1,152 @@
import {useState} from 'react';
import {factText, sanitizeUnits} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {sectionName, unitSpec, unitViews, type UnitView} from '@site/lib/derive';
import {Car} from '../paper/Car';
import {BOOK_EVENT} from '../paper/Rooms';
import {Sheet} from '../paper/Sheet';
import {RoomArt} from './Art';
const HOME_UNITS = 4;
function splitName(name: string): [string, string | undefined] {
const m = /^(.+?)\s*\((.+)\)\s*$/.exec(name);
return m ? [m[1], m[2]] : [name, undefined];
}
function specRows(unit: UnitView, facts: Parameters<typeof factText>[0]) {
const bed = [factText(facts, 'bed_type'), factText(facts, 'room_size')].filter(Boolean).join(' · ');
const std = factText(facts, 'standard_capacity');
const max = factText(facts, 'max_capacity');
const people = [std && `기준 ${std.replace(/명$/, '인')}`, max && `최대 ${max.replace(/명$/, '인')}`]
.filter(Boolean)
.join(' · ');
const price = unit.priceText ?? unit.prices.map((p) => `${p.label} ${p.value}`).join(' · ');
return [
['침대', bed],
['정원', people],
['요금', price],
].filter(([, value]) => value) as [string, string][];
}
function Cover({unit, index}: {unit: UnitView; index: number}) {
const image = unit.images[0];
if (!image) return <RoomArt night={index % 2 === 1} />;
return (
<div className="art">
<img src={image.url} alt={image.alt} loading="lazy" />
</div>
);
}
function UnitSheet({unit, onClose}: {unit: UnitView | null; onClose: () => void}) {
return (
<Sheet open={unit != null} onClose={onClose} label={unit?.name ?? ''}>
{unit && (
<>
{unit.images[0] && <img src={unit.images[0].url} alt={unit.images[0].alt} style={{aspectRatio: '3 / 2'}} />}
<h3>{unit.name}</h3>
{unit.intro && <p>{unit.intro}</p>}
{unit.rows.map((row) => (
<p key={row.label}>
{row.label} — {row.value}
</p>
))}
</>
)}
</Sheet>
);
}
export function Rooms() {
const payload = useSite();
const units = unitViews(payload);
const spec = unitSpec(payload);
const raw = new Map(sanitizeUnits(payload.units).map((unit) => [unit.unitId, unit]));
const [open, setOpen] = useState<UnitView | null>(null);
if (units.length === 0) return null;
return (
<>
<div data-pg="home">
<section className="sec" id="units" aria-labelledby="units-heading">
<span className="bub">{spec.label}</span>
<h2 id="units-heading" className="disp">
{sectionName(payload, spec.path, `${spec.label} 안내`)}
</h2>
<div className="rgrid">
{units.slice(0, HOME_UNITS).map((unit, i) => {
const people = specRows(unit, raw.get(unit.unitId)?.facts ?? []).find(([label]) => label === '정원')?.[1];
return (
<a key={unit.unitId} href="#/stay" className="rcard">
<Cover unit={unit} index={i} />
<b className="nm">{unit.name}</b>
{people && <span className="ds">{people}</span>}
</a>
);
})}
</div>
<a className="more" href="#/stay">
이용안내에서 전체 보기
</a>
</section>
</div>
<div data-pg="stay" className="units">
{units.map((unit, i) => {
const [main, sub] = splitName(unit.name);
return (
<section key={unit.unitId} className="sec" id={`unit-${unit.slug}`}>
<span className="bub">
{spec.label} {String.fromCharCode(65 + i)}
</span>
<h2 className="disp">
{main} {sub && <span>{sub}</span>}
</h2>
<div className="room room--full">
{unit.images.length > 1 ? (
<Car
label={`${main} 사진`}
arrows
count
dots
slides={unit.images.map((image) => (
<img
key={image.mediaId}
src={image.url}
alt={image.alt}
loading="lazy"
style={{aspectRatio: '3 / 2'}}
/>
))}
/>
) : (
<Cover unit={unit} index={i} />
)}
<div className="bd">
{unit.intro && <p className="lead">{unit.intro}</p>}
<dl className="spec">
{specRows(unit, raw.get(unit.unitId)?.facts ?? []).map(([label, value]) => (
<div key={label} style={{display: 'contents'}}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
<button type="button" className="more" onClick={() => setOpen(unit)}>
{main} 자세히
</button>
<button type="button" className="cta" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
{main} 예약 요청
</button>
</div>
</div>
</section>
);
})}
</div>
<UnitSheet unit={open} onClose={() => setOpen(null)} />
</>
);
}

View File

@ -0,0 +1,21 @@
import type {SectionHeadProps} from '@site/lib/layout';
export function SectionHead({id, title, lead, aside}: SectionHeadProps) {
return (
<div className="phead">
{aside ? (
<div className="head">
<h2 id={`${id}-heading`} className="disp">
{title}
</h2>
{aside}
</div>
) : (
<h2 id={`${id}-heading`} className="disp">
{title}
</h2>
)}
{lead && <p className="note">{lead}</p>}
</div>
);
}

View File

@ -0,0 +1,198 @@
import {useState, type ReactNode} from 'react';
import type {ChronicleItem, PeopleItem, ReadingItem, SongItem} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {Sheet} from '../paper/Sheet';
const COUNT_WORD: Record<number, string> = {1: '한', 2: '두', 3: '세', 4: '네'};
function More({rest, unit, children}: {rest: number; unit: string; children: ReactNode}) {
if (rest <= 0) return null;
return (
<details className="fold">
<summary className="link">
{rest}
{unit} 더 보기
</summary>
{children}
</details>
);
}
interface Open {
title: string;
meta?: string;
body?: string;
image?: string;
}
export function Story() {
const payload = useSite();
const region = payload.place.addressLocality?.replace(/시$|군$/, '') ?? '지역';
const songs = sectionItems<SongItem>(payload, 'songs');
const people = sectionItems<PeopleItem>(payload, 'people');
const chronicle = sectionItems<ChronicleItem>(payload, 'chronicle');
const reading = sectionItems<ReadingItem>(payload, 'reading');
const [open, setOpen] = useState<Open | null>(null);
const parts = [
songs.items.length > 0 && '노래',
people.items.length > 0 && '인물',
chronicle.items.length > 0 && '연표',
reading.items.length > 0 && '읽기',
].filter(Boolean) as string[];
if (parts.length === 0) return null;
const peopleSorted = [...people.items].sort((a, b) => Number(Boolean(b.imageUrl)) - Number(Boolean(a.imageUrl)));
const withPhoto = people.items.filter((p) => p.imageUrl).length;
const song = (item: SongItem) => (
<button
key={item.title + item.artist}
type="button"
className="line"
onClick={() =>
setOpen({title: item.title, meta: [item.artist, item.year].filter(Boolean).join(' · '), body: item.story})
}
>
<span>
{item.title} {item.artist && <span className="dim">{item.artist}</span>}
</span>
{item.year && <span className="r">{item.year}</span>}
</button>
);
const person = (item: PeopleItem) => (
<button
key={item.name}
type="button"
className={item.imageUrl ? 'card' : 'card card--txt'}
onClick={() =>
setOpen({
title: item.name,
meta: [item.role, item.years].filter(Boolean).join(' · '),
body: item.oneLine,
image: item.imageUrl,
})
}
>
{item.imageUrl && <img src={item.imageUrl} alt={item.name} loading="lazy" />}
<b className="nm">{item.name}</b>
{item.role && <span className="ds">{item.role}</span>}
</button>
);
const event = (item: ChronicleItem) => (
<button
key={`${item.year}-${item.title}`}
type="button"
className="line"
onClick={() =>
setOpen({
title: item.title,
meta: [item.year, item.place].filter(Boolean).join(' · '),
body: item.summary,
image: item.imageUrl,
})
}
>
<span>
<span className="blk">{item.title}</span>
{item.place && <span className="dim blk">{item.place}</span>}
</span>
{item.year && <span className="r">{item.year}</span>}
</button>
);
const book = (item: ReadingItem) => (
<button
key={item.title}
type="button"
className="course glass"
onClick={() => setOpen({title: item.title, meta: item.year ? String(item.year) : item.group, body: item.body})}
>
<b className="nm">
{item.title} {item.year && <span className="dim">{item.year}</span>}
</b>
<span className="ds clamp clamp--3">{item.body}</span>
</button>
);
return (
<>
<section className="wrap" id="story" aria-labelledby="story-heading">
<p className="label">STORY</p>
<h2 id="story-heading">
{sectionName(payload, 'story', `${region} 이야기`)} <span className="cnt">{parts.join(' · ')}</span>
</h2>
<p className="lead">
이 도시를 {COUNT_WORD[parts.length] ?? parts.length} 갈래로 봅니다. 하나씩 골라 보세요.
</p>
</section>
{songs.items.length > 0 && (
<section className="wrap tight" id="songs">
<p className="label">MUSIC</p>
<h2>
{songs.title || '가요 다방'} <span className="cnt">{songs.items.length}곡</span>
</h2>
<p className="lead">{region}을 노래한 곡들입니다.</p>
<div className="rows">{songs.items.slice(0, 6).map(song)}</div>
<More rest={songs.items.length - 6} unit="곡">
<div className="rows">{songs.items.slice(6).map(song)}</div>
</More>
</section>
)}
{people.items.length > 0 && (
<section className="wrap tight" id="people">
<p className="label">PEOPLE</p>
<h2>
{people.title || '인물 열전'} <span className="cnt">{people.items.length}명</span>
</h2>
<p className="lead">{region}과 얽힌 사람들입니다.</p>
<div className="grid3">{peopleSorted.slice(0, 6).map(person)}</div>
<More rest={people.items.length - 6} unit="명">
<div className="grid3">{peopleSorted.slice(6).map(person)}</div>
</More>
{withPhoto > 0 && withPhoto < people.items.length && (
<p className="note">사진이 있는 {withPhoto}명을 먼저 뒀습니다.</p>
)}
</section>
)}
{chronicle.items.length > 0 && (
<section className="wrap tight" id="chronicle">
<p className="label">CHRONICLE</p>
<h2>{chronicle.title || '시간의 골목'}</h2>
<p className="lead">개항부터 지금까지, 이 도시의 연표입니다.</p>
<div className="rows rows--ev">{chronicle.items.slice(0, 5).map(event)}</div>
<More rest={chronicle.items.length - 5} unit="줄">
<div className="rows rows--ev">{chronicle.items.slice(5).map(event)}</div>
</More>
</section>
)}
{reading.items.length > 0 && (
<section className="wrap tight" id="reading">
<p className="label">READING</p>
<h2>
{reading.title || `${region} 읽기`} <span className="cnt">{reading.items.length}편</span>
</h2>
<p className="lead">{region}을 배경으로 한 문학입니다.</p>
<div className="courses">{reading.items.slice(0, 3).map(book)}</div>
<More rest={reading.items.length - 3} unit="편">
<div className="courses">{reading.items.slice(3).map(book)}</div>
</More>
</section>
)}
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.title ?? ''}>
{open && (
<>
{open.image && <img src={open.image} alt={open.title} className="sq" />}
<h3>{open.title}</h3>
{open.meta && <p className="meta">{open.meta}</p>}
{open.body && <p>{open.body}</p>}
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,33 @@
import {youtubeId, youtubePoster, type VideoItem} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
export function Video() {
const payload = useSite();
const parsed = sectionItems<VideoItem>(payload, 'video');
const items = parsed.items.filter((item) => youtubeId(item.url));
if (items.length === 0) return null;
return (
<div data-pg="home">
<section className="wrap" id="video" aria-labelledby="video-heading">
<p className="label">FILM</p>
<h2 id="video-heading">
{parsed.title || sectionName(payload, 'video', '영상으로 보기')} <span className="cnt">{items.length}편</span>
</h2>
{parsed.subtitle && <p className="lead">{parsed.subtitle}</p>}
<div className="rail">
{items.map((item) => (
<a key={item.url} className="film" href={item.url} target="_blank" rel="noopener noreferrer">
<span className="poster">
<img src={youtubePoster(youtubeId(item.url)!)} alt="" loading="lazy" />
<span className="play" />
</span>
{item.caption && <span className="cap">{item.caption}</span>}
</a>
))}
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,336 @@
.w4-graphic{--sky3:#6a5acd;--sky2:#ff9a76;--sky1:#ffd9a8;--hill1:#2f6b4f;--hill2:#1f4d39;--hill3:#133226;--cream:var(--tpl-bg,#fff7ec);--ink:var(--tpl-text,#1d2b24);--sub:var(--tpl-secondary,#51605a);--accent:var(--tpl-accent,#ff7a45);--card:#fff;--g:20px;--k-bg:var(--cream);--k-ink:var(--ink);--k-sub:var(--sub);--k-line:rgba(29,43,36,.14);--k-soft:#fff;--k-r:18px;--k-head:'Jua','Gowun Dodum',sans-serif;background:var(--cream);color:var(--ink);font:17px/1.7 'Gowun Dodum','Noto Sans KR',sans-serif;word-break:keep-all;padding-bottom:92px;overflow-x:clip}
.w4-graphic img{display:block;width:100%;height:100%;object-fit:cover}
.w4-graphic a{color:inherit;text-decoration:none}
.w4-graphic button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer;text-align:left}
.w4-graphic h1,.w4-graphic h2,.w4-graphic h3,.w4-graphic p,.w4-graphic figure,.w4-graphic dl,.w4-graphic dd{margin:0}
.w4-graphic .disp,.w4-graphic h2,.w4-graphic h3{font-family:'Jua','Gowun Dodum',sans-serif;font-weight:400;letter-spacing:-.01em}
.w4-graphic .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
.w4-graphic .f-ac{fill:var(--accent)}
.w4-graphic .s-ac{stroke:var(--accent)}
.w4-graphic .f-h1{fill:var(--hill1)}
.w4-graphic .f-h2{fill:var(--hill2)}
.w4-graphic .f-h3{fill:var(--hill3)}
.w4-graphic .top{position:absolute;top:0;left:0;right:0;z-index:5;display:flex;justify-content:space-between;align-items:center;padding:14px var(--g);color:#fff}
.w4-graphic[data-page]:not([data-page=home]) .top{position:static;color:var(--ink)}
.w4-graphic .top .disp{font-size:22px}
.w4-graphic .top .ico{width:40px;height:40px;display:grid;place-items:center}
.w4-graphic .top svg{width:22px;height:22px}
.w4-graphic .tabs{position:sticky;top:0;z-index:20;display:flex;gap:6px;padding:10px var(--g);background:var(--cream);overflow-x:auto;scrollbar-width:none;box-shadow:0 1px 0 rgba(29,43,36,.06)}
.w4-graphic .tabs::-webkit-scrollbar{display:none}
.w4-graphic .tabs a{flex:none;padding:7px 15px;border-radius:999px;background:var(--card);font-size:15px;box-shadow:0 2px 0 rgba(29,43,36,.08)}
.w4-graphic .tabs a.on{background:var(--accent);color:#fff}
.w4-graphic[data-page=home] .top{top:56px}
.w4-graphic .scene{position:relative;height:100svh;min-height:640px;overflow:hidden;background:linear-gradient(180deg,#3b3a7a 0%,var(--sky3) 22%,var(--sky2) 55%,var(--sky1) 78%);animation:w4g-dusk 16s ease-in-out infinite alternate}
@keyframes w4g-dusk{to{filter:hue-rotate(-18deg) saturate(1.1)}}
.w4-graphic .sun{position:absolute;left:50%;top:52%;width:150px;height:150px;margin-left:-75px;border-radius:50%;background:radial-gradient(circle,#fff3c4,#ffc86b 60%,rgba(255,200,107,0) 72%);animation:w4g-sun 8s ease-in-out infinite alternate}
@keyframes w4g-sun{to{transform:translateY(24px) scale(1.05)}}
.w4-graphic .star{position:absolute;width:3px;height:3px;border-radius:50%;background:#fff;animation:w4g-tw 3s ease-in-out infinite}
@keyframes w4g-tw{50%{opacity:.2}}
.w4-graphic .tw{animation:w4g-tw 3s ease-in-out infinite}
.w4-graphic .cloud{position:absolute;height:22px;border-radius:22px;background:rgba(255,255,255,.55);animation:w4g-drift linear infinite}
.w4-graphic .cloud:before{content:'';position:absolute;left:18%;top:-12px;width:40%;height:28px;border-radius:50%;background:inherit}
@keyframes w4g-drift{from{transform:translateX(-160px)}to{transform:translateX(110vw)}}
.w4-graphic .hills{position:absolute;left:0;right:0;bottom:0;width:100%;height:46%}
.w4-graphic .glow{animation:w4g-glow 2.4s ease-in-out infinite}
.w4-graphic .glow--slow{animation-duration:3s}
@keyframes w4g-glow{50%{opacity:.55}}
.w4-graphic .hero-copy{position:absolute;left:var(--g);right:var(--g);top:17%;z-index:3;color:#fff;text-align:center}
.w4-graphic .tag{display:inline-block;background:rgba(255,255,255,.18);border:1px solid rgba(255,255,255,.4);border-radius:999px;padding:4px 14px;font-size:14px}
.w4-graphic .hero-copy h1{font-size:28px;line-height:1.2;margin-top:14px;opacity:.95}
.w4-graphic .cp{font-size:38px;line-height:1.2;margin-top:10px;min-height:2.4em;text-shadow:0 3px 0 rgba(0,0,0,.08)}
.w4-graphic .hero-copy .sub{margin-top:10px;font-size:16px;opacity:.92}
.w4-graphic .in .wd{display:inline}
.w4-graphic .in .wn{display:inline-block;white-space:nowrap}
.w4-graphic .in .wn>span{display:inline-block;animation:w4g-pop .6s cubic-bezier(.3,1.6,.5,1) both}
@keyframes w4g-pop{from{opacity:0;transform:translateY(20px) scale(.8)}}
.w4-graphic .wx{position:absolute;left:var(--g);right:var(--g);bottom:150px;z-index:3;display:flex;align-items:center;gap:14px;background:rgba(19,50,38,.55);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border-radius:20px;padding:14px 18px;color:#fff}
.w4-graphic .wx .deg{font-size:40px;line-height:1;flex:none}
.w4-graphic .wx p{font-size:15px;line-height:1.5;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.w4-graphic .wx .wline{display:block;font-size:13px;opacity:.8}
.w4-graphic{--gap-sec:56px;--gap-sub:40px}
.w4-graphic .sec,.w4-graphic .wrap{max-width:720px;margin:0 auto;padding:var(--gap-sec) var(--g) 0}
.w4-graphic .wrap.tight{padding-top:var(--gap-sub)}
.w4-graphic .pagehead{padding-top:28px}
.w4-graphic .bub,.w4-graphic .label{display:inline-block;background:var(--accent);color:#fff;border-radius:999px;padding:3px 14px;font-size:14px;margin-bottom:14px;transform:rotate(-2deg);letter-spacing:0;text-transform:none}
.w4-graphic .sec h2,.w4-graphic .wrap h2{font-size:34px;line-height:1.25}
.w4-graphic h2 .cnt{font-family:'Gowun Dodum',sans-serif;font-size:15px;color:var(--sub);margin-left:8px}
.w4-graphic .lead{margin-top:14px;font-size:18px;color:var(--sub);line-height:1.8}
.w4-graphic .note,.w4-graphic .sub-h{margin-top:10px;font-size:15px;color:var(--sub)}
.w4-graphic .pills,.w4-graphic .tabrow,.w4-graphic .chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:18px}
.w4-graphic .pills a,.w4-graphic .tabrow button,.w4-graphic .chips button,.w4-graphic .pill{padding:7px 15px;border-radius:999px;background:var(--card);font-size:15px;box-shadow:0 2px 0 rgba(29,43,36,.08)}
.w4-graphic .tabrow button[aria-pressed=true],.w4-graphic .chips button[aria-pressed=true],.w4-graphic .tabrow button[aria-current=true],.w4-graphic .chips button[aria-current=true]{background:var(--ink);color:#fff}
.w4-graphic .icons{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:26px}
.w4-graphic .icons>*{background:var(--card);border-radius:22px;padding:18px 16px;box-shadow:0 2px 0 rgba(29,43,36,.08)}
.w4-graphic .icons .off{opacity:.45}
.w4-graphic .icons svg{width:44px;height:44px}
.w4-graphic .icons b{display:block;margin-top:8px;font-size:17px;font-weight:400}
.w4-graphic .icons span{font-size:14px;color:var(--sub)}
.w4-graphic .float{animation:w4g-bob 3s ease-in-out infinite}
@keyframes w4g-bob{50%{transform:translateY(-4px)}}
.w4-graphic #about p:not([class]){margin-top:12px;font-size:18px;line-height:1.85;color:var(--sub)}
.w4-graphic .pic{padding-top:24px}
.w4-graphic .pic img{border-radius:28px;aspect-ratio:4/3;height:auto}
.w4-graphic .room{display:block;margin-top:18px;border-radius:28px;overflow:hidden;background:var(--card);box-shadow:0 2px 0 rgba(29,43,36,.08)}
.w4-graphic .room .art{height:210px;position:relative;overflow:hidden}
.w4-graphic .room .art svg{width:100%;height:100%}
.w4-graphic .room .art img{height:210px}
.w4-graphic .a1{background:linear-gradient(180deg,#bfe3ff,#e9f6ff)}
.w4-graphic .a2{background:linear-gradient(180deg,#2b2d5c,#5b4b8a)}
.w4-graphic .room .bd{padding:20px 22px 22px}
.w4-graphic .room h3{font-size:26px}
.w4-graphic .room p{color:var(--sub);font-size:15px}
.w4-graphic .room .row{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:14px;padding:0;border:0}
.w4-graphic .room .row b{font-size:19px;font-weight:400}
.w4-graphic .room .pill{background:var(--ink);color:#fff;box-shadow:none}
.w4-graphic .rgrid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:20px}
.w4-graphic .rcard{display:flex;flex-direction:column;min-width:0;border-radius:22px;overflow:hidden;background:var(--card);box-shadow:0 2px 0 rgba(29,43,36,.08);padding-bottom:12px}
.w4-graphic .rcard .art{aspect-ratio:3/2;position:relative;overflow:hidden;margin-bottom:10px}
.w4-graphic .rcard .art svg{width:100%;height:100%}
.w4-graphic .rcard .nm,.w4-graphic .rcard .ds{padding:0 12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.w4-graphic .rcard .nm{font-family:var(--k-head);font-weight:400;font-size:17px;line-height:1.4}
.w4-graphic .rcard .ds{font-size:14px;color:var(--sub);line-height:1.5}
.w4-graphic .room--full{box-shadow:none;background:transparent}
.w4-graphic .room--full .car{border-radius:28px;overflow:hidden}
.w4-graphic .room--full .slide img{aspect-ratio:4/3;height:auto}
.w4-graphic .car .arrow{position:absolute;top:50%;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;background:#fff;display:grid;place-items:center;text-align:center;box-shadow:0 3px 0 rgba(29,43,36,.12)}
.w4-graphic .car .prev{left:10px}
.w4-graphic .car .next{right:10px}
.w4-graphic .car .count{position:absolute;right:12px;bottom:12px;padding:2px 10px;border-radius:999px;background:rgba(29,43,36,.6);color:#fff;font-size:13px}
.w4-graphic .spec{display:grid;gap:8px;margin-top:16px}
.w4-graphic .spec>div{display:flex;justify-content:space-between;gap:12px;background:var(--card);border-radius:16px;padding:12px 16px}
.w4-graphic .spec dt{color:var(--sub)}
.w4-graphic .spec.card{margin-top:14px}
.w4-graphic address{font-style:normal}
.w4-graphic .checks{margin-top:22px;display:grid;gap:10px}
.w4-graphic .checks>*{display:flex;justify-content:space-between;gap:12px;background:var(--card);border-radius:16px;padding:14px 18px}
.w4-graphic .checks b{font-weight:400;font-size:17px}
.w4-graphic .more,.w4-graphic .cta{display:flex;align-items:center;justify-content:center;width:100%;min-height:50px;margin-top:12px;border-radius:999px;font-size:16px;text-align:center}
.w4-graphic .more{background:var(--card);box-shadow:0 2px 0 rgba(29,43,36,.08)}
.w4-graphic .cta{background:var(--accent);color:#fff;box-shadow:0 4px 0 #c9562a}
.w4-graphic .cta.ghost{background:var(--card);color:var(--ink);box-shadow:0 2px 0 rgba(29,43,36,.08)}
.w4-graphic details>summary{list-style:none;cursor:pointer}
.w4-graphic details:not([open])>:not(summary){display:none}
.w4-graphic details>summary::-webkit-details-marker{display:none}
.w4-graphic .sec--box{padding-top:var(--gap-sec)}
.w4-graphic .book{position:relative;overflow:hidden;border-radius:32px;background:var(--hill2);color:#fff;padding:36px 24px 28px;text-align:center}
.w4-graphic .book h2{font-size:36px;color:#fff}
.w4-graphic .book p{opacity:.8;margin-top:10px}
.w4-graphic .fire{position:absolute;bottom:-10px;width:60px;height:60px}
.w4-graphic .flame{transform-origin:50% 100%;animation:w4g-flick .5s ease-in-out infinite alternate}
@keyframes w4g-flick{to{transform:scaleY(1.12) skewX(3deg)}}
.w4-graphic .btns{display:flex;flex-direction:column;align-items:center;gap:10px;margin-top:22px;position:relative;z-index:1}
.w4-graphic .btn{display:inline-flex;align-items:center;justify-content:center;background:var(--accent);color:#fff;border-radius:999px;padding:14px 32px;font-size:18px;box-shadow:0 5px 0 #c9562a;text-align:center}
.w4-graphic .btn:active{transform:translateY(3px);box-shadow:0 2px 0 #c9562a}
.w4-graphic .btn--line{background:transparent;border:1.5px solid rgba(255,255,255,.6);box-shadow:none;font-size:16px;padding:11px 24px}
.w4-graphic .sec:not(.sec--box) .btns{flex-direction:row}
.w4-graphic .sec:not(.sec--box) .btns>*{flex:1}
.w4-graphic .shots{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:16px}
.w4-graphic .shot{border-radius:20px;overflow:hidden;aspect-ratio:16/10}
.w4-graphic .shots--lead .shot:first-child{grid-column:span 2;aspect-ratio:2/1}
.w4-graphic .map{margin-top:24px;border-radius:28px;background:#dff0e4;height:280px;position:relative;overflow:hidden}
.w4-graphic .road{position:absolute;inset:0;width:100%;height:100%}
.w4-graphic .dash{animation:w4g-dash 1s linear infinite}
@keyframes w4g-dash{to{stroke-dashoffset:-36}}
.w4-graphic .pin{position:absolute;width:48px;height:48px;border-radius:50% 50% 50% 0;background:var(--accent);transform:rotate(-45deg);box-shadow:0 6px 0 rgba(0,0,0,.1)}
.w4-graphic .pin:after{content:'';position:absolute;inset:14px;border-radius:50%;background:#fff}
.w4-graphic .pin.me{left:58%;top:36%;animation:w4g-drop 1.6s cubic-bezier(.3,1.6,.5,1) infinite alternate}
@keyframes w4g-drop{from{transform:rotate(-45deg) translate(6px,-6px)}}
.w4-graphic .spot{position:absolute;background:#fff;border-radius:14px;padding:6px 12px;font-size:13px;box-shadow:0 2px 0 rgba(0,0,0,.08);white-space:nowrap}
.w4-graphic .head{display:flex;justify-content:space-between;align-items:flex-end;gap:12px}
.w4-graphic .guide{margin-top:18px;display:grid;gap:10px}
.w4-graphic .gitem{display:grid;grid-template-columns:52px 1fr auto;gap:14px;align-items:center;width:100%;background:var(--card);border-radius:20px;padding:14px 16px}
.w4-graphic .gitem .ic{width:52px;height:52px;border-radius:16px;display:grid;place-items:center;background:#fff1c9;overflow:hidden}
.w4-graphic .gitem .ic svg{width:30px;height:30px}
.w4-graphic .gt{display:block;min-width:0}
.w4-graphic .gt b{display:block;font-weight:400;font-size:18px;line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.w4-graphic .gt .clamp{font-size:14px;color:var(--sub);line-height:1.4;-webkit-line-clamp:1;margin-top:2px}
.w4-graphic .gitem small{font-size:13px;color:var(--accent);white-space:nowrap}
.w4-graphic .gitem{min-height:80px}
.w4-graphic .gitem--s{grid-template-columns:40px 1fr auto;gap:12px;min-height:60px;padding:10px 14px}
.w4-graphic .gitem--s .ic{width:40px;height:40px;border-radius:12px}
.w4-graphic .gitem--s .ic svg{width:24px;height:24px}
.w4-graphic .clamp{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.w4-graphic .rail{display:flex;gap:10px;overflow-x:auto;margin:20px calc(var(--g)*-1) 0;padding:0 var(--g) 4px;scrollbar-width:none}
.w4-graphic .rail::-webkit-scrollbar{display:none}
.w4-graphic .card{flex:0 0 72%;display:flex;flex-direction:column;gap:4px;padding:18px;border-radius:22px;background:var(--card);box-shadow:0 2px 0 rgba(29,43,36,.08)}
.w4-graphic .card .nm{font-weight:400;font-size:17px}
.w4-graphic .card .ds{font-size:14px;color:var(--sub)}
.w4-graphic .rows,.w4-graphic .list{margin-top:18px;display:grid;gap:10px}
.w4-graphic .line,.w4-graphic .list .row{display:flex;justify-content:space-between;gap:12px;width:100%;background:var(--card);border-radius:18px;padding:16px 18px}
.w4-graphic .line .r,.w4-graphic .row .r{color:var(--accent);font-size:14px;flex:none}
.w4-graphic .grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:18px}
.w4-graphic .grid3 .card{flex:auto;padding:10px;min-width:0}
.w4-graphic .grid3 .card img{aspect-ratio:1;height:auto;border-radius:14px;margin-bottom:4px}
.w4-graphic .grid3 .card .nm,.w4-graphic .grid3 .card .ds{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.4}
.w4-graphic .card--txt{justify-content:center;background:#fff1c9;box-shadow:none}
.w4-graphic .grid3 .card--txt .ds{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.w4-graphic .rail .card img,.w4-graphic .rail .noimg{aspect-ratio:3/2;height:auto;border-radius:14px;margin-bottom:6px}
.w4-graphic .rail .card .nm,.w4-graphic .rail .card .ds{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.w4-graphic .rows--ev .line{min-height:85px;align-items:center}
.w4-graphic .rows--ev .line>span:first-child{min-width:0}
.w4-graphic .rows--ev .line .blk{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.w4-graphic .rail .noimg{display:grid;place-items:center;padding:12px;text-align:center;font-family:var(--k-head);font-size:18px;color:var(--ink)}
.w4-graphic .clamp--3{-webkit-line-clamp:3}
.w4-graphic .course .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.w4-graphic .noimg{display:block;aspect-ratio:1;border-radius:14px;background:linear-gradient(180deg,#bfe3ff,#e9f6ff)}
.w4-graphic .courses{display:grid;gap:10px;margin-top:20px}
.w4-graphic .course{display:flex;flex-direction:column;gap:4px;padding:18px 20px;border-radius:22px;background:var(--card);box-shadow:0 2px 0 rgba(29,43,36,.08)}
.w4-graphic .dim{color:var(--sub);font-size:14px}
.w4-graphic .blk{display:block}
.w4-graphic .fold{margin-top:6px}
.w4-graphic .link{color:var(--accent);font-size:15px}
.w4-graphic .faq{margin-top:20px}
.w4-graphic .faq details{background:var(--card);border-radius:18px;margin-bottom:10px;overflow:hidden}
.w4-graphic .faq summary{display:flex;justify-content:space-between;gap:12px;padding:16px 18px;font-size:17px}
.w4-graphic .faq summary:after{content:'+';color:var(--accent);font-size:22px;line-height:1;transition:transform .3s}
.w4-graphic .faq details[open] summary:after{transform:rotate(45deg)}
.w4-graphic .faq details p{padding:0 18px 16px;color:var(--sub);font-size:15px}
.w4-graphic .film,.w4-graphic .poster{border-radius:22px;overflow:hidden}
.w4-graphic #postcard-maker,.w4-graphic #reviews{background:transparent;border:0;--section-space:var(--gap-sec)}
.w4-graphic footer{margin-top:80px;position:relative;background:var(--hill3);color:rgba(255,255,255,.72);font-size:13px;padding:40px var(--g) 36px;display:grid;gap:4px}
.w4-graphic footer .hill{position:absolute;left:0;top:-59px;width:100%;height:60px;fill:var(--hill3)}
.w4-graphic footer .fname{font-size:22px;color:#fff;margin-bottom:8px}
.w4-graphic footer .byline a{text-decoration:underline}
.w4-graphic .player{position:fixed;right:14px;bottom:92px;z-index:30;background:#fff;border-radius:999px;padding:3px 8px 3px 12px;box-shadow:0 4px 0 rgba(29,43,36,.12)}
.w4-graphic .player.low{bottom:16px}
.w4-graphic #w4d-mini{display:flex;align-items:center;gap:2px}
.w4-graphic #w4d-tape{width:24px;height:24px;margin-right:6px;color:var(--accent)}
.w4-graphic #w4d-tape svg{width:100%;height:100%}
.w4-graphic #w4d-now{max-width:110px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px}
.w4-graphic #w4d-mini button{width:38px;height:38px;padding:9px}
.w4-graphic #w4d-mini button svg{width:100%;height:100%}
.w4-graphic #w4d-panel{background:#fff;border-radius:22px;box-shadow:0 6px 0 rgba(29,43,36,.12);color:var(--ink)}
.w4-graphic .tabbar{position:fixed;left:12px;right:12px;bottom:max(12px,env(safe-area-inset-bottom));z-index:30;display:grid;gap:6px;background:#fff;border-radius:24px;padding:6px;box-shadow:0 4px 0 rgba(29,43,36,.12)}
.w4-graphic .tabbar>a,.w4-graphic .tabbar>button{height:52px;border-radius:18px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;font-size:12px;color:var(--sub);text-align:center}
.w4-graphic .tabbar svg{width:20px;height:20px}
.w4-graphic .tabbar>.cta{margin:0;min-height:0;background:var(--accent);color:#fff;font-family:'Jua',sans-serif;font-size:19px;box-shadow:none;flex-direction:row}
@media (prefers-reduced-motion:reduce){.w4-graphic *,.w4-graphic *:before{animation:none!important}}
@media (min-width:700px){.w4-graphic{--gap-sec:64px;--side:max(0px,calc((100vw - 640px) / 2));max-width:640px;margin-left:auto;margin-right:auto;box-shadow:0 0 0 1px rgba(0,0,0,.06)}.w4-graphic .player{right:calc(var(--side) + 14px)}.w4-graphic .tabbar{left:calc(var(--side) + 12px);right:calc(var(--side) + 12px)}}
.w4-graphic .hdr{display:contents}
.w4-graphic .hact,.w4-graphic .hgo,.w4-graphic .hills2{display:none}
@media(min-width:1024px){.w4-graphic{--g:32px;--gap-sec:36px;--gap-sub:32px;--side:0px;max-width:none;margin:0;box-shadow:none;padding-bottom:0}
.w4-graphic .sec,.w4-graphic .wrap{max-width:1144px;padding-left:32px;padding-right:32px}
.w4-graphic .sec h2,.w4-graphic .wrap h2{font-size:30px}
.w4-graphic .lead,.w4-graphic #about p:not([class]){font-size:17px}
.w4-graphic .lead{margin-top:8px;line-height:1.6}
.w4-graphic .hdr{display:flex;align-items:center;gap:28px;position:sticky;top:0;z-index:20;background:var(--cream);padding:10px max(32px,calc((100% - 1080px)/2));box-shadow:0 1px #1d2b2414}
.w4-graphic .top,.w4-graphic[data-page] .top,.w4-graphic[data-page=home] .top{position:static;padding:0;color:var(--ink);flex:none}
.w4-graphic .top .ico{display:none}
.w4-graphic .tabs{position:static;flex:1;padding:0;background:none;box-shadow:none;overflow:visible}
.w4-graphic .tabs a{box-shadow:none;background:none}
.w4-graphic .tabs a:hover{background:var(--card)}
.w4-graphic .hact{display:flex;gap:8px;flex:none}
.w4-graphic .hbtn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:999px;background:var(--card);font-size:15px;box-shadow:0 2px #1d2b2414}
.w4-graphic .hbtn svg{width:16px;height:16px}
.w4-graphic .hbtn.on{background:var(--accent);color:#fff;box-shadow:0 3px #c9562a;font-family:var(--k-head)}
.w4-graphic .scene{height:480px;min-height:0;max-width:1080px;margin:20px auto 0;border-radius:32px}
.w4-graphic .sun{left:24%;top:30%;width:130px;height:130px;margin-left:-65px}
.w4-graphic .hills,.w4-graphic .hills2{width:50%;height:290px}
.w4-graphic .hills2{display:block;position:absolute;right:0;bottom:0;transform:scaleX(-1)}
.w4-graphic .hills2 .hills{width:100%;height:100%}
.w4-graphic .hero-copy{left:50%;right:48px;top:52px;text-align:left}
.w4-graphic .hero-copy h1{font-size:44px;margin-top:12px}
.w4-graphic .cp{font-size:28px;min-height:2.4em}
.w4-graphic .hgo{display:flex;gap:10px;margin-top:18px}
.w4-graphic .hgo .btn{padding:12px 26px;font-size:17px}
.w4-graphic .hgo .btn--line{font-size:15px;padding:10px 20px;background:#13322659}
.w4-graphic .wx{left:32px;right:auto;bottom:28px;max-width:400px;padding:12px 16px}
.w4-graphic .wx .deg{font-size:34px}
.w4-graphic .intro{display:grid;grid-template-columns:1.1fr 1fr 1fr;column-gap:32px;max-width:1144px;margin:0 auto;padding:0 32px;align-items:start}
.w4-graphic .intro>.sec{max-width:none;padding-left:0;padding-right:0;margin:0}
.w4-graphic .intro>.sec{grid-row:1}
.w4-graphic .intro>.pic{grid-column:3;grid-row:1;margin-top:var(--gap-sec)}
.w4-graphic .intro>.pic img{aspect-ratio:3/2}
.w4-graphic .icons{grid-template-columns:repeat(3,1fr);gap:10px;margin-top:20px}
.w4-graphic .icons>*{padding:14px}
.w4-graphic .icons svg{width:34px;height:34px}
.w4-graphic #info .icons{grid-template-columns:repeat(6,1fr)}
.w4-graphic .rgrid{grid-template-columns:repeat(4,1fr);gap:16px}
.w4-graphic .units{display:grid;grid-template-columns:repeat(3,1fr);gap:24px;max-width:1144px;margin:0 auto;padding:var(--gap-sec) 32px 0}
.w4-graphic .units>.sec{padding:0;max-width:none;margin:0;min-width:0}
.w4-graphic .units h2{font-size:24px}
.w4-graphic .units .bub{margin-bottom:8px}
.w4-graphic .room--full .slide img{aspect-ratio:3/2}
.w4-graphic .room .bd{padding:14px 0 0}
.w4-graphic .units .lead{font-size:15px;margin-top:0}
.w4-graphic .units .spec{margin-top:10px}
.w4-graphic .units .more,.w4-graphic .units .cta{min-height:42px;margin-top:8px;font-size:15px}
.w4-graphic .book{display:grid;grid-template-columns:1fr auto;align-items:center;gap:8px 32px;text-align:left;padding:24px 40px}
.w4-graphic .book h2{font-size:30px}
.w4-graphic .book p{margin-top:0;grid-column:1}
.w4-graphic .book .btns{grid-column:2;grid-row:1/span 2;flex-direction:row;flex-wrap:wrap;justify-content:flex-end;margin-top:0;max-width:560px}
.w4-graphic .book .btn{padding:12px 24px;font-size:16px}
.w4-graphic .book .btn--line{padding:10px 20px;font-size:15px}
.w4-graphic .rail{margin-left:0;margin-right:0;padding-left:0;padding-right:0;gap:16px}
.w4-graphic .card{flex:0 0 240px}
.w4-graphic .rail .card{flex:0 0 220px;min-width:0;padding:12px}
.w4-graphic #video{display:grid;grid-template-columns:1.25fr 1fr;column-gap:40px;align-content:start}
.w4-graphic #video>*{grid-column:1}
.w4-graphic #video>.label{justify-self:start}
.w4-graphic #video>.rail{grid-column:2;grid-row:1/span 8;margin-top:0}
.w4-graphic #video .film{flex:0 0 100%;overflow:visible;height:auto}
.w4-graphic #video .cap{display:block;margin-top:6px;font-size:15px;color:var(--sub)}
.w4-graphic .shots--lead{grid-template-columns:2fr 1fr 1fr;grid-auto-rows:110px}
.w4-graphic .shots--lead .shot,.w4-graphic .shots--lead .shot:first-child{aspect-ratio:auto}
.w4-graphic .shots--lead .shot:first-child{grid-column:1;grid-row:span 2}
.w4-graphic .shots:not(.shots--lead){grid-template-columns:repeat(4,1fr)}
.w4-graphic .guide{grid-template-columns:repeat(3,1fr)}
.w4-graphic .guide--card{grid-template-columns:repeat(4,1fr);gap:16px}
.w4-graphic .guide--card .gitem{grid-template-columns:1fr;gap:6px;padding:0 0 8px;overflow:hidden;min-height:0;align-content:start}
.w4-graphic .guide--card .gitem .ic{width:100%;height:auto;aspect-ratio:3/2;border-radius:0}
.w4-graphic .guide--card .gitem .ic svg{width:40px;height:40px}
.w4-graphic .guide--card .gt,.w4-graphic .guide--card small{padding:0 14px}
.w4-graphic .guide--card .gt b{font-size:17px}
.w4-graphic .guide--two,.w4-graphic .rows,.w4-graphic .list,.w4-graphic .checks,.w4-graphic .courses{grid-template-columns:1fr 1fr}
.w4-graphic .grid3{grid-template-columns:repeat(4,1fr);gap:12px}
.w4-graphic .faq{display:grid;grid-template-columns:1fr 1fr;gap:10px;align-items:start}
.w4-graphic .faq details{margin-bottom:0}
.w4-graphic .faq .rest{grid-column:1/-1;background:none;display:grid;grid-template-columns:1fr 1fr;gap:10px}
.w4-graphic .faq .rest>summary{grid-column:1/-1}
.w4-graphic #location{display:grid;grid-template-columns:1fr 1fr;column-gap:32px;align-content:start}
.w4-graphic #location>*{grid-column:1/-1}
.w4-graphic #location>.bub{justify-self:start}
.w4-graphic #location>.map{grid-column:1;grid-row:4/span 3;height:auto;min-height:260px;margin-top:20px}
.w4-graphic #location>.spec,.w4-graphic #location>.checks,.w4-graphic #location>.btns{grid-column:2}
.w4-graphic #location>.spec{margin-top:20px}
.w4-graphic #location .btns{flex-wrap:wrap}
.w4-graphic .more,.w4-graphic .cta{max-width:360px;margin-left:auto;margin-right:auto;min-height:44px}
.w4-graphic footer{margin-top:44px;padding:20px max(32px,calc((100% - 1080px)/2)) 20px}
.w4-graphic footer{grid-template-columns:auto 1fr;column-gap:32px;align-items:center}
.w4-graphic footer .fname{grid-row:1/span 3;margin:0}
.w4-graphic footer>p:not(.fname){grid-column:2;text-align:right}
.w4-graphic #video{padding-bottom:24px}
.w4-graphic footer .hill{top:-31px;height:32px}
.w4-graphic[data-page=area] div:has(>#songs){display:grid!important;grid-template-columns:repeat(2,minmax(0,540px));column-gap:32px;justify-content:center;align-items:start}
.w4-graphic[data-page=area] div:has(>#songs)>.wrap{max-width:none;margin:0;padding-left:0;padding-right:0;min-width:0}
.w4-graphic[data-page=area] div:has(>#songs)>#story{grid-column:1/-1}
.w4-graphic[data-page=area] div:has(>#songs) .rows,.w4-graphic div:has(>#songs) .courses{grid-template-columns:1fr}
.w4-graphic[data-page=area] div:has(>#songs) .line{padding:12px 18px}
.w4-graphic .rows--ev .line{min-height:0;padding:8px 18px}
.w4-graphic #postcard-maker,.w4-graphic #reviews{--section-space:32px}
.w4-graphic #reviews ul{margin-bottom:16px}
.w4-graphic .pagehead{padding-top:24px}
.w4-graphic[data-page=area] div:has(>#songs)>.wrap.tight{padding-top:36px}
.w4-graphic #postcard-maker,.w4-graphic #reviews{padding-bottom:16px!important}
.w4-graphic .guide--two .gitem{padding-top:8px;padding-bottom:8px;min-height:0}
.w4-graphic .course{padding:14px 18px}
.w4-graphic .course .clamp--3{-webkit-line-clamp:2}
.w4-graphic[data-page=area] div:has(>#songs) .grid3{grid-template-columns:repeat(3,minmax(0,1fr))}
.w4-graphic .player,.w4-graphic .player.low{right:24px;bottom:24px}
.w4-graphic .tabbar{display:none}}
@media(min-width:1024px){.w4-graphic{--edge:max(32px,calc((100vw - 1080px) / 2))}
.w4-graphic .scene{max-width:none;width:100%;height:540px;margin:0;border-radius:0}
.w4-graphic .hero-copy{left:50%;right:var(--edge);top:64px}
.w4-graphic .wx{left:var(--edge);bottom:32px}}
@media(min-width:1024px){.w4-graphic .intro{grid-template-columns:minmax(0,1fr) minmax(0,1fr);column-gap:56px}
.w4-graphic .intro>.sec{grid-row:auto}
.w4-graphic .intro>#about{grid-column:1;grid-row:1}
.w4-graphic .intro>.pic{grid-column:2;grid-row:1}
.w4-graphic .intro>.pic img{aspect-ratio:4/3}
.w4-graphic .intro>.sec:not(#about):not(.pic){grid-column:1/-1;grid-row:2}
.w4-graphic .rgrid{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}}
.w4-graphic .sec>.eyebrow{display:inline-block;background:var(--accent);color:#fff;border-radius:999px;padding:3px 14px;font-size:14px;margin-bottom:14px;transform:rotate(-2deg);letter-spacing:0;text-transform:none}
.w4-graphic .film .poster img{aspect-ratio:16/9;height:auto;object-fit:cover}
@media(max-width:1023px){.w4-graphic #location .btns{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.w4-graphic #location .btns>:first-child{grid-column:1/-1}
.w4-graphic #location .btns>*{white-space:nowrap;min-width:0}}

Some files were not shown because too many files have changed in this diff Show More