[feat] solution/site: 고택(paper) 레이아웃을 /s/stay2 시안과 같게 — 탭 4개 · stay2 마크업 이식

고택이 stay2 DOM 이식 1차에 머물러 화면 전체 폭·다른 머리글로 나가 시안과 달랐다.

- layouts/paper/paper.css: stay2 CSS 를 .w4p 안으로 옮겼다(@layer components, Tailwind 유틸이 이긴다)
- layouts/paper: 머리글·탭·첫 화면·날씨·블로그·소개·객실·사진·영상·주변·예약·이용안내·오시는 길·FAQ·축제·일정·지역 이야기·예약 요청 창
- stay2 하위 페이지(지역 소개·이용안내·이야기)는 #/area · #/stay · #/story 탭 전환, 내용은 한 HTML 에 둔다
- 공용 섹션에서 계산 함수만 꺼내 쓴다: infoView · useBookingRequest · StayBookingDemo 달력 함수
- SectionList: 레이아웃이 blog · reviews · postcardMaker 자리를 바꿀 수 있다
- templates.json paper: 보조색 #5b564e · 본문 고딕 · 모서리 4px · 간격 2.5rem(stay2 값)
- app.test: 고택 Frame 표식을 새 클래스(w4p)로

머뭄 데이터로 구워 1280·390px 에서 stay2 와 나란히 비교(첫 페이지 4855/4857px).
site tsc·eslint·vitest 105 passed, frontend vite build 통과

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-09-28 16:05:21 +09:00
parent 11d30bb3d1
commit 765b9b6463
30 changed files with 2520 additions and 482 deletions

View File

@ -6,6 +6,52 @@
존재하지 않는 템플릿 id를 가리켰고, 음식점 강조색에 오타가 있었고, 섹션 간격이 빌더와 서버에서 존재하지 않는 템플릿 id를 가리켰고, 음식점 강조색에 오타가 있었고, 섹션 간격이 빌더와 서버에서
달랐다. 병원 "클린" 템플릿은 이름과 실제 모양이 맞지 않았다. 달랐다. 병원 "클린" 템플릿은 이름과 실제 모양이 맞지 않았다.
- 템플릿 목록은 `solution/shared/src/data/templates.json` 하나다. TS와 파이썬이 같은 파일을 읽는다.
- 템플릿 id에서 업종을 뗐다. `stay-retro` → `retro`. 기존 DB 값은 마이그레이션 `0023`으로 바꾼다(운영 미적용).
- 모르는 템플릿 id는 저장·미리보기·발행에서 모두 거절한다. 기본값으로 슬쩍 굽지 않는다.
- 레이아웃은 `basic`과 `paper` 둘만 남겼다. 연결 안 된 레이아웃 5개, 배치 고르기, 서체 선택, 빌더 캔버스를 지웠다.
- 템플릿을 바꾸면 이전 템플릿이 켠 섹션은 꺼지고, 빌더에 그 안내가 뜬다.
- 바뀐 동작: 저장된 모양(look)과 배치 선택은 무시한다. 레트로 사진은 캐러셀에서 그리드로 바뀐다.
병원은 날씨·주변 정보가 기본으로 꺼진다. 모두 재발행할 때부터 적용된다.
- 고택(`paper`)을 `/s/stay2` 시안과 같게 다시 만들었다. stay2의 CSS를 `layouts/paper/paper.css`로
옮기고, 섹션마다 stay2 마크업으로 그린다. 하위 페이지는 탭으로 바꿔 한 HTML 안에 둔다.
같은 머뭄 데이터로 구워 1280px·390px에서 stay2와 나란히 찍어 비교했다.
- 프로젝트 코드 주석을 한 줄로 줄이고 히스토리 주석을 지웠다(파일 478개). 파이썬은 정리 전후 문법
트리가 같은지, TS는 주석을 뺀 토큰이 같은지 대조했다.
구조와 새 템플릿 추가 방법은 [TEMPLATES.md](TEMPLATES.md).
**검증** — shared·site·frontend·admin `tsc`, site `eslint`·`vitest` 105개, frontend `vite build` 통과.
백엔드는 DB 없이 도는 테스트 41개 통과, DB가 필요한 테스트는 로컬 DB 접속 문제로 못 돌렸다.
## 2026-09-23 — 개발자 전용 사이트관리·유저관리를 solution 앱에 경량으로
admin/frontend(:9801)를 새 메뉴로 키우려면 새 도메인이 필요하고 아직 그럴 기능도 안
갖춰졌다(대표 지시) — 그래서 대신 solution 앱(:9800)에 얹었다. `UserRole.DEVELOPER` 게이트
하나로, 회사 스코프를 걷어낸(2026-09-08, DECISIONS.md) 전 계정 사이트·유저 목록(읽기 전용)을 본다.
- **백엔드**: `router/v1/ops/ops.py`(`GET /v1/ops/sites`, `GET /v1/ops/users`, 전부
`RequireDeveloper`) + `services/ops_service.py` + `crud/site_crud.py:list_all_sites` /
`crud/user_crud.py:list_users`. 유저 목록은 USER/OWNER 만 — 개발자 계정은 여기서도 뺀다
(`UserRole` 주석 원칙을 내부 화면에도 지킨다).
- **프론트**: `pages/OpsSitesPage.tsx` · `OpsUsersPage.tsx`(`/ops/sites` · `/ops/users`).
`AppShell.tsx` 의 기본 nav(`OWNER_NAV`)에 `role===DEVELOPER` 일 때만 두 줄을 더 붙인다.
★ 이 문자열은 role 과 무관하게 사장님에게 나가는 번들에도 실린다(런타임 조건부 렌더일 뿐,
빌드 타임에 갈라지지 않는다) — AppShell 주석의 "메뉴가 섞이면 새어 나간다"가 그대로 적용된다.
실제 데이터 접근은 백엔드 게이트가 막으므로 새는 것은 경로 이름 정도다.
- 액션(재발행·상태 토글·강제 로그아웃 등)은 다음 단계 — 이번엔 조회만.
**검증** — DB 접속이 안 되는 환경이라 pytest 는 못 돌렸다: `app.openapi()` 로 라우터 임포트·
스키마 생성 확인, `scripts/export_openapi.py` → `orval` 코드젠 성공, 프론트 `tsc --noEmit` ·
`eslint src` 통과. 실제 DB 조회 동작은 미검증 — docker compose 로 띄운 뒤 확인 필요.
## 2026-09-28 — 템플릿 정의를 한 파일로 모았다
템플릿 정보가 빌더, 렌더러, 백엔드에 따로따로 적혀 있어서 서로 어긋나 있었다. 백엔드 기본값이
존재하지 않는 템플릿 id를 가리켰고, 음식점 강조색에 오타가 있었고, 섹션 간격이 빌더와 서버에서
달랐다. 병원 "클린" 템플릿은 이름과 실제 모양이 맞지 않았다.
- 템플릿 목록은 `solution/shared/src/data/templates.json` 하나다. TS와 파이썬이 같은 파일을 읽는다. - 템플릿 목록은 `solution/shared/src/data/templates.json` 하나다. TS와 파이썬이 같은 파일을 읽는다.
- 템플릿 id에서 업종을 뗐다. `stay-retro` → `retro`. 기존 DB 값은 마이그레이션 `0023`으로 바꾼다(운영 미적용). - 템플릿 id에서 업종을 뗐다. `stay-retro` → `retro`. 기존 DB 값은 마이그레이션 `0023`으로 바꾼다(운영 미적용).
- 모르는 템플릿 id는 저장·미리보기·발행에서 모두 거절한다. 기본값으로 슬쩍 굽지 않는다. - 모르는 템플릿 id는 저장·미리보기·발행에서 모두 거절한다. 기본값으로 슬쩍 굽지 않는다.

View File

@ -46,8 +46,10 @@
| `retro` | 레트로 | basic | | `retro` | 레트로 | basic |
| `paper` | 고택 | paper | | `paper` | 고택 | paper |
심플·매거진·레트로는 뼈대가 같고 색과 서체만 다르다. 고택만 머리글, 첫 화면, 객실 목록, 심플·매거진·레트로는 뼈대가 같고 색과 서체만 다르다. 고택은 손으로 만든 시안 `/s/stay2`를
섹션 제목 모양이 따로 있다. 그대로 옮긴 것이라 뼈대부터 다르다. 폭 640px짜리 한 단에, 위쪽 탭 네 개(소개 · 지역 소개 ·
이용안내·예약 · 이야기)로 화면을 나눈다. 탭을 눌러도 페이지를 새로 받지 않는다. 내용은 전부 한
HTML 안에 있고 보이는 묶음만 바뀐다. 검색엔진은 네 묶음을 다 읽는다.
업종마다 쓸 수 있는 템플릿과 기본 템플릿은 같은 파일의 `industries`에 적는다. 숙박은 레트로가 업종마다 쓸 수 있는 템플릿과 기본 템플릿은 같은 파일의 `industries`에 적는다. 숙박은 레트로가
기본이고, 병원은 심플과 매거진만 쓸 수 있다. 기본이고, 병원은 심플과 매거진만 쓸 수 있다.
@ -60,8 +62,12 @@
- `SectionHead`: 섹션 제목 모양. 없으면 공용 제목을 쓴다. - `SectionHead`: 섹션 제목 모양. 없으면 공용 제목을 쓴다.
- `sections`: 섹션별로 바꿔 그릴 컴포넌트. 여기 안 적은 섹션은 공용 컴포넌트를 그대로 쓴다. - `sections`: 섹션별로 바꿔 그릴 컴포넌트. 여기 안 적은 섹션은 공용 컴포넌트를 그대로 쓴다.
고택은 첫 화면·객실·제목만 바꿨고, FAQ나 오시는 길은 심플과 같은 컴포넌트를 쓴다. 새 새 레이아웃을 만들 때 전부 새로 그릴 필요는 없다. 다르게 보여야 하는 섹션만 만들면 된다.
레이아웃을 만들 때도 전부 새로 그릴 필요 없이 다르게 보여야 하는 섹션만 만들면 된다. 고택은 시안과 똑같이 맞추느라 대부분의 섹션을 따로 그렸다. 데이터를 고르는 계산(이용안내 표,
예약 달력, 예약 요청 전송)은 공용 섹션의 함수를 그대로 가져다 쓰고, 모양만 따로 그린다.
고택에서 섹션이 어느 탭에 속하는지는 `layouts/paper/paper.css` 맨 아래 규칙이 정한다. 새 섹션을
만들고 이 규칙에 적지 않으면 첫 탭(소개)에 나온다.
### 이상한 값이 들어오면 멈춘다 ### 이상한 값이 들어오면 멈춘다

View File

@ -89,7 +89,7 @@
"layout": "paper", "layout": "paper",
"colors": { "colors": {
"primary": "#1f1d19", "primary": "#1f1d19",
"secondary": "#726c61", "secondary": "#5b564e",
"bg": "#fdfcfa", "bg": "#fdfcfa",
"card": "#f2efe8", "card": "#f2efe8",
"text": "#1f1d19", "text": "#1f1d19",
@ -97,13 +97,13 @@
}, },
"look": { "look": {
"fontHeading": "'Noto Serif KR', 'AppleMyungjo', 'Nanum Myeongjo', serif", "fontHeading": "'Noto Serif KR', 'AppleMyungjo', 'Nanum Myeongjo', serif",
"fontBody": "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif", "fontBody": "-apple-system, 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif",
"radius": "0px", "radius": "4px",
"borderWidth": "1px", "borderWidth": "1px",
"shadow": "none", "shadow": "none",
"headingTracking": "0.03em", "headingTracking": "0.03em",
"headingWeight": "400", "headingWeight": "400",
"sectionSpace": "4.5rem" "sectionSpace": "2.5rem"
}, },
"addSections": [] "addSections": []
} }

View File

@ -14,7 +14,8 @@ it('템플릿이 가리키는 레이아웃의 Frame으로 그린다', () => {
const basic = renderToStaticMarkup(<App payload={withTemplate('simple')} />); const basic = renderToStaticMarkup(<App payload={withTemplate('simple')} />);
const paper = renderToStaticMarkup(<App payload={withTemplate('paper')} />); const paper = renderToStaticMarkup(<App payload={withTemplate('paper')} />);
expect(basic).toContain('shell flex h-16'); expect(basic).toContain('shell flex h-16');
expect(paper).toContain('shell flex h-14'); expect(paper).toContain('class="w4p"');
expect(basic).not.toContain('class="w4p"');
}); });
it('여러 템플릿이 같은 레이아웃을 쓴다', () => { it('여러 템플릿이 같은 레이아웃을 쓴다', () => {

View File

@ -1,10 +1,25 @@
import type {LayoutId} from '@o2o/shared'; import type {LayoutId} from '@o2o/shared';
import type {Layout} from '@site/lib/layout'; import type {Layout} from '@site/lib/layout';
import {Frame as BasicFrame} from './basic/Frame'; import {Frame as BasicFrame} from './basic/Frame';
import {Around as PaperAround} from './paper/Around';
import {Booking as PaperBooking} from './paper/Booking';
import {Faq as PaperFaq} from './paper/Faq';
import {Festival as PaperFestival} from './paper/Festival';
import {Frame as PaperFrame} from './paper/Frame'; import {Frame as PaperFrame} from './paper/Frame';
import {Gallery as PaperGallery} from './paper/Gallery';
import {Hero as PaperHero} from './paper/Hero'; import {Hero as PaperHero} from './paper/Hero';
import {Info as PaperInfo} from './paper/Info';
import {Intro as PaperIntro} from './paper/Intro';
import {Itinerary as PaperItinerary} from './paper/Itinerary';
import {Location as PaperLocation} from './paper/Location';
import {Rooms as PaperRooms} from './paper/Rooms'; import {Rooms as PaperRooms} from './paper/Rooms';
import {SectionHead as PaperSectionHead} from './paper/SectionHead'; import {SectionHead as PaperSectionHead} from './paper/SectionHead';
import {Story as PaperStory} from './paper/Story';
import {StoryTail as PaperStoryTail} from './paper/StoryTail';
import {Video as PaperVideo} from './paper/Video';
import {Weather as PaperWeather} from './paper/Weather';
const Nothing = () => null;
// 등록 안 한 섹션은 공용 컴포넌트를 쓴다. // 등록 안 한 섹션은 공용 컴포넌트를 쓴다.
export const LAYOUTS: Record<LayoutId, Layout> = { export const LAYOUTS: Record<LayoutId, Layout> = {
@ -12,6 +27,26 @@ export const LAYOUTS: Record<LayoutId, Layout> = {
paper: { paper: {
Frame: PaperFrame, Frame: PaperFrame,
SectionHead: PaperSectionHead, SectionHead: PaperSectionHead,
sections: {hero: PaperHero, rooms: PaperRooms, menu: PaperRooms, programs: PaperRooms}, sections: {
hero: PaperHero,
weather: PaperWeather,
intro: PaperIntro,
blog: Nothing,
rooms: PaperRooms,
menu: PaperRooms,
programs: PaperRooms,
photos: PaperGallery,
video: PaperVideo,
local: PaperAround,
booking: PaperBooking,
info: PaperInfo,
map: PaperLocation,
faq: PaperFaq,
festival: PaperFestival,
itinerary: PaperItinerary,
story: PaperStory,
reviews: PaperStoryTail,
postcardMaker: Nothing,
},
}, },
}; };

View File

@ -0,0 +1,174 @@
import {useState} from 'react';
import type {LocalPlace} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverMapSearchUrl} from '@site/lib/format';
import {Car} from './Car';
import {Sheet} from './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_ROWS = 8;
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);
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>
);
}
const inRange = (id: string) => RANGES.find((r) => r.id === id)?.test ?? (() => true);
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');
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>
);
return (
<>
{rail.length > 0 && (
<div data-pg="home">
<section className="sec sec--flush" id="around" aria-labelledby="around-heading">
<p className="eyebrow">AROUND</p>
<div className="head" style={{padding: '0 var(--pad)'}}>
<h2 id="around-heading">주변 안내</h2>
<a href="#/area">지역 소개 →</a>
</div>
<p className="note" style={{margin: '-8px 0 14px', padding: '0 var(--pad)'}}>
걸어서 닿는 명소와 맛집입니다.
</p>
<Car
multi
width={150}
arrows
label="주변 안내"
slides={rail.map((place) => (
<button type="button" className="itemcard" onClick={() => setOpen(place)}>
<img src={place.imageUrl} alt={place.name} loading="lazy" style={{aspectRatio: '3 / 2'}} />
<p className="cap">{place.name}</p>
{place.distanceText && <p className="cap dim">{place.distanceText}</p>}
</button>
))}
/>
</section>
<div className="rule" />
</div>
)}
<div data-pg="area" id="guide">
{spots.length > 0 && (
<>
<section className="sec" id="walk">
<p className="eyebrow">AROUND</p>
<h2>
주변 명소 <span>{spots.length}곳</span>
</h2>
<p className="note" style={{margin: '-8px 0 14px'}}>
걸어서 닿는 곳부터 차로 가는 곳까지 묶었습니다.
</p>
<RangeTabs places={spots} value={spotRange} onChange={setSpotRange} />
<div className="grid2" style={{gap: 14}}>
{shownSpots.map((place) => (
<button key={place.name} type="button" className="itemcard" onClick={() => setOpen(place)}>
{place.imageUrl && (
<img src={place.imageUrl} alt={place.name} loading="lazy" style={{aspectRatio: '3 / 2'}} />
)}
<p className="cap">
<b>{place.name}</b> {place.distanceText && <span className="dim">{place.distanceText}</span>}
</p>
{place.description && <p className="cap dim clamp">{place.description}</p>}
</button>
))}
</div>
</section>
<div className="rule" />
</>
)}
{eats.length > 0 && (
<>
<section className="sec" id="eat">
<p className="eyebrow">EAT</p>
<h2>
주변 맛집 <span>{eats.length}곳</span>
</h2>
<RangeTabs places={eats} value={eatRange} onChange={setEatRange} />
{shownEats.slice(0, FIRST_ROWS).map(row)}
{shownEats.length > FIRST_ROWS && (
<details>
<summary
className="more"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{shownEats.length - FIRST_ROWS}곳 더 보기
</summary>
{shownEats.slice(FIRST_ROWS).map(row)}
</details>
)}
</section>
<div className="rule" />
</>
)}
</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,70 @@
import {PlaceCategory} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, stayBookingView} from '@site/lib/derive';
import {BOOK_EVENT} from './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">
<p className="eyebrow">RESERVATION</p>
<h2 id="booking-home-heading">이용안내 및 예약</h2>
<p className="note" style={{margin: '-8px 0 14px'}}>
요금과 예약 방법을 안내합니다.
</p>
{links.slice(0, 1).map((link) => (
<a key={link.url} className="cta" href={link.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(link)}
</a>
))}
<a className="cta ghost" href="#/stay">
이용안내 · 예약 요청
</a>
</section>
</div>
<div data-pg="stay">
<section className="sec" id="booking" aria-labelledby="booking-heading">
<p className="eyebrow">RESERVATION</p>
<h2 id="booking-heading">이용안내 및 예약</h2>
<p className="note" style={{margin: '-8px 0 14px'}}>
요금과 예약 방법을 안내합니다.
</p>
{links.map((link) => (
<a key={link.url} className="cta" href={link.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(link)}
</a>
))}
{phone && (
<a className="cta ghost" href={`tel:${phone}`}>
{phone}
</a>
)}
{stay && (
<button
type="button"
className="cta ghost"
onClick={book}
style={{
width: '100%',
cursor: 'pointer',
font: 'inherit',
fontSize: '15.5px',
}}
>
예약 요청하기
</button>
)}
</section>
<div className="rule" />
</div>
</>
);
}

View File

@ -0,0 +1,261 @@
import {useEffect, useMemo, useState} from 'react';
import {selectPublishable} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {useBookingRequest} from '@site/sections/BookingRequestSection';
import {
buildArrivalSlots,
buildMonth,
demoUnits,
MONTH_SPAN,
shortUnitName,
WEEKDAY_LABEL,
type DayCell,
} from '@site/sections/StayBookingDemo';
function CalendarIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden>
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M3 10h18M8 3v4M16 3v4" />
</svg>
);
}
const label = {display: 'block', margin: '14px 0 6px', fontSize: '14.5px', color: 'var(--sub)'} as const;
export function BookingForm() {
const payload = useSite();
const units = useMemo(() => demoUnits(payload), [payload]);
const checkIn = selectPublishable(payload.facts).find((fact) => fact.key === 'check_in_time')?.value ?? undefined;
const slots = useMemo(() => buildArrivalSlots(checkIn), [checkIn]);
const [today, setToday] = useState<Date | null>(null);
const [offset, setOffset] = useState(0);
const [openStep, setOpenStep] = useState(false);
const [dateIso, setDateIso] = useState<string | null>(null);
const [slot, setSlot] = useState<string | null>(null);
const [unitId, setUnitId] = useState<string | null>(units[0]?.unitId ?? null);
const [guests, setGuests] = useState(2);
const [confirmed, setConfirmed] = useState(false);
useEffect(() => setToday(new Date()), []);
const cursor = today ? new Date(today.getFullYear(), today.getMonth() + offset, 1) : null;
const cells = today && cursor ? buildMonth(cursor.getFullYear(), cursor.getMonth(), today) : [];
const day = cells.find((cell): cell is DayCell => cell !== null && cell.iso === dateIso) ?? null;
const unit = units.find((u) => u.unitId === unitId) ?? null;
const maxGuests = unit?.maxCapacity ?? 8;
const price =
day && unit ? (day.isWeekend ? (unit.weekendPrice ?? unit.weekdayPrice) : unit.weekdayPrice) : undefined;
const ready = Boolean(dateIso && unit && (slots.length === 0 || slot));
const stay = day && cursor ? `${cursor.getMonth() + 1}월 ${day.day}일${slot ? ` ${slot} 도착` : ''}` : undefined;
const who = unit ? `${shortUnitName(unit.name)} · ${guests}명` : `${guests}명`;
const {submit, sending, done} = useBookingRequest(stay, who);
useEffect(() => {
setGuests((current) => Math.min(current, unit?.maxCapacity ?? 8));
}, [unit]);
if (units.length === 0) return null;
if (done?.ok) {
return (
<div className="bsum">
<p className="bp">{done.message}</p>
<p className="bn">예약이 확정된 것은 아닙니다. 사장님이 확인 후 연락드립니다.</p>
</div>
);
}
return (
<form id="brform" onSubmit={submit}>
<p className="dim" style={{margin: '0 0 10px'}}>
빈 방을 확인해 사장님이 직접 연락드립니다.
</p>
<p className="note" style={{margin: '0 0 16px'}}>
예약이 확정되는 것은 아닙니다. 사장님 확인 후 연락드립니다.
</p>
<div className="bstep">
<button type="button" className="bacc" aria-expanded={openStep} onClick={() => setOpenStep((was) => !was)}>
<CalendarIcon />
<span>날짜 · 시간 선택</span>
<b>{openStep ? '−' : '+'}</b>
</button>
<div className="bbody" hidden={!openStep}>
<p className="blab">날짜</p>
{cursor && (
<p className="bmon" style={{display: 'flex', alignItems: 'center', justifyContent: 'space-between'}}>
<span>
{cursor.getFullYear()}년 {cursor.getMonth() + 1}월
</span>
<span>
<button
type="button"
aria-label="이전 달"
disabled={offset === 0}
onClick={() => setOffset((n) => n - 1)}
style={{border: 0, background: 'none', padding: '0 8px', cursor: 'pointer', color: 'inherit'}}
>
‹
</button>
<button
type="button"
aria-label="다음 달"
disabled={offset >= MONTH_SPAN}
onClick={() => setOffset((n) => n + 1)}
style={{border: 0, background: 'none', padding: '0 8px', cursor: 'pointer', color: 'inherit'}}
>
›
</button>
</span>
</p>
)}
<div className="bdow">
{WEEKDAY_LABEL.map((w) => (
<span key={w}>{w}</span>
))}
</div>
<div className="bcal">
{cells.map((cell, i) =>
cell ? (
<button
key={cell.iso}
type="button"
disabled={cell.past}
aria-pressed={cell.iso === dateIso}
onClick={() => setDateIso(cell.iso)}
>
{cell.day}
</button>
) : (
<span key={`e${i}`} />
),
)}
</div>
{slots.length > 0 && (
<>
<p className="blab">
도착 예정 시간{' '}
<span className="dim" style={{fontWeight: 400}}>
(체크인 {checkIn} 이후)
</span>
</p>
<div className="bchips">
{slots.map((s) => (
<button key={s} type="button" aria-pressed={s === slot} onClick={() => setSlot(s)}>
{s}
</button>
))}
</div>
</>
)}
<p className="blab">객실</p>
<div className="brooms">
{units.map((u) => (
<button
key={u.unitId}
type="button"
aria-pressed={u.unitId === unitId}
onClick={() => setUnitId(u.unitId)}
>
{u.name}
{u.maxCapacity && <small>최대 {u.maxCapacity}인</small>}
</button>
))}
</div>
<p className="blab">인원</p>
<div className="bcount">
<button type="button" aria-label="인원 줄이기" onClick={() => setGuests((n) => Math.max(1, n - 1))}>
−
</button>
<b>{guests}명</b>
<button type="button" aria-label="인원 늘리기" onClick={() => setGuests((n) => Math.min(maxGuests, n + 1))}>
+
</button>
</div>
</div>
</div>
<div className="bsum">
{stay ? (
<>
<p style={{margin: 0}}>
{stay} · {who}
</p>
{price != null && <p className="bp">{price.toLocaleString('ko-KR')}원</p>}
{price != null && (
<p className="bn">1박 기준 안내 요금입니다. 인원 추가·성수기 요금은 예약 창구에서 확인됩니다.</p>
)}
</>
) : (
<p className="dim" style={{margin: 0}}>
날짜를 골라 주세요
</p>
)}
</div>
{!confirmed ? (
<button
type="button"
className="cta"
id="bnext"
disabled={!ready}
onClick={() => setConfirmed(true)}
style={{width: '100%', marginTop: 14, border: 0, font: 'inherit', fontSize: '16.5px'}}
>
예약 요청 확인하기
</button>
) : (
<div>
<label htmlFor="br-name" style={label}>
성함
</label>
<input id="br-name" name="name" required maxLength={60} placeholder="이름을 적어 주세요" />
<label htmlFor="br-phone" style={label}>
연락처
</label>
<input id="br-phone" name="phone" type="tel" required maxLength={60} placeholder="010-0000-0000" />
<label htmlFor="br-email" style={label}>
이메일 <span style={{fontWeight: 400}}>(선택)</span>
</label>
<input id="br-email" name="email" type="email" maxLength={60} placeholder="선택 — 답변을 메일로도 받습니다" />
<label htmlFor="br-message" style={label}>
요청사항
</label>
<textarea id="br-message" name="message" maxLength={1000} placeholder="늦은 도착, 주차 대수 등" />
<input
type="text"
name="company"
tabIndex={-1}
autoComplete="off"
aria-hidden
style={{position: 'absolute', width: 0, height: 0, opacity: 0}}
/>
<label
htmlFor="br-consent"
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
marginTop: 14,
fontSize: '14.5px',
color: 'var(--sub)',
}}
>
<input id="br-consent" name="consent" type="checkbox" required style={{flex: '0 0 auto', marginTop: 3}} />
<span style={{minWidth: 0}}>예약 상담을 위한 연락처 수집·이용에 동의합니다. 상담이 끝나면 파기합니다.</span>
</label>
{done && !done.ok && (
<p className="note" style={{margin: '10px 0 0'}}>
{done.message}
</p>
)}
<button
type="submit"
className="cta"
disabled={sending}
style={{width: '100%', marginTop: 16, border: 0, font: 'inherit', fontSize: '16.5px', cursor: 'pointer'}}
>
{sending ? '보내는 중…' : '예약 요청 보내기'}
</button>
</div>
)}
</form>
);
}

View File

@ -0,0 +1,100 @@
import {useCallback, useEffect, useState, type CSSProperties, type ReactNode} from 'react';
import useEmblaCarousel from 'embla-carousel-react';
export function Car({
slides,
multi,
width,
autoplay,
dots,
arrows,
count,
label,
style,
}: {
slides: ReactNode[];
multi?: boolean;
width?: 150 | 200;
autoplay?: number;
dots?: boolean;
arrows?: boolean;
count?: boolean;
label: string;
style?: CSSProperties;
}) {
const [ref, api] = useEmblaCarousel({
loop: !multi,
align: 'start',
containScroll: multi ? 'trimSnaps' : false,
});
const [index, setIndex] = useState(0);
const [snaps, setSnaps] = useState<number[]>([]);
useEffect(() => {
if (!api) return;
const sync = () => {
setIndex(api.selectedScrollSnap());
setSnaps(api.scrollSnapList());
};
sync();
api.on('select', sync).on('reInit', sync);
return () => {
api.off('select', sync).off('reInit', sync);
};
}, [api]);
useEffect(() => {
if (!api || !autoplay || slides.length < 2) return;
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
const timer = window.setInterval(() => {
if (!document.hidden && !motion.matches) api.scrollNext();
}, autoplay);
return () => window.clearInterval(timer);
}, [api, autoplay, slides.length]);
const go = useCallback((to: number) => api?.scrollTo(to), [api]);
const className = ['car', multi && 'car--multi', width && `car--${width}`].filter(Boolean).join(' ');
return (
<div className={className} style={style} role="region" aria-roledescription="carousel" aria-label={label}>
<div className="vp" ref={ref}>
<div className="track">
{slides.map((slide, i) => (
<div className="slide" key={i}>
{slide}
</div>
))}
</div>
</div>
{arrows && slides.length > 1 && (
<>
<button type="button" className="arrow prev" aria-label="이전" onClick={() => api?.scrollPrev()}>
‹
</button>
<button type="button" className="arrow next" aria-label="다음" onClick={() => api?.scrollNext()}>
›
</button>
</>
)}
{count && slides.length > 1 && (
<p className="count">
{index + 1} / {slides.length}
</p>
)}
{dots && snaps.length > 1 && (
<div className="dots">
{snaps.map((_, i) => (
<button
key={i}
type="button"
className={i === index ? 'on' : undefined}
aria-label={`${i + 1}번째`}
aria-current={i === index}
onClick={() => go(i)}
/>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,47 @@
import {SourceType} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {faqList} from '@site/lib/derive';
const FIRST = 8;
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 faq" id="faq" aria-labelledby="faq-heading">
<p className="eyebrow">Q &amp; A</p>
<h2 id="faq-heading">
자주 묻는 질문 <span>{faqs.length}</span>
</h2>
{verified && (
<p className="note" style={{margin: '-8px 0 14px'}}>
아래 답변은 모두 사업자가 확인한 내용입니다.
</p>
)}
{faqs.slice(0, FIRST).map(item)}
{faqs.length > FIRST && (
<details className="rest">
<summary
className="more"
style={{display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: 16}}
>
{faqs.length - FIRST}개 더 보기
</summary>
{faqs.slice(FIRST).map(item)}
</details>
)}
</section>
<div className="rule" />
</>
);
}

View File

@ -0,0 +1,88 @@
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 './Car';
import {Sheet} from './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="sec sec--flush" id="festival" aria-labelledby="festival-heading">
<p className="eyebrow">SEASON</p>
<h2 id="festival-heading">
계절별 축제 <span>{festivals.length}</span>
</h2>
<p className="note" style={{margin: '-8px 0 14px'}}>
{payload.place.addressLocality ?? '이 지역'}의 축제와 행사를 계절로 묶었습니다.
</p>
{tabs.length > 1 && (
<div className="tabrow tabrow--flush" 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>
)}
<Car
key={season}
multi
width={200}
arrows
label="계절별 축제"
slides={shown.map((f) => (
<button type="button" className="itemcard" onClick={() => setOpen(f)}>
{f.imageUrl ? (
<img src={f.imageUrl} alt={f.name} loading="lazy" style={{aspectRatio: '3 / 2'}} />
) : (
<span className="noimg" style={{aspectRatio: '3 / 2'}}>
{f.name}
</span>
)}
<p className="cap">
<b>{f.name}</b>
</p>
<p className="cap dim">{[f.month, f.location].filter(Boolean).join(' · ')}</p>
</button>
))}
/>
</section>
<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.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>
<div className="rule" />
</>
);
}

View File

@ -1,230 +1,274 @@
import {useEffect, useState, type ReactNode} from 'react'; import {useEffect, useState, type ReactNode} from 'react';
import {Phone} from 'lucide-react'; import {PlaceCategory, selectPublishable} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {enabledSections} from '@site/lib/derive'; import {
bookingActionLabel,
bookingLinks,
channelLabel,
isSectionEnabled,
publicLinks,
stayBookingView,
} from '@site/lib/derive';
import {isoDate} from '@site/lib/format'; import {isoDate} from '@site/lib/format';
import {MobileTabBar} from '@site/sections/MobileTabBar';
import {SongPlayer} from '@site/sections/SongPlayer'; import {SongPlayer} from '@site/sections/SongPlayer';
import {BookingForm} from './BookingForm';
import {BOOK_EVENT} from './Rooms';
import {Sheet} from './Sheet';
import './paper.css';
interface TabEntry { const HOUSE_WORD: Record<number, string> = {
label: string; [PlaceCategory.LODGING]: '펜션',
anchor: string; [PlaceCategory.CAFE]: '카페',
} [PlaceCategory.RESTAURANT]: '가게',
[PlaceCategory.CLINIC]: '병원',
const ANCHOR: Record<string, string> = {
intro: 'about',
info: 'info',
rooms: 'units',
menu: 'units',
programs: 'units',
pricing: 'pricing',
booking: 'booking',
space: 'space',
inquiry: 'inquiry',
exhibition: 'exhibition',
photos: 'gallery',
local: 'guide',
weather: 'weather',
map: 'location',
faq: 'faq',
songs: 'songs',
daily: 'daily',
chronicle: 'chronicle',
reading: 'reading',
people: 'people',
quiz: 'quiz',
postcard: 'postcard',
video: 'video',
itinerary: 'itinerary',
}; };
const FALLBACK_LABEL: Record<string, string> = { function PhoneIcon() {
about: '소개', return (
info: '이용 정보', <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden>
units: '객실', <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" />
pricing: '요금', </svg>
booking: '예약 안내',
space: '공간',
inquiry: '문의',
exhibition: '관람 안내',
gallery: '사진',
guide: '주변',
weather: '날씨',
location: '오시는 길',
faq: '자주 묻는 질문',
songs: '노래',
daily: '일력',
chronicle: '연표',
reading: '읽기',
people: '인물',
quiz: '퀴즈',
postcard: '엽서',
video: '영상',
itinerary: '일정',
};
function useTabEntries(): TabEntry[] {
const payload = useSite();
const seen = new Set<string>();
const entries: TabEntry[] = [];
for (const section of enabledSections(payload)) {
const anchor = ANCHOR[section.id];
if (!anchor || seen.has(anchor)) continue;
if (anchor === 'units' && payload.units.length === 0) continue;
seen.add(anchor);
entries.push({label: section.name?.trim() || FALLBACK_LABEL[anchor] || anchor, anchor});
}
if (!seen.has('location')) entries.push({label: '오시는 길', anchor: 'location'});
return entries;
}
function useActiveAnchor(entries: TabEntry[]): string {
const anchors = entries.map((entry) => entry.anchor).join(',');
const [active, setActive] = useState('');
useEffect(() => {
if (typeof IntersectionObserver === 'undefined') return;
const targets = anchors
.split(',')
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null);
if (targets.length === 0) return;
const observer = new IntersectionObserver(
(records) => {
const hit = records.find((record) => record.isIntersecting);
if (hit) setActive(hit.target.id);
},
{rootMargin: '-45% 0px -50% 0px'},
); );
targets.forEach((el) => observer.observe(el)); }
return () => observer.disconnect();
}, [anchors]);
return active; export 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(0, 0);
};
sync();
window.addEventListener('hashchange', sync);
return () => window.removeEventListener('hashchange', sync);
}, []);
return 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}) { export function Frame({children}: {children: ReactNode}) {
const payload = useSite(); const payload = useSite();
const {place, site} = payload; const {place, site} = payload;
const entries = useTabEntries(); const tabs = useTabs();
const active = useActiveAnchor(entries); const page = usePage();
const address = place.roadAddress ?? place.address; const [bookingOpen, setBookingOpen] = useState(false);
useEffect(() => {
const open = () => setBookingOpen(true);
window.addEventListener(BOOK_EVENT, open);
return () => window.removeEventListener(BOOK_EVENT, open);
}, []);
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 instagram = publicLinks(payload).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);
return ( return (
<div <div className="w4p" data-page={page}>
className="flex min-h-screen w-full flex-col" <div className="app">
style={{backgroundColor: 'var(--tpl-bg, #fff)', color: 'var(--tpl-text, #1a1a1a)'}} <header className="top">
> <a className="brand mj" href="#/">
<header
className="border-line safe-t sticky top-0 z-40 w-full border-b backdrop-blur-md"
style={{backgroundColor: 'color-mix(in srgb, var(--tpl-surface, #fff) 90%, transparent)'}}
>
<div className="shell flex h-14 items-center justify-between gap-3">
<a
href="#top"
className="serif min-w-0 truncate text-[length:var(--fs-lead)]"
style={{fontWeight: 'var(--tpl-heading-weight, 400)'}}
>
{place.name} {place.name}
</a> </a>
<span className="mp">
<div className="flex shrink-0 items-center gap-1.5">
<SongPlayer /> <SongPlayer />
</span>
{place.phone && ( {place.phone && (
<a <a className="ico" href={`tel:${place.phone}`} aria-label="전화 걸기">
href={`tel:${place.phone}`} <PhoneIcon />
className="tap flex size-9 items-center justify-center rounded-full"
aria-label="전화 걸기"
>
<Phone className="size-4" />
</a> </a>
)} )}
</div>
</div>
{entries.length > 0 && (
<nav aria-label="주요 메뉴" className="border-line border-t">
<ul className="shell flex gap-5 overflow-x-auto">
{entries.map((entry) => {
const on = entry.anchor === active;
return (
<li key={entry.anchor} className="shrink-0">
<a
href={`#${entry.anchor}`}
aria-current={on ? 'true' : undefined}
className="tap flex h-11 items-center whitespace-nowrap text-[length:var(--fs-sm)]"
style={{
fontWeight: on ? 700 : 400,
boxShadow: on ? 'inset 0 -2px 0 currentColor' : undefined,
}}
>
{entry.label}
</a>
</li>
);
})}
</ul>
</nav>
)}
</header> </header>
<div className="flex w-full flex-1 flex-col">{children}</div> <nav className="tabs" aria-label="주요 메뉴">
{tabs.map((tab) => (
<footer
className="w-full pb-28 pt-12 md:pb-14"
style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)'}}
>
<div className="shell flex flex-col gap-4 text-[length:var(--fs-sm)]">
<p className="serif" style={{fontSize: 'var(--fs-lead)', fontWeight: 'var(--tpl-heading-weight, 400)'}}>
{place.name}
</p>
{address && <p className="opacity-100">{address}</p>}
{place.phone && (
<p>
<a href={`tel:${place.phone}`} className="underline-offset-2 hover:underline">
{place.phone}
</a>
</p>
)}
<div className="border-line flex flex-wrap items-center gap-x-3 gap-y-1 border-t pt-4 text-[length:var(--fs-xs)] opacity-100">
<span>상호: {place.name}</span>
{place.legal?.representative && <span>대표: {place.legal.representative}</span>}
{place.legal?.businessRegistrationNumber && (
<span>사업자등록번호: {place.legal.businessRegistrationNumber}</span>
)}
{place.legal?.mailOrderNumber && <span>통신판매업신고: {place.legal.mailOrderNumber}</span>}
{place.legal?.licenseNumber && (
<span>
{place.legal.licenseLabel ?? '인허가번호'}: {place.legal.licenseNumber}
</span>
)}
</div>
<p className="byline text-[length:var(--fs-xs)] opacity-100">
<span className="author">작성·운영 {place.name}</span>
{' · 최종 업데이트: '}
<time dateTime={site.updatedAt}>{isoDate(site.updatedAt)}</time>
</p>
<p className="text-[length:var(--fs-xs)] opacity-100">
<a <a
href="https://www.o2osolution.ai/" key={tab.page}
target="_blank" href={tab.page === 'home' ? '#/' : `#/${tab.page}`}
rel="noopener noreferrer" className={tab.page === page ? 'on' : undefined}
className="underline-offset-2 hover:underline"
> >
{tab.label}
</a>
))}
</nav>
{(Object.keys(PAGE_HEAD) as Exclude<PageId, 'home'>[]).map((id) => (
<div key={id} data-pg={id}>
<section className="sec" style={{paddingTop: 34, paddingBottom: 24}}>
<p
className="mj"
style={{
margin: 0,
fontSize: 24,
lineHeight: 1.5,
letterSpacing: '.04em',
}}
>
{id === 'area'
? (place.addressLocality?.replace(/(시|군)$/, '') ?? PAGE_HEAD.area.title)
: PAGE_HEAD[id].title}
</p>
<p className="note" style={{margin: id === 'area' ? '10px 0 20px' : '10px 0 0'}}>
{PAGE_HEAD[id].note}
</p>
{id === 'area' && (
<div className="pills">
{areaPills.map(([label, href]) => (
<a key={href} className="pill" href={href}>
{label}
</a>
))}
</div>
)}
</section>
<div className="rule" />
</div>
))}
{children}
{publicLinks(payload).length > 0 && (
<div data-pg="stay">
<section className="sec" id="channels">
<p className="eyebrow">CHANNELS</p>
<h2>공식 채널</h2>
{publicLinks(payload).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>
))}
</section>
</div>
)}
<footer>
{place.name}
{(place.roadAddress ?? place.address) && ` · ${place.roadAddress ?? place.address}`}
<br />
{hours.length > 0 && (
<>
{hours.join(' · ')}
<br />
</>
)}
{place.phone && <a href={`tel:${place.phone}`}>{place.phone}</a>}
{instagram && (
<>
{' · '}
<a href={instagram.url} target="_blank" rel="noopener noreferrer">
instagram
</a>
</>
)}
{place.legal?.businessRegistrationNumber && (
<p style={{margin: '10px 0 0', fontSize: '13.5px'}}>
{place.legal.representative && `대표 ${place.legal.representative} · `}
사업자등록번호 {place.legal.businessRegistrationNumber}
{place.legal.mailOrderNumber && ` · 통신판매업신고 ${place.legal.mailOrderNumber}`}
</p>
)}
<p style={{margin: '10px 0 0', fontSize: '13.5px', opacity: 0.72}} 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 AI O2O
</a> </a>
의 Web4Ai로 만든 사이트입니다. 의 Web4Ai로 만든 사이트입니다.
</p> </p>
</div>
</footer> </footer>
</div>
<MobileTabBar /> {(place.phone || booking) && (
<div className="bar">
{place.phone && (
<a className="cta ghost" href={`tel:${place.phone}`}>
전화
</a>
)}
{stayBooking ? (
<button
type="button"
className="cta"
onClick={() => setBookingOpen(true)}
style={{border: 0, cursor: 'pointer'}}
>
예약하기
</button>
) : (
booking && (
<a className="cta" href={booking.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(booking)}
</a>
)
)}
</div>
)}
{stayBooking && (
<Sheet open={bookingOpen} onClose={() => setBookingOpen(false)} label="예약 요청" plain>
<div style={{padding: '8px 28px 32px'}}>
<h3 className="mj" style={{margin: '0 0 16px', fontSize: 19}}>
예약 요청하기
</h3>
<BookingForm />
</div>
</Sheet>
)}
</div> </div>
); );
} }

View File

@ -0,0 +1,62 @@
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 './Sheet';
const FIRST = 4;
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="itemcard" onClick={() => setOpen(image)}>
<img src={image.url} alt={image.alt} loading="lazy" style={{aspectRatio: '4 / 3'}} />
</button>
);
return (
<div data-pg="home">
<section className="sec" id="gallery" aria-labelledby="gallery-heading">
<p className="eyebrow">GALLERY</p>
<div className="head">
<h2 id="gallery-heading">
{sectionName(payload, 'photos', '사진 갤러리')} <span>{images.length}장</span>
</h2>
</div>
<div className="grid2 grid2--tight">{images.slice(0, FIRST).map(card)}</div>
{images.length > FIRST && (
<details>
<summary
className="more"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
사진 {images.length - FIRST}장 더 보기
</summary>
<div className="grid2 grid2--tight" style={{marginTop: 4}}>
{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 className="rule" />
</div>
);
}

View File

@ -1,56 +1,26 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase'; import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {Car} from './Car';
const ROTATE_MS = 6000;
const MAX_SLIDES = 5; const MAX_SLIDES = 5;
export function Hero() { export function Hero() {
const payload = useSite(); const payload = useSite();
const {place, narrative} = payload; const {place, narrative} = payload;
const locality = place.addressLocality ?? place.addressRegion;
const slides = [...payload.media] const slides = [...payload.media]
.filter((image) => image.alt?.trim()) .filter((image) => image.alt?.trim())
.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary)) .sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary))
.slice(0, MAX_SLIDES); .slice(0, MAX_SLIDES);
const fixed = narrative.tagline ?? narrative.heroSubline;
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const touchX = useRef<number | null>(null);
const step = useCallback(
(delta: number) => setIndex((now) => (now + delta + slides.length) % slides.length),
[slides.length],
);
useEffect(() => {
if (paused || slides.length < 2) return;
const timer = setInterval(() => step(1), ROTATE_MS);
return () => clearInterval(timer);
}, [paused, step, slides.length]);
return ( return (
<section id="top" className="w-full"> <section className="hero" id="top" data-pg="home">
<div <Car
className="relative w-full overflow-hidden" label={`${place.name} 사진`}
style={{height: 'clamp(24rem, 74vh, 40rem)'}} autoplay={5000}
onMouseEnter={() => setPaused(true)} dots
onMouseLeave={() => setPaused(false)} style={{height: '100%'}}
onFocusCapture={() => setPaused(true)} slides={slides.map((image, i) => (
onBlurCapture={() => setPaused(false)}
onTouchStart={(event) => {
touchX.current = event.touches[0].clientX;
}}
onTouchEnd={(event) => {
const from = touchX.current;
touchX.current = null;
if (from === null) return;
const moved = event.changedTouches[0].clientX - from;
if (Math.abs(moved) > 40) step(moved < 0 ? 1 : -1);
}}
>
{slides.map((image, i) => (
<img <img
key={image.mediaId} key={image.mediaId}
src={image.url} src={image.url}
@ -58,66 +28,20 @@ export function Hero() {
fetchPriority={i === 0 ? 'high' : 'low'} fetchPriority={i === 0 ? 'high' : 'low'}
loading={i === 0 ? undefined : 'lazy'} loading={i === 0 ? undefined : 'lazy'}
decoding="async" decoding="async"
className="absolute inset-0 size-full object-cover object-center transition-opacity duration-700" style={{height: '100%'}}
style={{opacity: i === index ? 1 : 0}}
/> />
))} ))}
<div
className="pointer-events-none absolute inset-0"
style={{
background:
'linear-gradient(to top, color-mix(in srgb, var(--tpl-inverse, #1c1917) 42%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 10%, transparent) 40%, transparent 70%)',
}}
/> />
<div className="veil" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-col items-start gap-2 px-6 pb-10 sm:px-10 sm:pb-14"> <div className="herocopy">
{locality && ( {(place.addressLocality ?? place.addressRegion) && (
<p <p className="eyebrow">{place.addressLocality ?? place.addressRegion}</p>
className="text-[length:var(--fs-xs)] uppercase opacity-100" )}
style={{letterSpacing: '0.28em', color: 'var(--tpl-bg, #fff)'}} <h1 className="mj">{place.name}</h1>
> {fixed && <p className="tagline">{fixed}</p>}
{locality} <p className="cp">
<HeroCatchphrase />
</p> </p>
)}
<h1
className="serif"
style={{
fontSize: 'var(--fs-display)',
fontWeight: 'var(--tpl-heading-weight, 400)',
letterSpacing: 'var(--tpl-heading-tracking, 0.03em)',
lineHeight: 1.3,
color: 'var(--tpl-bg, #fff)',
}}
>
{place.name}
</h1>
{(narrative.tagline ?? narrative.heroSubline) && (
<p
className="measure text-[length:var(--fs-lead)] leading-relaxed opacity-100"
style={{color: 'var(--tpl-bg, #fff)'}}
>
<HeroCatchphrase>{narrative.tagline ?? narrative.heroSubline}</HeroCatchphrase>
</p>
)}
</div>
{slides.length > 1 && (
<ul className="absolute inset-x-0 bottom-5 flex items-center justify-center gap-2 sm:hidden">
{slides.map((image, i) => (
<li key={image.mediaId}>
<button
type="button"
onClick={() => setIndex(i)}
aria-label={`${i + 1}번째 사진`}
aria-current={i === index}
className={`h-1.5 rounded-full transition-all ${i === index ? 'w-7' : 'w-1.5'}`}
style={{backgroundColor: 'var(--tpl-bg, #fff)', opacity: i === index ? 1 : 0.5}}
/>
</li>
))}
</ul>
)}
</div> </div>
</section> </section>
); );

View File

@ -0,0 +1,89 @@
import {useSite} from '@site/lib/site-context';
import type {InfoRow} from '@site/lib/derive';
import {infoView, ON_VALUES} from '@site/sections/EssentialInfoSection';
function Row({row}: {row: InfoRow}) {
const long = Boolean(row.note) || row.value.length > 24;
return (
<div className={long ? 'row--long' : 'row'}>
<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="sec" id="info" aria-labelledby="info-heading">
<p className="eyebrow">INFORMATION</p>
<h2 id="info-heading">이용안내</h2>
<p className="dim" style={{margin: '6px 0 4px'}}>
방문 전 확인이 필요한 운영 규정과 시설 안내입니다.
</p>
{ruleRows.length > 0 && (
<>
<p className="infobar">
<i aria-hidden />
예약 전 확인
</p>
{[...short, ...long].map((row) => (
<Row key={row.key ?? row.label} row={row} />
))}
</>
)}
{detailRows.length + sorted.length > 0 && (
<>
<p className="infobar" style={{marginTop: 24}}>
시설 · 편의
</p>
{detailRows.map((row) => (
<Row key={row.key ?? row.label} row={row} />
))}
{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'}>
<i className="ic" aria-hidden>
{on ? '✓' : '✕'}
</i>
<span>
<b>{row.label}</b> {row.value}
</span>
</li>
);
})}
</ul>
)}
</>
)}
{notices.length > 0 && (
<div className="faq" style={{marginTop: 16}}>
{notices.map((text) => (
<details key={text.slice(0, 32)}>
<summary>예약 공지</summary>
<p style={{whiteSpace: 'pre-line'}}>{text}</p>
</details>
))}
</div>
)}
</section>
<div className="rule" />
</>
);
}

View File

@ -0,0 +1,139 @@
import {useSite} from '@site/lib/site-context';
import {galleryImages, 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;
}
export function Blog({full}: {full?: boolean}) {
const payload = useSite();
const [post, ...older] = [...(payload.posts ?? [])].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
if (!post) return null;
return (
<section className="sec" id={full ? 'blog' : undefined}>
<p className="eyebrow">BLOG</p>
<h2>미니 블로그</h2>
<p className="note" style={{margin: '-8px 0 14px'}}>
사장님이 남기는 숙소 이야기입니다.
</p>
<div style={{padding: '14px 0', borderBottom: '1px solid #efece6'}}>
<p className="dim" style={{margin: '0 0 6px'}}>
<time dateTime={post.publishedAt}>{dotDate(post.publishedAt)}</time>
</p>
<p
style={{
margin: 0,
fontSize: 17,
lineHeight: 1.85,
whiteSpace: 'pre-line',
}}
>
{post.body}
</p>
</div>
{!full && older.length > 0 && (
<a
href="#/story"
className="more"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: 14,
}}
>
이야기에서 지난 소식 더 보기
</a>
)}
{full && older.length > 0 && (
<details>
<summary
className="more"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: 14,
}}
>
지난 소식 {older.length}개
</summary>
{older.map((item) => (
<div key={item.postId} style={{padding: '14px 0', borderBottom: '1px solid #efece6'}}>
<p className="dim" style={{margin: '0 0 6px'}}>
<time dateTime={item.publishedAt}>{dotDate(item.publishedAt)}</time>
</p>
<p
style={{
margin: 0,
fontSize: 17,
lineHeight: 1.85,
whiteSpace: 'pre-line',
}}
>
{item.body}
</p>
</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 image = galleryImages(payload).find((m) => !m.isPrimary);
return (
<>
<div data-pg="story">
<Blog full />
<div className="rule" />
</div>
<div data-pg="home">
<Blog />
{about.length > 0 && (
<section className="sec" id="about" aria-labelledby="about-heading">
<p className="eyebrow">THE HOUSE</p>
<h2 id="about-heading">{place.name} 소개</h2>
{narrative.summary && (
<p className="note" style={{margin: '-8px 0 14px'}}>
{narrative.summary}
</p>
)}
<p>{about[0]}</p>
{about.length > 1 && (
<details>
<summary
className="more"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
더 읽기
</summary>
{about.slice(1).map((paragraph, i) => (
<p key={i}>{paragraph}</p>
))}
</details>
)}
</section>
)}
{image && (
<figure>
<img src={image.url} alt={image.alt} loading="lazy" style={{aspectRatio: '16 / 9'}} />
</figure>
)}
<div className="rule" style={{marginTop: 40}} />
</div>
</>
);
}

View File

@ -0,0 +1,103 @@
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 './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="itemcard"
onClick={() => setOpen(item)}
style={{padding: '16px 18px', border: '1px solid var(--line)', marginBottom: 10}}
>
<p style={{margin: '0 0 4px', fontSize: '15.5px', fontWeight: 500}}>{item.name}</p>
<p className="cap dim" style={{margin: '0 0 8px'}}>
{[item.duration, item.audience].filter(Boolean).join(' · ')}
</p>
{item.why && <p style={{margin: '0 0 8px', fontSize: '16.5px', lineHeight: 1.8}}>{item.why}</p>}
<p className="note" style={{margin: 0}}>
{stopsOf(item)
.slice(0, 4)
.map((stop) => [stop.name.split(' · ').pop(), stop.minutes && `${stop.minutes}분`].filter(Boolean).join(' '))
.join(' → ')}
</p>
</button>
);
return (
<>
<section className="sec" id="itinerary" aria-labelledby="itinerary-heading">
<p className="eyebrow">ITINERARY</p>
<h2 id="itinerary-heading">
{parsed.title || sectionName(payload, 'itinerary', '추천 일정')} <span>{items.length}</span>
</h2>
<p className="note" style={{margin: '-8px 0 14px'}}>
{parsed.subtitle || '하루를 어떻게 쓸지 미리 짜 두었습니다.'}
</p>
{tags.length > 1 && (
<div className="tabrow" 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>
)}
{shown.slice(0, FIRST).map(card)}
{shown.length > FIRST && (
<details>
<summary className="more" style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
{shown.length - FIRST}개 더 보기
</summary>
{shown.slice(FIRST).map(card)}
</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>
<div className="rule" />
</>
);
}

View File

@ -0,0 +1,108 @@
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 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">
<p className="eyebrow">DIRECTIONS</p>
<h2 id="location-heading">오시는 길</h2>
<p className="note" style={{margin: '-8px 0 14px'}}>
주소와 주요 거점까지의 이동 시간을 안내합니다.
</p>
{lat != null && lng != null && (
<iframe
title={`${place.name} 위치 지도`}
src={osmEmbedUrl(lat, lng)}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
style={{
display: 'block',
width: '100%',
aspectRatio: '16 / 9',
border: '1px solid var(--line)',
borderRadius: 4,
marginBottom: 18,
}}
/>
)}
<dl className="spec">
{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 style={{marginTop: 18}}>
{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 style={{marginTop: 18}}>
<button
type="button"
className="cta ghost"
onClick={copy}
style={{width: '100%', cursor: 'pointer', font: 'inherit', fontSize: '15.5px'}}
>
{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>
<div className="rule" />
</>
);
}

View File

@ -1,139 +1,166 @@
import {Phone} from 'lucide-react'; import {useState} from 'react';
import type {ChannelLink} from '@o2o/shared'; import {factText, sanitizeUnits} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, sectionName, unitSpec, unitViews, type UnitView} from '@site/lib/derive'; import {sectionName, unitSpec, unitViews, type UnitView} from '@site/lib/derive';
import {Carousel, CarouselSlide} from '@site/lib/ui'; import {Car} from './Car';
import {SectionHead} from './SectionHead'; import {Sheet} from './Sheet';
export const BOOK_EVENT = 'w4p:book';
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 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() { export function Rooms() {
const payload = useSite(); const payload = useSite();
const units = unitViews(payload); const units = unitViews(payload);
const spec = unitSpec(payload); const spec = unitSpec(payload);
const booking = bookingLinks(payload)[0]; 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; if (units.length === 0) return null;
return ( return (
<section <>
id="units" <div data-pg="home">
aria-labelledby="units-heading" <section className="sec" id="units" aria-labelledby="units-heading">
className="border-line w-full border-b" <p className="eyebrow">ROOMS</p>
style={{backgroundColor: 'var(--tpl-surface, #fff)', paddingBlock: 'var(--section-space)'}} <h2 id="units-heading">{sectionName(payload, spec.path, `${spec.label} 안내`)}</h2>
> <div className="grid2" style={{gap: 14}}>
<div className="shell"> {units.map((unit) => (
<SectionHead <a key={unit.unitId} href={`#/stay`} className="itemcard">
id="units" {unit.images[0] && (
title={sectionName(payload, spec.path, `${spec.label} ${units.length}개 안내`)} <img
src={unit.images[0].url}
alt={unit.images[0].alt}
loading="lazy"
style={{aspectRatio: '3 / 2'}}
/> />
</div> )}
<p className="cap">
<div className="flex flex-col"> <b>{unit.name}</b>
{units.map((unit, i) => ( </p>
<UnitPanel key={unit.unitId} unit={unit} no={i + 1} booking={booking} phone={payload.place.phone} /> <p className="cap dim">
{specRows(unit, raw.get(unit.unitId)?.facts ?? [])
.filter(([label]) => label !== '요금')
.map(([, value]) => value)
.join(' · ')}
</p>
</a>
))} ))}
</div> </div>
<a
href="#/stay"
className="more"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: 14,
}}
>
이용안내에서 전체 보기
</a>
</section> </section>
); <div className="rule" />
} </div>
function UnitPanel({ <div data-pg="stay">
unit, {units.map((unit, i) => {
no, const [main, sub] = splitName(unit.name);
booking,
phone,
}: {
unit: UnitView;
no: number;
booking?: ChannelLink;
phone?: string;
}) {
return ( return (
<article className="border-line border-t"> <div key={unit.unitId}>
<section className="sec sec--flush" id={`unit-${unit.slug}`}>
<p className="eyebrow">ROOM {String.fromCharCode(65 + i)}</p>
<h2>
{main} {sub && <span>{sub}</span>}
</h2>
{unit.images.length > 0 && ( {unit.images.length > 0 && (
<Carousel label={`${unit.name} 사진`} align="center" gap={0} arrows="overlay"> <Car
{unit.images.map((image) => ( label={`${main} 사진`}
<CarouselSlide key={image.mediaId} basis="basis-full"> arrows
<div className="relative aspect-3/2 overflow-hidden"> count
dots
slides={unit.images.map((image) => (
<img <img
key={image.mediaId}
src={image.url} src={image.url}
alt={image.alt} alt={image.alt}
loading="lazy" loading="lazy"
decoding="async" style={{aspectRatio: '3 / 2'}}
className="size-full object-cover"
/> />
</div>
</CarouselSlide>
))} ))}
</Carousel> />
)} )}
<div style={{padding: '18px var(--pad) 0'}}>
<div className="shell flex flex-col gap-3 py-6"> {unit.intro && <p className="lead">{unit.intro}</p>}
<p <dl className="spec">
className="text-[length:var(--fs-xs)] uppercase opacity-70" {specRows(unit, raw.get(unit.unitId)?.facts ?? []).map(([label, value]) => (
style={{letterSpacing: '0.24em'}} <div key={label} style={{display: 'contents'}}>
> <dt>{label}</dt>
{String(no).padStart(2, '0')} <dd>{value}</dd>
</p>
<h3
className="serif"
style={{
fontSize: 'clamp(1.1rem, 2.4vw, 1.4rem)',
fontWeight: 'var(--tpl-heading-weight, 400)',
letterSpacing: 'var(--tpl-heading-tracking, 0.03em)',
}}
>
{unit.name}
</h3>
{unit.intro && (
<p className="text-[length:var(--fs-sm)] leading-relaxed opacity-100">{unit.intro}</p>
)}
{unit.rows.length > 0 && (
<dl className="border-line divide-line mt-2 divide-y border-t">
{unit.rows.map((row) => (
<div key={row.label} className="flex items-baseline justify-between gap-4 py-2 text-[length:var(--fs-sm)]">
<dt className="text-muted shrink-0">{row.label}</dt>
<dd className="text-right font-semibold">{row.value}</dd>
</div> </div>
))} ))}
</dl> </dl>
)} <button type="button" className="more" style={{marginTop: 18}} onClick={() => setOpen(unit)}>
{main} 자세히
{unit.chips.length > 0 && ( </button>
<ul className="mt-1 flex flex-wrap gap-1.5"> <button
{unit.chips.map((chip) => ( type="button"
<li className="cta"
key={chip.label} style={{
className="border-line inline-flex items-center rounded-full border px-3 py-1 text-[length:var(--fs-xs)]" width: '100%',
marginTop: 10,
border: 0,
cursor: 'pointer',
}}
onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}
> >
{chip.value} {main} 예약 요청
</li> </button>
))} </div>
</ul> </section>
)} <div className="rule" />
</div>
);
})}
</div>
{(booking || phone) && ( <UnitSheet unit={open} onClose={() => setOpen(null)} />
<div className="mt-2 flex flex-wrap items-center gap-x-6 gap-y-1 text-[length:var(--fs-sm)] font-semibold"> </>
{booking && (
<a
href={booking.url}
target="_blank"
rel="noopener noreferrer"
className="tap inline-flex items-center underline underline-offset-4"
>
{bookingActionLabel(booking)}
</a>
)}
{phone && (
<a href={`tel:${phone}`} className="tap inline-flex items-center gap-1.5 underline underline-offset-4">
<Phone className="size-4" />
<span>{phone}</span>
</a>
)}
</div>
)}
</div>
</article>
); );
} }

View File

@ -1,38 +1,43 @@
import type {ReactNode} from 'react'; import type {SectionHeadProps} from '@site/lib/layout';
export function SectionHead({ const EYEBROW: Record<string, string> = {
title, about: 'THE HOUSE',
lead, info: 'INFORMATION',
aside, units: 'ROOMS',
}: { booking: 'RESERVATION',
id: string; gallery: 'GALLERY',
title: string; guide: 'AROUND',
lead?: string; weather: 'TODAY',
aside?: ReactNode; location: 'LOCATION',
}) { faq: 'FAQ',
video: 'FILM',
blog: 'BLOG',
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 ( return (
<header className="mb-6 sm:mb-8"> <div className="phead">
<div aria-hidden className="border-line mb-4 w-full border-t" /> {EYEBROW[id] && <p className="eyebrow">{EYEBROW[id]}</p>}
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"> {aside ? (
<div className="min-w-0"> <div className="head">
<h2 <h2 id={`${id}-heading`}>{title}</h2>
className="serif" {aside}
style={{ </div>
fontSize: 'clamp(1.15rem, 2.4vw, 1.5rem)', ) : (
fontWeight: 'var(--tpl-heading-weight, 400)', <h2 id={`${id}-heading`}>{title}</h2>
letterSpacing: 'var(--tpl-heading-tracking, 0.03em)', )}
}}
>
{title}
</h2>
{lead && ( {lead && (
<p className="text-muted measure mt-2 text-[length:var(--fs-sm)] leading-relaxed"> <p className="note" style={{margin: '-8px 0 14px'}}>
{lead} {lead}
</p> </p>
)} )}
</div> </div>
{aside && <div className="shrink-0">{aside}</div>}
</div>
</header>
); );
} }

View File

@ -0,0 +1,38 @@
import {useEffect, type ReactNode} from 'react';
import {useScrollLock} from '@site/lib/ui/use-scroll-lock';
export function Sheet({
open,
onClose,
label,
plain,
children,
}: {
open: boolean;
onClose: () => void;
label: string;
plain?: boolean;
children: ReactNode;
}) {
useScrollLock(open);
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => event.key === 'Escape' && onClose();
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="sheet" role="dialog" aria-modal="true" aria-label={label}>
<div className="sbg" onClick={onClose} />
<div className="sbox">
<button type="button" className="sx" aria-label="닫기" onClick={onClose}>
×
</button>
{plain ? children : <div className="sin">{children}</div>}
</div>
</div>
);
}

View File

@ -0,0 +1,216 @@
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 './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>
<summary className="more" style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
{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="itemrow"
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="itemcard"
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" style={{aspectRatio: '1 / 1'}} />}
<p className="cap">
<b>{item.name}</b> {item.role && <span className="dim">{item.role}</span>}
</p>
{item.oneLine && <p className="cap dim clamp">{item.oneLine}</p>}
</button>
);
const event = (item: ChronicleItem) => (
<button
key={`${item.year}-${item.title}`}
type="button"
className="itemrow"
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" style={{display: 'block'}}>
{item.place}
</span>
)}
</span>
{item.year && <span className="r">{item.year}</span>}
</button>
);
const book = (item: ReadingItem) => (
<button
key={item.title}
type="button"
className="itemcard"
onClick={() => setOpen({title: item.title, meta: item.year ? String(item.year) : item.group, body: item.body})}
style={{padding: '16px 18px', background: 'var(--soft)', marginBottom: 10}}
>
<p style={{margin: '0 0 6px', fontSize: '15.5px'}}>
<b className="mj">{item.title}</b> {item.year && <span className="dim">{item.year}</span>}
</p>
<p className="clamp" style={{margin: 0, fontSize: '16.5px', lineHeight: 1.85, color: '#4a453a'}}>
{item.body}
</p>
</button>
);
return (
<>
<section className="sec" id="story" aria-labelledby="story-heading">
<p className="eyebrow">STORY</p>
<h2 id="story-heading" style={{marginBottom: 0}}>
{sectionName(payload, 'story', `${region} 이야기`)} <span>{parts.join(' · ')}</span>
</h2>
<p className="note" style={{margin: '10px 0 0'}}>
이 도시를 {COUNT_WORD[parts.length] ?? parts.length} 갈래로 봅니다. 하나씩 골라 보세요.
</p>
</section>
{songs.items.length > 0 && (
<section className="sec" style={{paddingTop: 18}} id="songs">
<p className="eyebrow">MUSIC</p>
<h2>
{songs.title || '가요 다방'} <span>{songs.items.length}곡</span>
</h2>
<p className="note" style={{margin: '-8px 0 14px'}}>
{region}을 노래한 곡들입니다.
</p>
{songs.items.slice(0, 6).map(song)}
<More rest={songs.items.length - 6} unit="곡">
{songs.items.slice(6).map(song)}
</More>
</section>
)}
{people.items.length > 0 && (
<section className="sec" id="people">
<p className="eyebrow">PEOPLE</p>
<h2>
{people.title || '인물 열전'} <span>{people.items.length}명</span>
</h2>
<p className="note" style={{margin: '-8px 0 14px'}}>
{region}과 얽힌 사람들입니다.
</p>
<div className="grid2 grid3">{peopleSorted.slice(0, 4).map(person)}</div>
<More rest={people.items.length - 4} unit="명">
<div className="grid2 grid3" style={{marginTop: 4}}>
{peopleSorted.slice(4).map(person)}
</div>
</More>
{withPhoto > 0 && withPhoto < people.items.length && (
<p className="note" style={{margin: '14px 0 0'}}>
사진이 있는 {withPhoto}명을 먼저 뒀습니다.
</p>
)}
</section>
)}
{chronicle.items.length > 0 && (
<section className="sec" id="chronicle">
<p className="eyebrow">CHRONICLE</p>
<h2>{chronicle.title || '시간의 골목'}</h2>
<p className="note" style={{margin: '-8px 0 14px'}}>
개항부터 지금까지, 이 도시의 연표입니다.
</p>
{chronicle.items.map(event)}
</section>
)}
{reading.items.length > 0 && (
<section className="sec" id="reading">
<p className="eyebrow">READING</p>
<h2>
{reading.title || `${region} 읽기`} <span>{reading.items.length}편</span>
</h2>
<p className="note" style={{margin: '-8px 0 14px'}}>
{region}을 배경으로 한 문학입니다.
</p>
{reading.items.slice(0, 3).map(book)}
<More rest={reading.items.length - 3} unit="편">
{reading.items.slice(3).map(book)}
</More>
</section>
)}
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.title ?? ''}>
{open && (
<>
{open.image && <img src={open.image} alt={open.title} style={{aspectRatio: '1 / 1', maxHeight: '60svh'}} />}
<h3>{open.title}</h3>
{open.meta && <p className="meta">{open.meta}</p>}
{open.body && <p>{open.body}</p>}
</>
)}
</Sheet>
</>
);
}

View File

@ -0,0 +1,11 @@
import {ReviewSection} from '@site/sections/ReviewSection';
import {PostcardMakerSection} from '@site/sections/items/PostcardMakerSection';
export function StoryTail() {
return (
<>
<PostcardMakerSection />
<ReviewSection />
</>
);
}

View File

@ -0,0 +1,43 @@
import {youtubeId, youtubePoster, type VideoItem} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {Car} from './Car';
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="sec sec--flush" id="video" aria-labelledby="video-heading">
<p className="eyebrow">FILM</p>
<h2 id="video-heading">
{parsed.title || sectionName(payload, 'video', '영상으로 보기')} <span>{items.length}편</span>
</h2>
{parsed.subtitle && (
<p className="note" style={{margin: '-8px 0 14px'}}>
{parsed.subtitle}
</p>
)}
<Car
multi
width={150}
arrows
label="영상"
slides={items.map((item) => (
<a className="itemcard" 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 && <p className="cap">{item.caption}</p>}
</a>
))}
/>
</section>
<div className="rule" />
</div>
);
}

View File

@ -0,0 +1,60 @@
import {Cloud, CloudRain, CloudSnow, Sun} from 'lucide-react';
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';
const MOOD_ICON = {
맑음: Sun,
흐림: Cloud,
비: CloudRain,
눈: CloudSnow,
} as const;
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];
return (
<div className="wbrief" id="weather" data-band={band} data-pg="home">
<p className="eyebrow wbrief-eyebrow" style={{marginBottom: 10}}>
TODAY · 오늘의 날씨
</p>
<div className="wbrief-badge">
<Icon className="wbrief-icon" strokeWidth={1.6} aria-hidden />
<b className="wbrief-temp">
{Math.round(weather.temperature * 10) / 10}° {weather.condition}
</b>
</div>
{line && (
<p id="wnote" key={line} className="wbrief-lead">
{line}
</p>
)}
{bandLine && (
<p key={bandLine} className="wbrief-sub">
{bandLine}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,322 @@
@layer theme, base, components, utilities;
@layer components {
.w4p{--paper:var(--tpl-bg,#fdfcfa);--ink:var(--tpl-text,#1f1d19);--sub:var(--tpl-secondary,#5b564e);--line:#e6e2da;--tint:var(--tpl-card,#f2efe8);--soft:#f8f6f1;--pad:20px;--wide:640px;--bar:64px}
.w4p *{box-sizing:border-box}
.w4p{margin:0;padding:0}
.w4p{background:var(--paper); color:var(--ink); font:17px/1.9 -apple-system,'Apple SD Gothic Neo','Noto Sans KR',sans-serif; -webkit-font-smoothing:antialiased; word-break:keep-all; padding-bottom:var(--bar);}
.w4p .app{max-width:var(--wide);margin:0 auto;overflow-x:clip;border-inline:1px solid var(--line)}
.w4p img{display:block;width:100%;object-fit:cover;background:var(--tint)}
.w4p a{color:inherit;text-decoration:none}
.w4p p{margin:0 0 14px}
.w4p .mj{font-family:'Noto Serif KR','AppleMyungjo','Nanum Myeongjo',serif;font-weight:400}
.w4p .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
.w4p .top{height:56px;padding:0 6px 0 var(--pad);display:flex;align-items:center;gap:8px; border-bottom:1px solid var(--line)}
.w4p .top .brand{flex:0 0 auto;font-size:17px;letter-spacing:.02em}
.w4p .top .ico{flex:0 0 auto;width:40px;height:44px;display:grid;place-items:center}
.w4p .mp{flex:1 1 auto;min-width:0;display:flex;align-items:center;justify-content:flex-end;gap:2px}
.w4p .mp button{width:40px;height:40px;padding:8px;border:0;background:none;color:var(--ink);cursor:pointer}
.w4p .mp svg{width:100%;height:100%}
.w4p .mp .now{min-width:0;max-width:108px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; font-size:14px;line-height:1.5;transform:translateY(2.4px);color:var(--sub)}
.w4p .mp.on .w4d-reel{transform-box:fill-box;transform-origin:center;animation:reel 2.4s linear infinite}
.w4p .mp.on .cas{color:#8c4a2f}
@keyframes reel{to{transform:rotate(360deg)}}
.w4p .mpsheet{display:none;border-bottom:1px solid var(--line);background:var(--soft)}
.w4p .mpsheet.on{display:block}
.w4p .mpsheet button{width:100%;height:46px;padding:0 var(--pad);border:0;border-bottom:1px solid var(--line); background:none;font:inherit;font-size:16.5px;color:var(--ink);text-align:left;cursor:pointer}
.w4p .mpsheet button:last-child{border-bottom:0}
.w4p .tabs{position:sticky;top:0;z-index:20;background:var(--paper);display:flex;gap:20px; padding:0 var(--pad);border-bottom:1px solid var(--line);overflow-x:auto; scrollbar-width:none;-webkit-overflow-scrolling:touch}
.w4p .tabs::-webkit-scrollbar{display:none}
.w4p .tabs a{flex:0 0 auto;height:48px;display:flex;align-items:center;font-size:17px; font-family:'Noto Serif KR','AppleMyungjo',serif;font-weight:400;letter-spacing:.02em; color:var(--sub)}
.w4p .tabs a.on{color:var(--ink);font-weight:500;box-shadow:inset 0 -1.5px 0 var(--ink)}
.w4p .hero{position:relative;height:min(62svh,520px);min-height:390px;background:#12110e;overflow:hidden}
.w4p .hero .vp,.w4p .hero .track{height:100%}
.w4p .hero .track{display:flex}
.w4p .hero .slide{flex:0 0 100%;min-width:0;height:100%}
.w4p .hero img{height:100%}
.w4p .veil{position:absolute;inset:0;pointer-events:none; background:linear-gradient(to top,rgba(10,9,7,.68) 0%,rgba(10,9,7,.24) 46%, rgba(10,9,7,.06) 72%,rgba(10,9,7,.28) 100%)}
.w4p .herocopy{position:absolute;left:0;right:0;bottom:0;padding:0 var(--pad) 34px;color:#fff; pointer-events:none}
.w4p .herocopy .eyebrow{color:#fff;opacity:.8;margin:0 0 10px}
.w4p .herocopy h1{margin:0;font-size:25px;font-weight:300;letter-spacing:.16em;line-height:1.4}
.w4p .herocopy .tagline{margin:12px 0 0;font-size:16px;line-height:1.7;opacity:.92}
.w4p .hero .dots{position:absolute;left:0;right:0;bottom:12px;z-index:2}
.w4p .hero .dots button{background:rgba(253,252,250,.45)}
.w4p .hero .dots button.on{background:#fff}
.w4p .herocopy .cp{margin:6px 0 0;font-size:16px;line-height:1.7;opacity:.78; min-height:1.7em;transition:opacity .5s ease}
.w4p .eyebrow{margin:0 0 12px;font-size:14px;letter-spacing:.3em;color:var(--sub)}
.w4p .sec{padding:40px var(--pad)}
.w4p .sec--flush{padding:40px 0}
.w4p .sec--tint{background:var(--soft)}
.w4p .sec h2{margin:0 0 18px;font-family:'Noto Serif KR','AppleMyungjo',serif;font-size:19px; font-weight:400;letter-spacing:.05em;line-height:1.5}
.w4p .sec h2 span{font-family:-apple-system,'Apple SD Gothic Neo',sans-serif;font-size:15px; font-weight:400;color:var(--sub);margin-left:6px;letter-spacing:0}
.w4p .sec--flush h2,.w4p .sec--flush .eyebrow{padding:0 var(--pad)}
.w4p .head{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
.w4p .head a{font-size:15px;color:var(--sub)}
.w4p .lead{font-family:'Noto Serif KR','AppleMyungjo',serif;font-size:17.5px;line-height:2.05; font-weight:300;letter-spacing:.01em;color:#2a2823}
.w4p .note{font-size:16px;line-height:1.8;color:var(--sub)}
.w4p .rule{height:1px;background:var(--line)}
.w4p figure{margin:0}
.w4p figure figcaption{padding:12px var(--pad) 0;font-size:14.5px;line-height:1.8;color:var(--sub)}
.w4p .today{padding:20px var(--pad) 4px;border-bottom:1px solid var(--line)}
.w4p .today p{margin:0;font-size:17px;line-height:1.85;color:#2a2823}
.w4p .wbrief{padding:24px var(--pad) 22px;border-bottom:1px solid var(--line);background:var(--paper)}
.w4p .wbrief-eyebrow{color:var(--sub) !important}
.w4p .wbrief-badge{display:flex;align-items:center;gap:9px;margin-bottom:14px}
.w4p .wbrief-icon{width:32px;height:32px;flex:0 0 auto;color:var(--ink)}
.w4p .wbrief-lead{margin:0;min-height:2.6em; font-family:'Noto Serif KR','AppleMyungjo',serif;font-size:21px;line-height:1.6; letter-spacing:.01em;color:#221f19}
.w4p .wbrief-sub{animation:wfade .6s ease-out both;margin:14px 0 0;font-size:15.5px;line-height:1.85;color:var(--sub)}
.w4p .wbrief-temp{font-size:15.5px;font-weight:700;letter-spacing:.02em;color:#221f19}
.w4p .wbrief-note{color:var(--sub) !important}
@keyframes wfade{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}
.w4p #wnote{animation:wfade .45s ease}
@media (prefers-reduced-motion:reduce){
.w4p #wnote{animation:none}
}
.w4p .pm canvas{max-width:420px;margin:0 auto;display:block}
.w4p .pm textarea{width:100%;height:76px;margin-top:10px;padding:12px 14px;border:1.5px solid #c7c1b5; background:var(--paper);font:inherit;font-size:17px;line-height:1.7;color:var(--ink);border-radius:4px; resize:none}
.w4p .blogpost{padding:14px 0;border-bottom:1px solid #efece6}
.w4p .blogpost:last-child{border-bottom:0}
.w4p .blogpost p:first-child{margin:0 0 6px}
.w4p .blogpost p:last-child{margin:0;font-size:17px;line-height:1.85}
.w4p .reviewcard{padding:14px 0;border-bottom:1px solid #efece6}
.w4p .reviewcard:last-child{border-bottom:0}
.w4p .reviewcard .who{display:flex;justify-content:space-between;align-items:baseline;margin:0 0 6px}
.w4p .reviewcard .who b{font-family:'Noto Serif KR','AppleMyungjo',serif;font-weight:400;font-size:15.5px}
.w4p .reviewcard .who span{color:var(--sub);font-size:14.5px}
.w4p .reviewcard p{margin:0;font-size:17px;line-height:1.85}
.w4p #reviewform textarea{width:100%;height:96px;padding:12px 14px;border:1.5px solid #c7c1b5; background:var(--paper);font:inherit;font-size:17px;line-height:1.7;color:var(--ink);border-radius:4px; resize:none}
.w4p #reviewform input{width:100%;height:44px;padding:0 14px;border:1px solid #ddd9d2; background:var(--paper);font:inherit;font-size:17px;color:var(--ink)}
.w4p .stops li.mvrow{display:block;margin-left:28px;border-bottom:0; color:var(--sub);font-size:14px}
.w4p .rm{position:relative;width:318px;max-width:100%;height:168px;margin:14px auto 4px;overflow:hidden; border-top:1px solid #e6e2da;border-bottom:1px solid #e6e2da;background:#e8e5df}
.w4p .rm-t{position:absolute;max-width:none;pointer-events:none;filter:grayscale(1) contrast(.95) opacity(.55)}
.w4p .rm-l{position:absolute;inset:0;pointer-events:none}
.w4p .rm-p{position:absolute;display:grid;place-items:center;min-width:22px;height:22px;padding:0 5px; transform:translate(-50%,-50%);border-radius:999px;border:1.5px solid #fdfcfa;background:#9c5b33; color:#fff;font-size:13.5px;font-weight:600;text-decoration:none; font-family:-apple-system,'Apple SD Gothic Neo',sans-serif}
.w4p .stops li.stop{display:grid;grid-template-columns:20px 1fr;column-gap:8px;align-items:start}
.w4p .stops li.stop > *{grid-column:2}
.w4p .stops li.stop .no{grid-column:1;grid-row:1;align-self:center; display:grid;place-items:center;width:20px;height:20px;border-radius:999px; background:#e8e3d9;color:#4a453a;font-style:normal;font-size:13.5px;font-weight:600; font-family:-apple-system,sans-serif}
.w4p .stops li.stop .r{margin-top:2px}
.w4p .stops .maplink{white-space:nowrap;text-decoration:underline;text-underline-offset:2px;color:var(--accent,#9c5b33)}
.w4p #peoplegrid{grid-template-columns:repeat(3,minmax(0,1fr))}
.w4p #songs .itemrow{padding-left:30px; background:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%23a39c8c%22 stroke-width=%221.6%22><circle cx=%2212%22 cy=%2212%22 r=%229%22/><circle cx=%2212%22 cy=%2212%22 r=%225.4%22 stroke-dasharray=%221.2 2%22/><circle cx=%2212%22 cy=%2212%22 r=%222.2%22/></svg>') 0 center/20px 20px no-repeat}
.w4p .row--long{display:flex;flex-direction:column;gap:6px;padding:12px 0;border-bottom:1px solid #efece6;font-size:17px}
.w4p .row--long .r{color:var(--ink);font-weight:400;font-size:16px;line-height:1.75;white-space:pre-line}
.w4p .infobar{display:flex;align-items:center;gap:8px;margin:20px 0 4px;font-size:15.5px;font-weight:700}
.w4p .infobar:first-child{margin-top:0}
.w4p .infobar i{width:3px;height:1em;background:#8c4a2f;flex:0 0 auto}
.w4p .amen{list-style:none;margin:6px 0 4px;padding:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:2px 14px}
.w4p .amen li{display:flex;align-items:center;gap:8px;padding:8px 0;font-size:16.5px}
.w4p .amen li b{font-weight:700}
.w4p .amen .ic{flex:0 0 auto;font-style:normal}
.w4p .amen li.on .ic{color:#15803d}
.w4p .amen li.off .ic{color:#c2410c}
.w4p .bracc{border:1px solid var(--line);border-radius:14px;overflow:hidden;margin-top:4px}
.w4p .bracc-toggle{display:flex;align-items:center;gap:8px;width:100%;padding:14px 16px;border:0; background:none;font:inherit;font-size:15.5px;font-weight:700;color:var(--ink);cursor:pointer;text-align:left}
.w4p .bracc-toggle b{flex:1 1 auto;font-weight:700}
.w4p .bracc-body{padding:16px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:18px}
.w4p .brcal-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
.w4p .brcal-head button{width:28px;height:28px;border:1px solid var(--line);border-radius:8px;background:none; font:inherit;cursor:pointer}
.w4p .brcal-head button:disabled{opacity:.3;cursor:not-allowed}
.w4p .brcal-wd{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;padding-bottom:6px; border-bottom:1px solid var(--line);font-size:14px;color:var(--sub);text-align:center}
.w4p .brcal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;margin-top:6px}
.w4p .brcal-grid button{height:34px;border:1px solid transparent;border-radius:8px;background:var(--tint); font:inherit;font-size:14px;cursor:pointer}
.w4p .brcal-grid button:disabled{opacity:.25;cursor:not-allowed}
.w4p .brcal-grid button[aria-pressed="true"]{background:#8c4a2f;border-color:#8c4a2f;color:#fff;font-weight:700}
.w4p .brslots{display:flex;flex-wrap:wrap;gap:6px}
.w4p .brslots button{padding:6px 12px;border:1px solid var(--line);border-radius:8px;background:var(--tint); font:inherit;font-size:14.5px;cursor:pointer}
.w4p .brslots button[aria-pressed="true"]{background:#8c4a2f;border-color:#8c4a2f;color:#fff;font-weight:700}
.w4p .brunits{display:grid;gap:6px}
.w4p .brunits button{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 12px; border:1px solid var(--line);border-radius:10px;background:var(--tint);font:inherit;font-size:14.5px; text-align:left;cursor:pointer}
.w4p .brunits button span:first-child{flex:1 1 auto;min-width:0}
.w4p .brunits button span:last-child{flex:0 0 auto;color:var(--sub);font-size:14px}
.w4p .brunits button[aria-pressed="true"]{border-color:#8c4a2f}
.w4p .brguests{display:flex;align-items:center;justify-content:space-between}
.w4p .brguests .stepper{display:flex;align-items:center;gap:10px}
.w4p .brguests button{width:28px;height:28px;border:1px solid var(--line);border-radius:8px;background:none; font:inherit;font-size:16.5px;cursor:pointer}
.w4p .brsum{border:1px solid var(--line);border-radius:10px;padding:12px;background:var(--tint)}
.w4p .brsum-row{display:flex;align-items:baseline;justify-content:space-between;gap:10px;font-size:14.5px}
.w4p .brsum-row b{font-size:16.5px;color:#8c4a2f}
.w4p .brsum p{margin:6px 0 0;font-size:14px;color:var(--sub)}
.w4p .brok{display:flex;align-items:flex-start;gap:10px;padding:14px 0}
.w4p .brok i{flex:0 0 auto;width:26px;height:26px;border-radius:999px;background:#8c4a2f;color:#fff; display:flex;align-items:center;justify-content:center;font-style:normal;font-size:15.5px}
.w4p .brok-actions{display:flex;gap:8px;margin-bottom:18px}
.w4p .brok-actions .cta{flex:1 1 auto;height:44px;font-size:15.5px}
.w4p .pills{display:flex;flex-wrap:wrap;gap:8px}
.w4p .pill{height:34px;padding:0 16px;display:flex;align-items:center;border:1px solid var(--line); background:var(--paper);border-radius:999px;font-size:14.5px;color:var(--ink)}
.w4p .car{position:relative}
.w4p .car .vp{overflow:hidden;touch-action:pan-y pinch-zoom;overscroll-behavior-x:contain}
.w4p .car .track{display:flex;touch-action:pan-y pinch-zoom}
.w4p .car .slide{flex:0 0 100%;min-width:0}
.w4p .car--multi .track{gap:12px;padding:0 var(--pad) 4px}
.w4p .car--150 .slide{flex:0 0 150px}
.w4p .car--200 .slide{flex:0 0 200px}
.w4p .car--multi .arrow{background:rgba(253,252,250,.92)}
.w4p button{font:inherit}
.w4p .mp[data-armed="1"] .pp{animation:mpbreathe 2.4s ease-in-out infinite}
@keyframes mpbreathe{0%,100%{opacity:1}50%{opacity:.45}}
@media (prefers-reduced-motion:reduce){
.w4p .mp[data-armed="1"] .pp{animation:none}
}
.w4p .mp .cas{flex:0 0 auto;width:24px;height:24px;margin-right:7px;color:var(--ink);display:flex;align-items:center}
.w4p .mp .cas svg{width:100%;height:100%}
.w4p .credit,.w4p .credit a{font-size:13.5px}
.w4p button.itemcard{-webkit-appearance:none;appearance:none;border:0;background:none; padding:0;margin:0;text-align:left;font:inherit;color:inherit;width:100%;display:block;cursor:pointer}
.w4p .itemcard img,.w4p .film,.w4p figure img,.w4p .sin img{border-radius:4px}
.w4p .car{border-radius:4px}
.w4p input,.w4p textarea,.w4p select{border-radius:4px}
.w4p .wbrief[data-band="혹서"]{--wb:#b03a0b}
.w4p .wbrief[data-band="더움"]{--wb:#a2600f}
.w4p .wbrief[data-band="선선"]{--wb:#43663f}
.w4p .wbrief[data-band="쌀쌀"]{--wb:#2f6383}
.w4p .wbrief[data-band="추움"]{--wb:#2a5570}
.w4p .wbrief[data-band] .wbrief-icon{color:var(--wb)}
.w4p .wbrief[data-band] .wbrief-temp{color:var(--wb)}
.w4p .sec--flush > .eyebrow,.w4p .sec--flush > h2,.w4p .sec--flush > .note{padding-left:var(--pad);padding-right:var(--pad)}
.w4p .sec--flush .head h2{padding-left:0;padding-right:0}
.w4p .sec p:not([class]),.w4p details p:not([class]),.w4p p.clamp,.w4p p.wbrief-sub{font-family:'Noto Serif KR','AppleMyungjo',serif;font-weight:400;letter-spacing:.01em}
.w4p #bsum p,.w4p #brform p:not([class]){font-family:inherit;font-weight:inherit;letter-spacing:normal}
.w4p #about p:not(.eyebrow),.w4p #about details p{font-family:'Noto Serif KR','AppleMyungjo',serif;font-size:17.5px;line-height:2.05;font-weight:400;letter-spacing:.01em;color:#2a2823}
.w4p .bstep{border:1px solid #e6e2da;border-radius:4px;overflow:hidden;margin-bottom:14px}
.w4p .bacc{display:flex;align-items:center;gap:8px;width:100%;padding:14px 16px;background:none;border:0; font:inherit;font-size:16px;font-weight:600;color:var(--ink);cursor:pointer;text-align:left}
.w4p .bacc svg{width:17px;height:17px;flex:0 0 auto}
.w4p .bacc span{flex:1;min-width:0}
.w4p .bacc b{font-size:17px;font-weight:400;color:var(--sub)}
.w4p .bbody{padding:4px 16px 18px;border-top:1px solid #efece6}
.w4p .blab{margin:16px 0 8px;font-size:15px;font-weight:600;color:var(--ink)}
.w4p .blab:first-child{margin-top:12px}
.w4p .bmon{margin:0 0 8px;font-size:15px;color:var(--sub)}
.w4p .bdow,.w4p .bcal{display:grid;grid-template-columns:repeat(7,1fr);gap:4px}
.w4p .bdow span{text-align:center;font-size:15px;color:var(--sub);padding-bottom:4px}
.w4p .bcal button{aspect-ratio:1;border:0;background:none;border-radius:999px;font:inherit;font-size:15px; color:var(--ink);cursor:pointer}
.w4p .bcal button:disabled{color:#c9c4bb;cursor:default}
.w4p .bcal button[aria-pressed=true]{background:var(--ink);color:#fff;font-weight:600}
.w4p .bchips,.w4p .brooms{display:flex;flex-wrap:wrap;gap:6px}
.w4p .bchips button{padding:8px 13px;border:1px solid #e6e2da;border-radius:999px;background:none; font:inherit;font-size:15px;color:var(--ink);cursor:pointer}
.w4p .brooms button{display:block;width:100%;text-align:left;padding:12px 14px;border:1px solid #e6e2da; border-radius:4px;background:none;font:inherit;font-size:15.5px;color:var(--ink);cursor:pointer}
.w4p .brooms button small{display:block;margin-top:3px;font-size:14px;color:var(--sub)}
.w4p .bchips button[aria-pressed=true],.w4p .brooms button[aria-pressed=true]{border-color:var(--ink); box-shadow:inset 0 0 0 1px var(--ink)}
.w4p .bcount{display:flex;align-items:center;gap:14px}
.w4p .bcount button{width:36px;height:36px;border:1px solid #e6e2da;border-radius:999px;background:none; font:inherit;font-size:17px;color:var(--ink);cursor:pointer}
.w4p .bcount b{font-size:16px;font-weight:600}
.w4p .bsum{padding:13px 16px;border-radius:4px;background:#f5f2ec;font-size:15px}
.w4p .bsum .bp{margin:4px 0 0;font-size:17px;font-weight:600}
.w4p .bsum .bn{margin:6px 0 0;font-size:15px;color:var(--sub);line-height:1.7}
.w4p #bnext:disabled{opacity:.45;cursor:default}
.w4p .car .arrow{position:absolute;top:50%;transform:translateY(-50%);width:40px;height:40px; border:0;border-radius:50%;background:rgba(253,252,250,.86);color:var(--ink);cursor:pointer; display:grid;place-items:center;font-size:18px;line-height:1}
.w4p .car .prev{left:10px}
.w4p .car .next{right:10px}
.w4p .car .count{position:absolute;right:12px;bottom:12px;margin:0;padding:3px 9px; background:rgba(16,15,12,.6);color:#fff;font-size:14px;letter-spacing:.06em}
.w4p .car .dots{display:flex;justify-content:center;gap:6px;padding:12px 0 0}
.w4p .car .dots button{width:6px;height:6px;padding:0;border:0;border-radius:50%; background:var(--line);cursor:pointer}
.w4p .car .dots button.on{width:18px;border-radius:999px;background:var(--ink)}
.w4p .rail{display:flex;align-items:flex-start;gap:12px;overflow-x:auto;padding:0 var(--pad) 4px; scroll-snap-type:x mandatory;scrollbar-width:none;-webkit-overflow-scrolling:touch}
.w4p .rail::-webkit-scrollbar{display:none}
.w4p .rail>*{flex:0 0 auto;scroll-snap-align:start}
.w4p .rail .w260{width:260px}
.w4p .rail .w200{width:200px}
.w4p .rail .w150{width:150px}
.w4p .grid2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}
.w4p .grid2--tight{gap:4px}
.w4p .cap{margin:10px 0 0;font-size:16.5px;line-height:1.6}
.w4p button.itemcard .cap:not(.dim){min-height:48px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.w4p .cap b{font-family:'Noto Serif KR','AppleMyungjo',serif;font-weight:400;font-size:16px;letter-spacing:.03em}
.w4p .dim{color:var(--sub);font-size:15px}
.w4p .clamp{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.w4p .row{display:flex;align-items:baseline;justify-content:space-between;gap:14px; padding:12px 0;border-bottom:1px solid #efece6;font-size:17px}
.w4p .row:last-child{border-bottom:0}
.w4p .row .r{flex:0 0 auto;color:var(--sub);font-size:15px}
.w4p dl.spec{margin:0;display:grid;grid-template-columns:82px minmax(0,1fr);row-gap:12px;font-size:16.5px}
.w4p dl.spec dt{color:var(--sub)}
.w4p dl.spec dd{margin:0}
.w4p .cta{height:52px;display:flex;align-items:center;justify-content:center; background:var(--ink);color:var(--paper);font-size:15.5px;letter-spacing:.06em}
.w4p .cta+.cta{margin-top:10px}
.w4p .cta.ghost{background:none;border:1px solid var(--ink);color:var(--ink)}
.w4p .more{width:100%;height:44px;margin-top:12px;border:1px solid var(--line);background:none; font:inherit;font-size:16.5px;color:var(--ink);cursor:pointer}
.w4p details>summary{list-style:none;cursor:pointer}
.w4p details>summary::-webkit-details-marker{display:none}
.w4p details[open] .more:not(.less){display:none !important}
.w4p details:not([open]) .less{display:none}
.w4p .faq details{border-bottom:1px solid var(--line)}
.w4p .faq summary{padding:14px 0;font-size:15.5px;line-height:1.7;display:flex;gap:10px; align-items:baseline;justify-content:space-between}
.w4p .faq summary::after{content:'+';color:var(--sub);flex:0 0 auto}
.w4p .faq details[open] summary::after{content:'−'}
.w4p .faq p{margin:0 0 16px;font-size:16.5px;line-height:1.85;color:#4a453a}
.w4p .form label{display:block;margin-bottom:10px}
.w4p .form span{display:block;margin-bottom:4px;font-size:14.5px;color:var(--sub)}
.w4p .form input,.w4p .form textarea{width:100%;height:46px;padding:0 14px;border:1px solid #ddd9d2; background:var(--paper);font:inherit;font-size:17px;color:var(--ink);border-radius:0}
.w4p .form textarea{height:84px;padding:12px 14px;line-height:1.7}
.w4p .film{position:relative;display:block;background:var(--ink)}
.w4p .film img{aspect-ratio:3/4;opacity:.9}
.w4p .film .play{position:absolute;inset:0;display:flex;align-items:center;justify-content:center}
.w4p .film .play::after{content:'';flex:0 0 auto;display:block;width:14px;height:18px;background:#fff;clip-path:polygon(0 0,100% 50%,0 100%);filter:drop-shadow(0 0 6px rgba(0,0,0,.5))}
.w4p footer{padding:26px var(--pad) 32px;border-top:1px solid var(--line); font-size:14.5px;line-height:1.95;color:var(--sub)}
.w4p footer a{color:var(--sub)}
.w4p .bar{position:fixed;bottom:0;left:0;right:0;max-width:var(--wide);margin:0 auto;height:var(--bar); padding:9px var(--pad);display:flex;align-items:center;gap:10px; background:var(--paper);border-top:1px solid var(--line);z-index:30}
.w4p .bar .cta{flex:1 1 auto;height:46px;margin:0}
.w4p .bar .cta.ghost{flex:0 0 108px}
@supports(padding:max(0px)){
.w4p .bar{height:calc(var(--bar) + env(safe-area-inset-bottom)); padding-bottom:calc(9px + env(safe-area-inset-bottom))}
}
.w4p .sheet{position:fixed;inset:0;z-index:40}
.w4p .sheet[hidden]{display:none}
.w4p .sbg{position:absolute;inset:0;background:rgba(14,13,10,.52)}
.w4p .sbox{position:absolute;left:0;right:0;bottom:0;max-width:var(--wide);margin:0 auto; max-height:88svh;overflow:auto;background:var(--paper);padding:0 0 32px; -webkit-overflow-scrolling:touch}
.w4p .sx{position:sticky;top:0;float:right;width:48px;height:48px;border:0;background:none; font-size:22px;line-height:1;color:var(--ink);cursor:pointer;z-index:1}
.w4p .sin{padding-top:16px}
.w4p .sin img{margin:0 0 18px}
.w4p .sin h3{margin:0 0 6px;padding:0 var(--pad);font-family:'Noto Serif KR','AppleMyungjo',serif; font-size:19px;font-weight:400;line-height:1.5;letter-spacing:.03em}
.w4p .sin .meta{margin:0 0 16px;padding:0 var(--pad);font-size:14.5px;letter-spacing:.06em;color:var(--sub)}
.w4p .sin p{margin:0 0 14px;padding:0 var(--pad);font-size:17px;line-height:1.95;color:#39352e}
.w4p .sin .stops{margin:0;padding:0 var(--pad);list-style:none}
.w4p .sin .stops li{padding:12px 0;border-bottom:1px solid #efece6;font-size:16.5px;line-height:1.75}
.w4p .sin .stops b{font-weight:500}
.w4p .sin .stops span{display:block;color:var(--sub);font-size:15px}
.w4p .sin .out{display:flex;margin:18px var(--pad) 0;height:46px;align-items:center; justify-content:center;border:1px solid var(--ink);font-size:16.5px}
.w4p .itemcard{display:block;width:100%;padding:0;border:0;background:none;font:inherit; color:inherit;text-align:left;cursor:pointer}
.w4p .itemrow{display:flex;align-items:baseline;justify-content:space-between;gap:14px;width:100%; padding:12px 0;border:0;border-bottom:1px solid #efece6;background:none;font:inherit; font-size:17px;color:inherit;text-align:left;cursor:pointer}
.w4p .itemrow .r{flex:0 0 auto;color:var(--sub);font-size:15px}
.w4p .pm canvas{width:100%;aspect-ratio:1/1;background:var(--tint);border:1px solid var(--line)}
.w4p .pm .thumbs{display:flex;gap:8px;overflow-x:auto;padding:12px 0 4px;scrollbar-width:none}
.w4p .pm .thumbs::-webkit-scrollbar{display:none}
.w4p .pm .thumbs button{flex:0 0 auto;width:64px;height:48px;padding:0;border:1px solid var(--line); background:none;cursor:pointer;overflow:hidden}
.w4p .pm .thumbs button[aria-current="true"]{outline:2px solid var(--ink);outline-offset:-2px}
.w4p .pm .thumbs img{width:100%;height:100%}
.w4p .tabrow{display:flex;gap:8px;overflow-x:auto;margin:-4px 0 16px;padding-bottom:4px; scrollbar-width:none}
.w4p .tabrow::-webkit-scrollbar{display:none}
.w4p .tabrow button{flex:0 0 auto;height:34px;padding:0 13px;border:1px solid var(--line); background:none;font:inherit;font-size:15.5px;color:var(--sub);cursor:pointer}
.w4p .tabrow button[aria-current="true"]{border-color:var(--ink);background:var(--ink);color:var(--paper)}
.w4p .tabrow--flush{margin-left:var(--pad);margin-right:var(--pad)}
.w4p [hidden]{display:none !important}
.w4p .pm textarea{width:100%;height:76px;margin-top:10px;padding:12px 14px;border:1px solid #ddd9d2; background:var(--paper);font:inherit;font-size:17px;line-height:1.7;color:var(--ink);border-radius:0}
.w4p .rw{position:relative}
.w4p .rw .arrow{position:absolute;top:50%;transform:translateY(-50%);width:36px;height:36px; border:0;border-radius:50%;background:rgba(253,252,250,.86);color:var(--ink);cursor:pointer; display:grid;place-items:center;font-size:17px;line-height:1;z-index:2}
.w4p .rw .prev{left:6px}
.w4p .rw .next{right:6px}
.w4p #brform textarea{width:100%;height:88px;padding:12px 14px;border:1.5px solid #c7c1b5; background:var(--paper);font:inherit;font-size:17px;line-height:1.7;color:var(--ink);border-radius:4px; resize:none}
.w4p #brform input:not([type=checkbox]){width:100%;height:46px;padding:0 14px;border:1px solid #ddd9d2; background:var(--paper);font:inherit;font-size:17px;color:var(--ink)}
}
.w4p .shell{max-width:none;padding-inline:var(--pad)}
.w4p section.paper{background:var(--paper) !important;border-color:var(--line)}
.w4p .phead h2{margin:0 0 18px;font-family:'Noto Serif KR','AppleMyungjo',serif;font-size:19px;font-weight:400;letter-spacing:.05em;line-height:1.5}
.w4p .phead .eyebrow{margin:0 0 12px;font-size:14px;letter-spacing:.3em;color:var(--sub)}
.w4p .herocopy .cp .w4d-line{margin-top:0}
.w4p .sheet .sin h3{margin-top:0}
.w4p[data-page] [data-pg]{display:none}
.w4p[data-page="home"] [data-pg~="home"]{display:revert}
.w4p[data-page="area"] [data-pg~="area"]{display:revert}
.w4p[data-page="stay"] [data-pg~="stay"]{display:revert}
.w4p[data-page="story"] [data-pg~="story"]{display:revert}
.w4p[data-page="home"] [data-editor-id="festival"],.w4p[data-page="home"] [data-editor-id="itinerary"],.w4p[data-page="home"] [data-editor-id="story"],.w4p[data-page="home"] [data-editor-id="songs"],.w4p[data-page="home"] [data-editor-id="people"],.w4p[data-page="home"] [data-editor-id="chronicle"],.w4p[data-page="home"] [data-editor-id="reading"],.w4p[data-page="home"] [data-editor-id="daily"],.w4p[data-page="home"] [data-editor-id="quiz"],.w4p[data-page="home"] [data-editor-id="course"],.w4p[data-page="home"] [data-editor-id="schedule"],.w4p[data-page="home"] [data-editor-id="info"],.w4p[data-page="home"] [data-editor-id="rules"],.w4p[data-page="home"] [data-editor-id="map"],.w4p[data-page="home"] [data-editor-id="faq"],.w4p[data-page="home"] [data-editor-id="planner"],.w4p[data-page="home"] [data-editor-id="event"],.w4p[data-page="home"] [data-editor-id="social"],.w4p[data-page="home"] [data-editor-id="postcard"],.w4p[data-page="home"] main > #location,.w4p[data-page="home"] main > #reviews,.w4p[data-page="home"] main > #postcard-maker{display:none !important}
.w4p[data-page="area"] [data-editor-id]:not([data-editor-id="festival"],[data-editor-id="itinerary"],[data-editor-id="story"],[data-editor-id="songs"],[data-editor-id="people"],[data-editor-id="chronicle"],[data-editor-id="reading"],[data-editor-id="daily"],[data-editor-id="quiz"],[data-editor-id="course"],[data-editor-id="schedule"],[data-editor-id="intro"],[data-editor-id="rooms"],[data-editor-id="menu"],[data-editor-id="programs"],[data-editor-id="local"],[data-editor-id="booking"]),.w4p[data-page="area"] main > #location,.w4p[data-page="area"] main > #reviews,.w4p[data-page="area"] main > #postcard-maker,.w4p[data-page="area"] main > #location{display:none !important}
.w4p[data-page="stay"] [data-editor-id]:not([data-editor-id="info"],[data-editor-id="rules"],[data-editor-id="map"],[data-editor-id="faq"],[data-editor-id="planner"],[data-editor-id="intro"],[data-editor-id="rooms"],[data-editor-id="menu"],[data-editor-id="programs"],[data-editor-id="local"],[data-editor-id="booking"]),.w4p[data-page="stay"] main > #reviews,.w4p[data-page="stay"] main > #postcard-maker{display:none !important}
.w4p[data-page="story"] [data-editor-id]:not([data-editor-id="event"],[data-editor-id="social"],[data-editor-id="postcard"],[data-editor-id="intro"],[data-editor-id="rooms"],[data-editor-id="menu"],[data-editor-id="programs"],[data-editor-id="local"],[data-editor-id="booking"]),.w4p[data-page="story"] main > #location,.w4p[data-page="story"] main > #location{display:none !important}
.w4p .grid3{grid-template-columns:repeat(3,minmax(0,1fr))}
.w4p .noimg{display:flex;align-items:center;justify-content:center;padding:0 12px;background:var(--soft);font-family:'Noto Serif KR','AppleMyungjo',serif;font-size:15px;text-align:center;border-radius:4px}
.w4p #postcard-maker .grid{grid-template-columns:minmax(0,1fr) !important}
@media (min-width:400px){.w4p .amen{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media (min-width:560px){.w4p .amen{grid-template-columns:repeat(4,minmax(0,1fr))}}
.w4p #w4d-mini{display:flex;align-items:center;justify-content:flex-end;gap:2px;margin:0;padding:0;border:0;min-width:0}
.w4p #w4d-tape{flex:0 0 auto;width:24px;height:24px;margin-right:7px;padding:0;display:flex;align-items:center;color:var(--ink)}
.w4p #w4d-tape svg{width:100%;height:100%}
.w4p #w4d-mini[data-playing] #w4d-now{display:block;min-width:0;max-width:108px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;font-weight:400;line-height:1.5;transform:translateY(2.4px);color:var(--sub);opacity:1;padding:0}
.w4p #w4d-mini button{width:40px;height:40px;padding:8px;border-radius:0;opacity:1;color:var(--ink)}
.w4p #w4d-mini button svg{width:100%;height:100%}
.w4p #w4d-mini[data-playing='1'] #w4d-tape{color:#8c4a2f}

View File

@ -53,6 +53,9 @@ export function SectionList() {
}; };
const Hero = layout.sections?.hero ?? HeroSection; const Hero = layout.sections?.hero ?? HeroSection;
const Blog = layout.sections?.blog ?? BlogSection;
const Reviews = layout.sections?.reviews ?? ReviewSection;
const PostcardMaker = layout.sections?.postcardMaker ?? PostcardMakerSection;
const rendered = new Set<ComponentType>(); const rendered = new Set<ComponentType>();
return ( return (
@ -69,7 +72,7 @@ export function SectionList() {
// 빌더 미리보기가 클릭한 자리를 섹션과 잇는 표식. // 빌더 미리보기가 클릭한 자리를 섹션과 잇는 표식.
<div key={section.id} data-editor-id={section.id} style={{display: 'contents'}}> <div key={section.id} data-editor-id={section.id} style={{display: 'contents'}}>
<Component /> <Component />
{section.id === 'intro' && <BlogSection />} {section.id === 'intro' && <Blog />}
</div> </div>
); );
})} })}
@ -78,11 +81,11 @@ export function SectionList() {
{isLodging && !hasSection(payload, 'booking') && <StayBookingSection />} {isLodging && !hasSection(payload, 'booking') && <StayBookingSection />}
{!isSectionEnabled(payload, 'intro') && <BlogSection />} {!isSectionEnabled(payload, 'intro') && <Blog />}
<ReviewSection /> <Reviews />
<PostcardMakerSection /> <PostcardMaker />
{isSectionEnabled(payload, 'social') && ( {isSectionEnabled(payload, 'social') && (
<div data-editor-id="social"> <div data-editor-id="social">

View File

@ -4,7 +4,7 @@ import {useSite} from '@site/lib/site-context';
/** 예약 요청 — 손님이 연락처를 적으면 사장님 메일로 간다. */ /** 예약 요청 — 손님이 연락처를 적으면 사장님 메일로 간다. */
const ENDPOINT = '/v1/site/booking-request'; const ENDPOINT = '/v1/site/booking-request';
export function BookingRequestSection({stay, guests}: {stay?: string; guests?: string}) { export function useBookingRequest(stay?: string, guests?: string) {
const payload = useSite(); const payload = useSite();
const openedAt = useRef(Date.now()); const openedAt = useRef(Date.now());
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
@ -45,6 +45,12 @@ export function BookingRequestSection({stay, guests}: {stay?: string; guests?: s
} }
} }
return {submit, sending, done};
}
export function BookingRequestSection({stay, guests}: {stay?: string; guests?: string}) {
const {submit, sending, done} = useBookingRequest(stay, guests);
if (done?.ok) { if (done?.ok) {
return ( return (
<div className="border-line tpl-border rounded-lg border p-5"> <div className="border-line tpl-border rounded-lg border p-5">

View File

@ -1,5 +1,5 @@
import {Check, X} from 'lucide-react'; import {Check, X} from 'lucide-react';
import {selectPublishable} from '@o2o/shared'; import {selectPublishable, type SitePayload} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {essentialRows} from '@site/lib/derive'; import {essentialRows} from '@site/lib/derive';
import {factualSummary} from '@site/seo/meta'; import {factualSummary} from '@site/seo/meta';
@ -34,8 +34,7 @@ function mergeCheckInOut(rows: InfoRow[]): InfoRow[] {
.filter((row) => row.key !== 'check_out_time'); .filter((row) => row.key !== 'check_out_time');
} }
export function EssentialInfoSection() { export function infoView(payload: SitePayload) {
const payload = useSite();
const rows = essentialRows(payload); const rows = essentialRows(payload);
const intro = selectPublishable(payload.facts).find((fact) => fact.key === 'intro'); const intro = selectPublishable(payload.facts).find((fact) => fact.key === 'intro');
const summary = intro?.summary?.trim() || intro?.value?.trim() || payload.narrative.summary || factualSummary(payload); const summary = intro?.summary?.trim() || intro?.value?.trim() || payload.narrative.summary || factualSummary(payload);
@ -61,10 +60,21 @@ export function EssentialInfoSection() {
.filter((text) => text.trim()); .filter((text) => text.trim());
if (rows.length === 0 && structured.length === 0 && notices.length === 0) return null; if (rows.length === 0 && structured.length === 0 && notices.length === 0) return null;
const otherRows = mergedRows.filter((row) => !isRule(row) && row.key !== 'intro');
return {
summary,
ruleRows: mergedRows.filter(isRule),
detailRows: otherRows.filter((row) => !isAmenity(row)),
amenities: otherRows.filter(isAmenity),
notices,
};
}
/** 규정과 나머지를 가른다. */ export function EssentialInfoSection() {
const ruleRows: InfoRow[] = mergedRows.filter(isRule); const payload = useSite();
const otherRows: InfoRow[] = mergedRows.filter((row) => !isRule(row) && row.key !== 'intro'); const view = infoView(payload);
if (!view) return null;
const {summary, ruleRows, detailRows, amenities, notices} = view;
return ( return (
<Section <Section
@ -76,9 +86,9 @@ export function EssentialInfoSection() {
<div className="space-y-8"> <div className="space-y-8">
{summary && <p className="measure text-[length:var(--fs-body)] leading-relaxed">{summary}</p>} {summary && <p className="measure text-[length:var(--fs-body)] leading-relaxed">{summary}</p>}
{ruleRows.length > 0 && <Rows title="예약 전 확인" rows={ruleRows} emphasis />} {ruleRows.length > 0 && <Rows title="예약 전 확인" rows={ruleRows} emphasis />}
{otherRows.length > 0 && ( {detailRows.length + amenities.length > 0 && (
<Rows title="시설 · 편의" rows={otherRows.filter((row) => !isAmenity(row))}> <Rows title="시설 · 편의" rows={detailRows}>
<Amenities rows={otherRows.filter(isAmenity)} /> <Amenities rows={amenities} />
</Rows> </Rows>
)} )}
{notices.map((text) => ( {notices.map((text) => (
@ -142,7 +152,7 @@ function Rows({title, rows, emphasis, children}: {
); );
} }
const ON_VALUES = new Set(['가능', '있음']); export const ON_VALUES = new Set(['가능', '있음']);
const AMENITY_ON = '#16a34a'; const AMENITY_ON = '#16a34a';
const AMENITY_OFF = '#e04b25'; const AMENITY_OFF = '#e04b25';

View File

@ -7,12 +7,12 @@ import {unitBaseRate} from '@site/seo/jsonld';
/** 예약 데모 — **화면 안에서만 도는 목업이다.** */ /** 예약 데모 — **화면 안에서만 도는 목업이다.** */
const WEEKDAY_LABEL = ['일', '월', '화', '수', '목', '금', '토'] as const; export const WEEKDAY_LABEL = ['일', '월', '화', '수', '목', '금', '토'] as const;
/** 고를 수 있는 앞선 달 수. */ /** 고를 수 있는 앞선 달 수. */
const MONTH_SPAN = 2; export const MONTH_SPAN = 2;
interface DayCell { export interface DayCell {
iso: string; iso: string;
day: number; day: number;
weekday: number; weekday: number;
@ -27,7 +27,7 @@ function isoOf(date: Date): string {
} }
/** 한 달치 칸. */ /** 한 달치 칸. */
function buildMonth(year: number, month: number, today: Date): (DayCell | null)[] { export function buildMonth(year: number, month: number, today: Date): (DayCell | null)[] {
const first = new Date(year, month, 1); const first = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0).getDate(); const lastDay = new Date(year, month + 1, 0).getDate();
const todayIso = isoOf(today); const todayIso = isoOf(today);
@ -48,7 +48,7 @@ function buildMonth(year: number, month: number, today: Date): (DayCell | null)[
} }
/** 도착 시간 후보. */ /** 도착 시간 후보. */
function buildArrivalSlots(checkIn?: string): string[] { export function buildArrivalSlots(checkIn?: string): string[] {
const match = /(\d{1,2})\s*:\s*(\d{2})/.exec(checkIn ?? ''); const match = /(\d{1,2})\s*:\s*(\d{2})/.exec(checkIn ?? '');
if (!match) return []; if (!match) return [];
const startHour = Number(match[1]); const startHour = Number(match[1]);
@ -59,7 +59,7 @@ function buildArrivalSlots(checkIn?: string): string[] {
.map((hour) => `${String(hour).padStart(2, '0')}:${minute}`); .map((hour) => `${String(hour).padStart(2, '0')}:${minute}`);
} }
interface DemoUnit { export interface DemoUnit {
unitId: string; unitId: string;
name: string; name: string;
weekdayPrice?: number; weekdayPrice?: number;
@ -68,11 +68,11 @@ interface DemoUnit {
} }
/** "B동(모던한 현대식 컨셉으로 꾸며진 따뜻한 공간)" → "B동" — 날짜·인원과 한 줄에 묶일 때만 줄인다. */ /** "B동(모던한 현대식 컨셉으로 꾸며진 따뜻한 공간)" → "B동" — 날짜·인원과 한 줄에 묶일 때만 줄인다. */
function shortUnitName(name: string): string { export function shortUnitName(name: string): string {
return name.replace(/\s*\(.*\)\s*$/, '').trim() || name; return name.replace(/\s*\(.*\)\s*$/, '').trim() || name;
} }
function demoUnits(payload: SitePayload): DemoUnit[] { export function demoUnits(payload: SitePayload): DemoUnit[] {
return sanitizeUnits(payload.units).map((unit) => { return sanitizeUnits(payload.units).map((unit) => {
const num = (key: string) => { const num = (key: string) => {
const value = Number(factText(unit.facts, key)?.replace(/[^0-9]/g, '')); const value = Number(factText(unit.facts, key)?.replace(/[^0-9]/g, ''));