diff --git a/solution/site/src/App.tsx b/solution/site/src/App.tsx index 8f1027a..fa998cb 100644 --- a/solution/site/src/App.tsx +++ b/solution/site/src/App.tsx @@ -8,6 +8,7 @@ import {Shell as OasiShell} from '@site/layouts/oasi/Shell'; import {Shell as StudioShell} from '@site/layouts/studio/Shell'; import {Shell as PastelShell} from '@site/layouts/pastel/Shell'; import {Shell as EditorialShell} from '@site/layouts/editorial/Shell'; +import {Shell as PaperShell} from '@site/layouts/paper/Shell'; import {HomePage} from '@site/pages'; /** @@ -39,7 +40,9 @@ export function App({payload}: {payload: SitePayload}) { ? PastelShell : layout === 'editorial' ? EditorialShell - : DefaultShell; + : layout === 'paper' + ? PaperShell + : DefaultShell; return ( diff --git a/solution/site/src/layouts/paper/Hero.tsx b/solution/site/src/layouts/paper/Hero.tsx new file mode 100644 index 0000000..8671493 --- /dev/null +++ b/solution/site/src/layouts/paper/Hero.tsx @@ -0,0 +1,124 @@ +import {useCallback, useEffect, useRef, useState} from 'react'; +import {HeroCatchphrase} from '@site/sections/HeroCatchphrase'; +import {useSite} from '@site/lib/site-context'; + +const ROTATE_MS = 6000; +const MAX_SLIDES = 5; + +export function Hero() { + const payload = useSite(); + const {place, narrative} = payload; + const locality = place.addressLocality ?? place.addressRegion; + + const slides = [...payload.media] + .filter((image) => image.alt?.trim()) + .sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary)) + .slice(0, MAX_SLIDES); + + const [index, setIndex] = useState(0); + const [paused, setPaused] = useState(false); + const touchX = useRef(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 ( +
+
setPaused(true)} + onMouseLeave={() => setPaused(false)} + onFocusCapture={() => setPaused(true)} + 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) => ( + {image.alt} + ))} + +
+ +
+ {locality && ( +

+ {locality} +

+ )} +

+ {place.name} +

+ {(narrative.tagline ?? narrative.heroSubline) && ( +

+ {narrative.tagline ?? narrative.heroSubline} +

+ )} +
+ + {slides.length > 1 && ( +
    + {slides.map((image, i) => ( +
  • +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/solution/site/src/layouts/paper/Rooms.tsx b/solution/site/src/layouts/paper/Rooms.tsx new file mode 100644 index 0000000..c9db0df --- /dev/null +++ b/solution/site/src/layouts/paper/Rooms.tsx @@ -0,0 +1,139 @@ +import {Phone} from 'lucide-react'; +import type {ChannelLink} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import {bookingActionLabel, bookingLinks, sectionName, unitSpec, unitViews, type UnitView} from '@site/lib/derive'; +import {Carousel, CarouselSlide} from '@site/lib/ui'; +import {SectionHead} from './SectionHead'; + +export function Rooms() { + const payload = useSite(); + const units = unitViews(payload); + const spec = unitSpec(payload); + const booking = bookingLinks(payload)[0]; + + if (units.length === 0) return null; + + return ( +
+
+ +
+ +
+ {units.map((unit, i) => ( + + ))} +
+
+ ); +} + +function UnitPanel({ + unit, + no, + booking, + phone, +}: { + unit: UnitView; + no: number; + booking?: ChannelLink; + phone?: string; +}) { + return ( +
+ {unit.images.length > 0 && ( + + {unit.images.map((image) => ( + +
+ {image.alt} +
+
+ ))} +
+ )} + +
+

+ {String(no).padStart(2, '0')} +

+

+ {unit.name} +

+ + {unit.intro && ( +

{unit.intro}

+ )} + + {unit.rows.length > 0 && ( +
+ {unit.rows.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+ )} + + {unit.chips.length > 0 && ( +
    + {unit.chips.map((chip) => ( +
  • + {chip.value} +
  • + ))} +
+ )} + + {(booking || phone) && ( +
+ {booking && ( + + {bookingActionLabel(booking)} + + )} + {phone && ( + + + {phone} + + )} +
+ )} +
+
+ ); +} diff --git a/solution/site/src/layouts/paper/SectionHead.tsx b/solution/site/src/layouts/paper/SectionHead.tsx new file mode 100644 index 0000000..7474cd2 --- /dev/null +++ b/solution/site/src/layouts/paper/SectionHead.tsx @@ -0,0 +1,38 @@ +import type {ReactNode} from 'react'; + +export function SectionHead({ + title, + lead, + aside, +}: { + id: string; + title: string; + lead?: string; + aside?: ReactNode; +}) { + return ( +
+
+
+
+

+ {title} +

+ {lead && ( +

+ {lead} +

+ )} +
+ {aside &&
{aside}
} +
+
+ ); +} diff --git a/solution/site/src/layouts/paper/Shell.tsx b/solution/site/src/layouts/paper/Shell.tsx new file mode 100644 index 0000000..11b793c --- /dev/null +++ b/solution/site/src/layouts/paper/Shell.tsx @@ -0,0 +1,230 @@ +import {useEffect, useState, type ReactNode} from 'react'; +import {Phone} from 'lucide-react'; +import {useSite} from '@site/lib/site-context'; +import {enabledSections} from '@site/lib/derive'; +import {isoDate} from '@site/lib/format'; +import {MobileTabBar} from '@site/sections/MobileTabBar'; +import {SongPlayer} from '@site/sections/SongPlayer'; + +interface TabEntry { + label: string; + anchor: string; +} + +const ANCHOR: Record = { + 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 = { + about: '소개', + info: '이용 정보', + units: '객실', + pricing: '요금', + 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(); + const entries: TabEntry[] = []; + + for (const section of enabledSections(payload)) { + const anchor = ANCHOR[section.id]; + if (!anchor || seen.has(anchor)) continue; + if (anchor === 'units' && payload.units.length === 0) continue; + seen.add(anchor); + entries.push({label: section.name?.trim() || FALLBACK_LABEL[anchor] || anchor, anchor}); + } + + if (!seen.has('location')) entries.push({label: '오시는 길', anchor: 'location'}); + return entries; +} + +function useActiveAnchor(entries: TabEntry[]): string { + const anchors = entries.map((entry) => entry.anchor).join(','); + const [active, setActive] = useState(''); + + useEffect(() => { + if (typeof IntersectionObserver === 'undefined') return; + const targets = anchors + .split(',') + .map((id) => document.getElementById(id)) + .filter((el): el is HTMLElement => el !== null); + if (targets.length === 0) return; + + const observer = new IntersectionObserver( + (records) => { + const hit = records.find((record) => record.isIntersecting); + if (hit) setActive(hit.target.id); + }, + {rootMargin: '-45% 0px -50% 0px'}, + ); + targets.forEach((el) => observer.observe(el)); + return () => observer.disconnect(); + }, [anchors]); + + return active; +} + +export function Shell({children}: {children: ReactNode}) { + const payload = useSite(); + const {place, site} = payload; + const entries = useTabEntries(); + const active = useActiveAnchor(entries); + const address = place.roadAddress ?? place.address; + + return ( +
+
+
+ + {place.name} + + +
+ + {place.phone && ( + + + + )} +
+
+ + {entries.length > 0 && ( + + )} +
+ +
{children}
+ +
+
+

+ {place.name} +

+ {address &&

{address}

} + {place.phone && ( +

+ + {place.phone} + +

+ )} + +
+ 상호: {place.name} + {place.legal?.representative && 대표: {place.legal.representative}} + {place.legal?.businessRegistrationNumber && ( + 사업자등록번호: {place.legal.businessRegistrationNumber} + )} + {place.legal?.mailOrderNumber && 통신판매업신고: {place.legal.mailOrderNumber}} + {place.legal?.licenseNumber && ( + + {place.legal.licenseLabel ?? '인허가번호'}: {place.legal.licenseNumber} + + )} +
+ +

+ 작성·운영 {place.name} + {' · 최종 업데이트: '} + +

+ +

+ + AI O2O + + 의 Web4Ai로 만든 사이트입니다. +

+
+
+ + +
+ ); +} diff --git a/solution/site/src/layouts/paper/index.ts b/solution/site/src/layouts/paper/index.ts new file mode 100644 index 0000000..aa96969 --- /dev/null +++ b/solution/site/src/layouts/paper/index.ts @@ -0,0 +1,4 @@ +export {Shell} from './Shell'; +export {SectionHead} from './SectionHead'; +export {Hero} from './Hero'; +export {Rooms} from './Rooms'; diff --git a/solution/site/src/lib/layout.ts b/solution/site/src/lib/layout.ts index b1e4e00..6220da5 100644 --- a/solution/site/src/lib/layout.ts +++ b/solution/site/src/lib/layout.ts @@ -13,7 +13,7 @@ import {useSite} from '@site/lib/site-context'; * (백엔드도 길이만 검사한다 — `site_service.set_template`). 계약을 넓히지 않고 * 있는 값에 뜻을 준다. 모르는 값이 오면 `default` 로 떨어져 화면이 깨지지 않는다. */ -export type LayoutId = 'default' | 'reservation' | 'oasi' | 'studio' | 'pastel' | 'editorial'; +export type LayoutId = 'default' | 'reservation' | 'oasi' | 'studio' | 'pastel' | 'editorial' | 'paper'; const LAYOUT_BY_TEMPLATE: Record = { 'stay-reservation': 'reservation', @@ -21,6 +21,9 @@ const LAYOUT_BY_TEMPLATE: Record = { 'stay-studio': 'studio', 'stay-pastel': 'pastel', 'stay-editorial': 'editorial', + 'stay-paper': 'paper', + 'restaurant-paper': 'paper', + 'cafe-paper': 'paper', }; export function layoutOf(templateId: string | undefined): LayoutId { diff --git a/solution/site/src/lib/ui/Section.tsx b/solution/site/src/lib/ui/Section.tsx index 746dd67..717f24f 100644 --- a/solution/site/src/lib/ui/Section.tsx +++ b/solution/site/src/lib/ui/Section.tsx @@ -20,6 +20,7 @@ import {SectionHead as OasiHead} from '@site/layouts/oasi/SectionHead'; import {SectionHead as StudioHead} from '@site/layouts/studio/SectionHead'; import {SectionHead as PastelHead} from '@site/layouts/pastel/SectionHead'; import {SectionHead as EditorialHead} from '@site/layouts/editorial/SectionHead'; +import {SectionHead as PaperHead} from '@site/layouts/paper/SectionHead'; export type SectionTone = 'base' | 'alt' | 'dark'; @@ -72,7 +73,9 @@ export function Section({ ? PastelHead : layout === 'editorial' ? EditorialHead - : DefaultHead; + : layout === 'paper' + ? PaperHead + : DefaultHead; return (
; if (layout === 'pastel') return ; if (layout === 'editorial') return ; + if (layout === 'paper') return ; const heroVariant = payload.theme.sections.find((s) => s.id === 'hero')?.variantId; if (heroVariant === 'hero.slideshow') return ; diff --git a/solution/site/src/sections/UnitsSection.tsx b/solution/site/src/sections/UnitsSection.tsx index 29b4389..851bf95 100644 --- a/solution/site/src/sections/UnitsSection.tsx +++ b/solution/site/src/sections/UnitsSection.tsx @@ -13,6 +13,7 @@ import {Rooms as OasiRooms} from '@site/layouts/oasi/Rooms'; import {Rooms as StudioRooms} from '@site/layouts/studio/Rooms'; import {Rooms as PastelRooms} from '@site/layouts/pastel/Rooms'; import {Rooms as EditorialRooms} from '@site/layouts/editorial/Rooms'; +import {Rooms as PaperRooms} from '@site/layouts/paper/Rooms'; /** * 객실 · 메뉴 · 프로그램 — 업종에 따라 이름만 달라진다. @@ -41,6 +42,7 @@ export function UnitsSection() { if (layout === 'studio') return ; if (layout === 'pastel') return ; if (layout === 'editorial') return ; + if (layout === 'paper') return ; const roomsVariant = payload.theme.sections.find((s) => s.id === spec.path)?.variantId; if (roomsVariant === 'rooms.bands') return ;