[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:
parent
11d30bb3d1
commit
765b9b6463
@ -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는 저장·미리보기·발행에서 모두 거절한다. 기본값으로 슬쩍 굽지 않는다.
|
||||||
|
|||||||
@ -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` 맨 아래 규칙이 정한다. 새 섹션을
|
||||||
|
만들고 이 규칙에 적지 않으면 첫 탭(소개)에 나온다.
|
||||||
|
|
||||||
### 이상한 값이 들어오면 멈춘다
|
### 이상한 값이 들어오면 멈춘다
|
||||||
|
|
||||||
|
|||||||
@ -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": []
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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('여러 템플릿이 같은 레이아웃을 쓴다', () => {
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
174
solution/site/src/layouts/paper/Around.tsx
Normal file
174
solution/site/src/layouts/paper/Around.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
solution/site/src/layouts/paper/Booking.tsx
Normal file
70
solution/site/src/layouts/paper/Booking.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
261
solution/site/src/layouts/paper/BookingForm.tsx
Normal file
261
solution/site/src/layouts/paper/BookingForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
solution/site/src/layouts/paper/Car.tsx
Normal file
100
solution/site/src/layouts/paper/Car.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
solution/site/src/layouts/paper/Faq.tsx
Normal file
47
solution/site/src/layouts/paper/Faq.tsx
Normal 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 & 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" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
solution/site/src/layouts/paper/Festival.tsx
Normal file
88
solution/site/src/layouts/paper/Festival.tsx
Normal 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" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 {
|
export type PageId = 'home' | 'area' | 'stay' | 'story';
|
||||||
const anchors = entries.map((entry) => entry.anchor).join(',');
|
|
||||||
const [active, setActive] = useState('');
|
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (typeof IntersectionObserver === 'undefined') return;
|
const sync = () => {
|
||||||
const targets = anchors
|
const {hash} = window.location;
|
||||||
.split(',')
|
if (hash && !hash.startsWith('#/')) return;
|
||||||
.map((id) => document.getElementById(id))
|
setPage(pageOf(hash));
|
||||||
.filter((el): el is HTMLElement => el !== null);
|
window.scrollTo(0, 0);
|
||||||
if (targets.length === 0) return;
|
};
|
||||||
|
sync();
|
||||||
|
window.addEventListener('hashchange', sync);
|
||||||
|
return () => window.removeEventListener('hashchange', sync);
|
||||||
|
}, []);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
function useTabs() {
|
||||||
(records) => {
|
const payload = useSite();
|
||||||
const hit = records.find((record) => record.isIntersecting);
|
const tabs: {label: string; page: PageId}[] = [
|
||||||
if (hit) setActive(hit.target.id);
|
{
|
||||||
},
|
label: `${HOUSE_WORD[payload.place.category] ?? '가게'} 소개`,
|
||||||
{rootMargin: '-45% 0px -50% 0px'},
|
page: 'home',
|
||||||
);
|
},
|
||||||
targets.forEach((el) => observer.observe(el));
|
];
|
||||||
return () => observer.disconnect();
|
if (isSectionEnabled(payload, 'local')) tabs.push({label: '지역 소개', page: 'area'});
|
||||||
}, [anchors]);
|
tabs.push({label: '이용안내 · 예약', page: 'stay'});
|
||||||
|
tabs.push({label: '이야기', page: 'story'});
|
||||||
return active;
|
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 />
|
||||||
{place.phone && (
|
</span>
|
||||||
<a
|
|
||||||
href={`tel:${place.phone}`}
|
|
||||||
className="tap flex size-9 items-center justify-center rounded-full"
|
|
||||||
aria-label="전화 걸기"
|
|
||||||
>
|
|
||||||
<Phone className="size-4" />
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<div className="flex w-full flex-1 flex-col">{children}</div>
|
|
||||||
|
|
||||||
<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 && (
|
{place.phone && (
|
||||||
<p>
|
<a className="ico" href={`tel:${place.phone}`} aria-label="전화 걸기">
|
||||||
<a href={`tel:${place.phone}`} className="underline-offset-2 hover:underline">
|
<PhoneIcon />
|
||||||
{place.phone}
|
</a>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav className="tabs" aria-label="주요 메뉴">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<a
|
||||||
|
key={tab.page}
|
||||||
|
href={tab.page === 'home' ? '#/' : `#/${tab.page}`}
|
||||||
|
className={tab.page === page ? 'on' : undefined}
|
||||||
|
>
|
||||||
|
{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>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
<p style={{margin: '10px 0 0', fontSize: '13.5px', opacity: 0.72}} className="byline credit">
|
||||||
<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 className="author">작성·운영 {place.name}</span> · 최종 업데이트{' '}
|
||||||
<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>
|
<time dateTime={site.updatedAt}>{isoDate(site.updatedAt)}</time>
|
||||||
</p>
|
<br />
|
||||||
|
<a href="https://www.o2osolution.ai/" target="_blank" rel="noopener noreferrer">
|
||||||
<p className="text-[length:var(--fs-xs)] opacity-100">
|
|
||||||
<a
|
|
||||||
href="https://www.o2osolution.ai/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline-offset-2 hover:underline"
|
|
||||||
>
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
62
solution/site/src/layouts/paper/Gallery.tsx
Normal file
62
solution/site/src/layouts/paper/Gallery.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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
|
<div className="veil" />
|
||||||
className="pointer-events-none absolute inset-0"
|
<div className="herocopy">
|
||||||
style={{
|
{(place.addressLocality ?? place.addressRegion) && (
|
||||||
background:
|
<p className="eyebrow">{place.addressLocality ?? place.addressRegion}</p>
|
||||||
'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="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">
|
|
||||||
{locality && (
|
|
||||||
<p
|
|
||||||
className="text-[length:var(--fs-xs)] uppercase opacity-100"
|
|
||||||
style={{letterSpacing: '0.28em', color: 'var(--tpl-bg, #fff)'}}
|
|
||||||
>
|
|
||||||
{locality}
|
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
<h1 className="mj">{place.name}</h1>
|
||||||
|
{fixed && <p className="tagline">{fixed}</p>}
|
||||||
|
<p className="cp">
|
||||||
|
<HeroCatchphrase />
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
89
solution/site/src/layouts/paper/Info.tsx
Normal file
89
solution/site/src/layouts/paper/Info.tsx
Normal 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" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
solution/site/src/layouts/paper/Intro.tsx
Normal file
139
solution/site/src/layouts/paper/Intro.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
solution/site/src/layouts/paper/Itinerary.tsx
Normal file
103
solution/site/src/layouts/paper/Itinerary.tsx
Normal 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" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
solution/site/src/layouts/paper/Location.tsx
Normal file
108
solution/site/src/layouts/paper/Location.tsx
Normal 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" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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}
|
||||||
</div>
|
alt={unit.images[0].alt}
|
||||||
|
loading="lazy"
|
||||||
<div className="flex flex-col">
|
style={{aspectRatio: '3 / 2'}}
|
||||||
{units.map((unit, i) => (
|
/>
|
||||||
<UnitPanel key={unit.unitId} unit={unit} no={i + 1} booking={booking} phone={payload.place.phone} />
|
)}
|
||||||
))}
|
<p className="cap">
|
||||||
</div>
|
<b>{unit.name}</b>
|
||||||
</section>
|
</p>
|
||||||
);
|
<p className="cap dim">
|
||||||
}
|
{specRows(unit, raw.get(unit.unitId)?.facts ?? [])
|
||||||
|
.filter(([label]) => label !== '요금')
|
||||||
function UnitPanel({
|
.map(([, value]) => value)
|
||||||
unit,
|
.join(' · ')}
|
||||||
no,
|
</p>
|
||||||
booking,
|
|
||||||
phone,
|
|
||||||
}: {
|
|
||||||
unit: UnitView;
|
|
||||||
no: number;
|
|
||||||
booking?: ChannelLink;
|
|
||||||
phone?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<article className="border-line border-t">
|
|
||||||
{unit.images.length > 0 && (
|
|
||||||
<Carousel label={`${unit.name} 사진`} align="center" gap={0} arrows="overlay">
|
|
||||||
{unit.images.map((image) => (
|
|
||||||
<CarouselSlide key={image.mediaId} basis="basis-full">
|
|
||||||
<div className="relative aspect-3/2 overflow-hidden">
|
|
||||||
<img
|
|
||||||
src={image.url}
|
|
||||||
alt={image.alt}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
className="size-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CarouselSlide>
|
|
||||||
))}
|
|
||||||
</Carousel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="shell flex flex-col gap-3 py-6">
|
|
||||||
<p
|
|
||||||
className="text-[length:var(--fs-xs)] uppercase opacity-70"
|
|
||||||
style={{letterSpacing: '0.24em'}}
|
|
||||||
>
|
|
||||||
{String(no).padStart(2, '0')}
|
|
||||||
</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>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{unit.chips.length > 0 && (
|
|
||||||
<ul className="mt-1 flex flex-wrap gap-1.5">
|
|
||||||
{unit.chips.map((chip) => (
|
|
||||||
<li
|
|
||||||
key={chip.label}
|
|
||||||
className="border-line inline-flex items-center rounded-full border px-3 py-1 text-[length:var(--fs-xs)]"
|
|
||||||
>
|
|
||||||
{chip.value}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(booking || phone) && (
|
|
||||||
<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>
|
</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>
|
||||||
)}
|
<a
|
||||||
|
href="#/stay"
|
||||||
|
className="more"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
이용안내에서 전체 보기
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
<div className="rule" />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
|
||||||
|
<div data-pg="stay">
|
||||||
|
{units.map((unit, i) => {
|
||||||
|
const [main, sub] = splitName(unit.name);
|
||||||
|
return (
|
||||||
|
<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 && (
|
||||||
|
<Car
|
||||||
|
label={`${main} 사진`}
|
||||||
|
arrows
|
||||||
|
count
|
||||||
|
dots
|
||||||
|
slides={unit.images.map((image) => (
|
||||||
|
<img
|
||||||
|
key={image.mediaId}
|
||||||
|
src={image.url}
|
||||||
|
alt={image.alt}
|
||||||
|
loading="lazy"
|
||||||
|
style={{aspectRatio: '3 / 2'}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div style={{padding: '18px var(--pad) 0'}}>
|
||||||
|
{unit.intro && <p className="lead">{unit.intro}</p>}
|
||||||
|
<dl className="spec">
|
||||||
|
{specRows(unit, raw.get(unit.unitId)?.facts ?? []).map(([label, value]) => (
|
||||||
|
<div key={label} style={{display: 'contents'}}>
|
||||||
|
<dt>{label}</dt>
|
||||||
|
<dd>{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
<button type="button" className="more" style={{marginTop: 18}} onClick={() => setOpen(unit)}>
|
||||||
|
{main} 자세히
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cta"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
marginTop: 10,
|
||||||
|
border: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}
|
||||||
|
>
|
||||||
|
{main} 예약 요청
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div className="rule" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UnitSheet unit={open} onClose={() => setOpen(null)} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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={{
|
|
||||||
fontSize: 'clamp(1.15rem, 2.4vw, 1.5rem)',
|
|
||||||
fontWeight: 'var(--tpl-heading-weight, 400)',
|
|
||||||
letterSpacing: 'var(--tpl-heading-tracking, 0.03em)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
{lead && (
|
|
||||||
<p className="text-muted measure mt-2 text-[length:var(--fs-sm)] leading-relaxed">
|
|
||||||
{lead}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{aside && <div className="shrink-0">{aside}</div>}
|
) : (
|
||||||
</div>
|
<h2 id={`${id}-heading`}>{title}</h2>
|
||||||
</header>
|
)}
|
||||||
|
{lead && (
|
||||||
|
<p className="note" style={{margin: '-8px 0 14px'}}>
|
||||||
|
{lead}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
38
solution/site/src/layouts/paper/Sheet.tsx
Normal file
38
solution/site/src/layouts/paper/Sheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
solution/site/src/layouts/paper/Story.tsx
Normal file
216
solution/site/src/layouts/paper/Story.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
solution/site/src/layouts/paper/StoryTail.tsx
Normal file
11
solution/site/src/layouts/paper/StoryTail.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import {ReviewSection} from '@site/sections/ReviewSection';
|
||||||
|
import {PostcardMakerSection} from '@site/sections/items/PostcardMakerSection';
|
||||||
|
|
||||||
|
export function StoryTail() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PostcardMakerSection />
|
||||||
|
<ReviewSection />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
solution/site/src/layouts/paper/Video.tsx
Normal file
43
solution/site/src/layouts/paper/Video.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
solution/site/src/layouts/paper/Weather.tsx
Normal file
60
solution/site/src/layouts/paper/Weather.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
322
solution/site/src/layouts/paper/paper.css
Normal file
322
solution/site/src/layouts/paper/paper.css
Normal 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}
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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, ''));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user