사장님이 고를 수 있는 숙박 템플릿이 고택 하나뿐이었다. 모두 stay2 콘텐츠(날씨·미니 블로그· 지역 이야기·객실·축제·일정·후기·엽서·노래)를 그대로 싣고 모양만 다르다. - layouts/<id>/: 템플릿별 Frame·섹션·CSS. 데스크톱은 넓은 배치(1024px 이상), 모바일 우선 - layouts/kit/kit.css: 탭 전환·시트·예약 폼·접기 등 공통 구조 - shared templates.json · builder.ts · catalog.ts: 템플릿·LayoutId 등록 - seo/head.ts: 템플릿별 한글 웹폰트 — 서로 겹치지 않게 배정 - paper Frame·Intro: 같은 탭을 다시 눌러도 맨 위로, 미니 블로그를 글 여러 개로 - docs/TEMPLATES.md · TEMPLATE_DESIGN.md: 목록과 화면 규칙(간격·접기·✓ 표시·글꼴) eslint·tsc 통과, vitest 123 passed, 템플릿 9개 굽기 성공 · 간격 검사 0건
136 lines
5.0 KiB
TypeScript
136 lines
5.0 KiB
TypeScript
import {useState} from 'react';
|
|
import {factText, sanitizeUnits} from '@o2o/shared';
|
|
import {useSite} from '@site/lib/site-context';
|
|
import {sectionName, unitSpec, unitViews, type UnitView} from '@site/lib/derive';
|
|
import {Car} from '../paper/Car';
|
|
import {BOOK_EVENT} from '../paper/Rooms';
|
|
import {Sheet} from '../paper/Sheet';
|
|
|
|
function splitName(name: string): [string, string | undefined] {
|
|
const m = /^(.+?)\s*\((.+)\)\s*$/.exec(name);
|
|
return m ? [m[1], m[2]] : [name, undefined];
|
|
}
|
|
|
|
function specRows(unit: UnitView, facts: Parameters<typeof factText>[0]) {
|
|
const bed = [factText(facts, 'bed_type'), factText(facts, 'room_size')].filter(Boolean).join(' · ');
|
|
const std = factText(facts, 'standard_capacity');
|
|
const max = factText(facts, 'max_capacity');
|
|
const people = [std && `기준 ${std.replace(/명$/, '인')}`, max && `최대 ${max.replace(/명$/, '인')}`]
|
|
.filter(Boolean)
|
|
.join(' · ');
|
|
const price = unit.priceText ?? unit.prices.map((p) => `${p.label} ${p.value}`).join(' · ');
|
|
return [
|
|
['침대', bed],
|
|
['정원', people],
|
|
['요금', price],
|
|
].filter(([, value]) => value) as [string, string][];
|
|
}
|
|
|
|
const HOME_MAX = 4;
|
|
|
|
export function Rooms() {
|
|
const payload = useSite();
|
|
const units = unitViews(payload);
|
|
const spec = unitSpec(payload);
|
|
const raw = new Map(sanitizeUnits(payload.units).map((unit) => [unit.unitId, unit]));
|
|
const [open, setOpen] = useState<UnitView | null>(null);
|
|
if (units.length === 0) return null;
|
|
const rowsOf = (unit: UnitView) => specRows(unit, raw.get(unit.unitId)?.facts ?? []);
|
|
const people = (unit: UnitView) => rowsOf(unit).find(([label]) => label === '정원')?.[1];
|
|
|
|
return (
|
|
<>
|
|
<div data-pg="home">
|
|
<section className="sec" id="units" aria-labelledby="units-heading">
|
|
<p className="kicker">Room View</p>
|
|
<h2 id="units-heading">{sectionName(payload, spec.path, `${spec.label} 안내`)}</h2>
|
|
<div className="rooms">
|
|
{units.slice(0, HOME_MAX).map((unit, i) => (
|
|
<a key={unit.unitId} href="#/stay" className="room room--mini">
|
|
{unit.images[0] && (
|
|
<div className="ph">
|
|
<img src={unit.images[0].url} alt={unit.images[0].alt} loading="lazy" />
|
|
</div>
|
|
)}
|
|
<div className="bd">
|
|
<h3>
|
|
<span className="no">{String(i + 1).padStart(2, '0')}</span>
|
|
{unit.name}
|
|
</h3>
|
|
{people(unit) && <p>{people(unit)}</p>}
|
|
</div>
|
|
</a>
|
|
))}
|
|
</div>
|
|
<a href="#/stay" className="more">
|
|
이용안내에서 전체 보기
|
|
</a>
|
|
</section>
|
|
</div>
|
|
|
|
<div data-pg="stay">
|
|
{units.map((unit, i) => {
|
|
const [main, sub] = splitName(unit.name);
|
|
return (
|
|
<section key={unit.unitId} className="sec" id={`unit-${unit.slug}`}>
|
|
<p className="kicker">
|
|
Room {String(i + 1).padStart(2, '0')}
|
|
</p>
|
|
<h2>
|
|
{main} {sub && <span className="sub">{sub}</span>}
|
|
</h2>
|
|
<div className="room room--full">
|
|
{unit.images.length > 0 && (
|
|
<Car
|
|
label={`${main} 사진`}
|
|
arrows
|
|
count
|
|
dots
|
|
slides={unit.images.map((image) => (
|
|
<img key={image.mediaId} src={image.url} alt={image.alt} loading="lazy" />
|
|
))}
|
|
/>
|
|
)}
|
|
<div className="bd">
|
|
{unit.intro && <p className="intro">{unit.intro}</p>}
|
|
<dl className="spec">
|
|
{rowsOf(unit).map(([label, value]) => (
|
|
<div key={label} style={{display: 'contents'}}>
|
|
<dt>{label}</dt>
|
|
<dd>{value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
<div className="btnrow">
|
|
<button type="button" className="btn ghost" onClick={() => setOpen(unit)}>
|
|
자세히
|
|
</button>
|
|
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
|
예약 요청
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
|
|
{open && (
|
|
<>
|
|
{open.images[0] && <img src={open.images[0].url} alt={open.images[0].alt} />}
|
|
<h3>{open.name}</h3>
|
|
{open.intro && <p>{open.intro}</p>}
|
|
{open.rows.map((row) => (
|
|
<p key={row.label}>
|
|
{row.label} — {row.value}
|
|
</p>
|
|
))}
|
|
</>
|
|
)}
|
|
</Sheet>
|
|
</>
|
|
);
|
|
}
|