o2o-site-AEO/solution/site/src/layouts/graphic/Rooms.tsx
Mina Choi 013cdcfc9f [feat] solution/site,shared: 숙박 템플릿 8종 추가 — 라운드·시네마·빅타이포·부티크·일러스트·코랄·미니멀·솔숲
사장님이 고를 수 있는 숙박 템플릿이 고택 하나뿐이었다. 모두 stay2 콘텐츠(날씨·미니 블로그·
지역 이야기·객실·축제·일정·후기·엽서·노래)를 그대로 싣고 모양만 다르다.

- layouts/<id>/: 템플릿별 Frame·섹션·CSS. 데스크톱은 넓은 배치(1024px 이상), 모바일 우선
- layouts/kit/kit.css: 탭 전환·시트·예약 폼·접기 등 공통 구조
- shared templates.json · builder.ts · catalog.ts: 템플릿·LayoutId 등록
- seo/head.ts: 템플릿별 한글 웹폰트 — 서로 겹치지 않게 배정
- paper Frame·Intro: 같은 탭을 다시 눌러도 맨 위로, 미니 블로그를 글 여러 개로
- docs/TEMPLATES.md · TEMPLATE_DESIGN.md: 목록과 화면 규칙(간격·접기·✓ 표시·글꼴)

eslint·tsc 통과, vitest 123 passed, 템플릿 9개 굽기 성공 · 간격 검사 0건
2026-09-30 13:45:10 +09:00

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