since3-portal/modules/templates/catalog/hotel/template.tsx
2026-10-10 02:38:50 +02:00

313 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { motion } from "motion/react"
import { useRef, useState } from "react"
import { BedDoubleIcon, BikeIcon, CarIcon, ChevronLeftIcon, ChevronRightIcon, CoffeeIcon, DogIcon, MapPinIcon, MaximizeIcon, PhoneIcon, SparklesIcon, StarIcon, UsersIcon, WavesIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { DemoNote } from "../shared/demo"
import { TField, TSelect } from "../shared/fields"
import { Lightbox } from "../shared/lightbox"
import { TImage } from "../shared/media"
import { Reveal, RevealWords, ScrollWords, ease, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { hotel, type HotelData } from "./definition"
import { fonts } from "./fonts"
const em = "italic text-t-accent"
const container = "mx-auto w-full max-w-[1320px] px-5 md:px-10"
const heading = "font-t-display font-normal tracking-[-0.01em]"
const label = "text-[11px] font-semibold uppercase tracking-[0.28em]"
const amenityIcons = [SparklesIcon, CoffeeIcon, WavesIcon, BikeIcon, CarIcon, DogIcon]
const DAY = 86_400_000
type Booking = { arrive: string; depart: string; guests: string; room: string }
export function HotelTemplate({ content }: TemplateProps<HotelData>) {
const { brand, hero, intro, rooms, amenities, gallery, experiences, reviews, contact } = content.data
const show = (key: string) => !content.hidden.includes(key)
const [booking, setBooking] = useState<Booking>({ arrive: "", depart: "", guests: "2", room: rooms.items[0]?.name ?? "" })
const [lightbox, setLightbox] = useState<number | null>(null)
const update = (patch: Partial<Booking>) => setBooking((b) => ({ ...b, ...patch }))
return (
<TemplateShell def={hotel} content={content} fonts={fonts[content.fonts] ?? fonts.klassisch}>
<Nav name={brand.name} cta={hero.cta} />
<section id="top" className="relative flex min-h-svh flex-col justify-end overflow-hidden text-white">
<motion.div className="absolute inset-0" initial={{ scale: 1.12 }} animate={{ scale: 1 }} transition={{ duration: 8, ease: "linear" }}>
<TImage src={hero.image} eager />
</motion.div>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-black/40" />
<div className={cn(container, "relative pb-10 text-center")}>
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 1, delay: 0.3 }} className={cn(label, "text-white/80")}>
{hero.eyebrow}
</motion.p>
<RevealWords as="h1" onMount delay={0.4} text={hero.title} emClassName="italic" className={cn(heading, "mt-6 text-[clamp(3rem,8vw,7.5rem)] leading-[1]")} />
<motion.div initial={{ opacity: 0, y: 30 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.9, ease, delay: 1 }} className="mx-auto mt-14 max-w-4xl">
<BookingBar booking={booking} rooms={rooms.items} onChange={update} cta={hero.cta} />
</motion.div>
</div>
</section>
{show("intro") && (
<section data-order="intro" className="mx-auto max-w-[1100px] px-5 py-24 md:py-36">
<ScrollWords text={intro.text} className={cn(heading, "text-center text-[clamp(1.75rem,3.8vw,3.25rem)] leading-[1.25]")} />
</section>
)}
<Rooms rooms={rooms} onPick={(room) => update({ room })} />
{show("amenities") && (
<section data-order="amenities" className="bg-t-surface py-24 md:py-32">
<div className={container}>
<RevealWords text={amenities.title} emClassName={em} className={cn(heading, "max-w-2xl text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
<div className="mt-14 grid gap-x-10 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
{amenities.items.map((item, i) => {
const Icon = amenityIcons[i % amenityIcons.length]
return (
<Reveal key={i} delay={(i % 3) * 0.08} className="flex gap-5">
<span className="grid size-14 shrink-0 place-items-center rounded-full border border-t-accent/40 text-t-accent">
<Icon className="size-6" />
</span>
<div>
<h3 className={cn(heading, "text-2xl")}>{item.title}</h3>
<p className="mt-1 text-t-muted">{item.text}</p>
</div>
</Reveal>
)
})}
</div>
</div>
</section>
)}
{show("gallery") && (
<section data-order="gallery" className={cn(container, "py-24 md:py-32")}>
<RevealWords text={gallery.title} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)]")} />
<div className="mt-12 columns-1 gap-4 sm:columns-2 lg:columns-3">
{gallery.images.map((image, i) => (
<Reveal key={i} delay={(i % 3) * 0.08} className="mb-4 break-inside-avoid">
<button type="button" onClick={() => setLightbox(i)} className={cn("group relative block w-full cursor-zoom-in overflow-hidden", ["aspect-[4/5]", "aspect-[4/3]", "aspect-square"][i % 3])}>
<TImage src={image.src} alt={image.caption} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" className="transition-transform duration-700 group-hover:scale-105" />
<span className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 text-left text-sm text-white opacity-0 transition-opacity group-hover:opacity-100">{image.caption}</span>
</button>
</Reveal>
))}
</div>
<Lightbox images={gallery.images} index={lightbox} onIndex={setLightbox} />
</section>
)}
{show("experiences") && (
<section data-order="experiences" className={cn(container, "pb-24 md:pb-32")}>
<Reveal><p className={cn(label, "mb-5 text-t-accent")}>{experiences.eyebrow}</p></Reveal>
<RevealWords text={experiences.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
<div className="mt-12 grid gap-6 md:grid-cols-3">
{experiences.items.map((item, i) => (
<Reveal key={i} delay={i * 0.1}>
<article className="group">
<div className="relative aspect-[3/4] overflow-hidden rounded-t-full">
<TImage src={item.image} alt={item.title} sizes="(min-width: 768px) 33vw, 100vw" className="transition-transform duration-[1.2s] group-hover:scale-105" />
</div>
<p className={cn(label, "mt-5 text-t-accent")}>{item.distance}</p>
<h3 className={cn(heading, "mt-2 text-2xl")}>{item.title}</h3>
<p className="mt-1 text-t-muted">{item.text}</p>
</article>
</Reveal>
))}
</div>
</section>
)}
{show("reviews") && (
<section data-order="reviews" className="bg-t-fg py-24 text-t-bg md:py-32">
<div className={cn(container, "grid gap-12 lg:grid-cols-[0.7fr_1.3fr] lg:items-center")}>
<Reveal>
<div className="text-center lg:text-left">
<p className={cn(heading, "text-[7rem] leading-none text-t-accent")}>{reviews.score}</p>
<p className={cn(heading, "text-3xl")}>{reviews.label}</p>
<p className="mt-2 flex items-center justify-center gap-2 opacity-70 lg:justify-start">
<StarIcon className="size-4 fill-current" /> {reviews.count}
</p>
</div>
</Reveal>
<div className="grid gap-5">
{reviews.items.map((review, i) => (
<Reveal key={i} delay={i * 0.1}>
<figure className="border-l border-t-bg/20 pl-6">
<blockquote className={cn(heading, "text-2xl italic leading-snug")}>„{review.quote}“</blockquote>
<figcaption className="mt-3 text-sm opacity-70">{review.author}</figcaption>
</figure>
</Reveal>
))}
</div>
</div>
</section>
)}
<section id="buchen" className={cn(container, "grid gap-12 py-24 md:py-32 lg:grid-cols-2")}>
<div>
<RevealWords text={contact.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4.5rem)] leading-[1.05]")} />
<Reveal delay={0.1}>
<p className="mt-6 max-w-md text-lg text-t-muted">{contact.text}</p>
<div className="mt-8 space-y-3">
<p className="flex items-center gap-3"><MapPinIcon className="size-4 text-t-accent" /> {brand.address}</p>
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className="flex items-center gap-3"><PhoneIcon className="size-4 text-t-accent" /> {brand.phone}</a>
</div>
</Reveal>
</div>
<Reveal delay={0.1}>
<BookingForm booking={booking} rooms={rooms.items} onChange={update} />
</Reveal>
</section>
<footer className="border-t border-t-fg/10">
<div className={cn(container, "flex flex-col gap-4 py-10 text-sm text-t-muted md:flex-row md:justify-between")}>
<p><span className={cn(heading, "text-lg text-t-fg")}>{brand.name}</span> · {brand.suffix}</p>
<div className="flex gap-6">
<a href="#" className="hover:text-t-fg">Impressum</a>
<a href="#" className="hover:text-t-fg">Datenschutz</a>
</div>
</div>
</footer>
</TemplateShell>
)
}
function Nav({ name, cta }: { name: string; cta: string }) {
const { scrolled, hidden } = useScrollNav()
return (
<motion.header
animate={{ y: hidden ? "-100%" : "0%" }}
transition={{ duration: 0.45, ease }}
className={cn("fixed inset-x-0 top-0 z-50 transition-colors duration-500", scrolled ? "bg-t-bg/90 text-t-fg backdrop-blur-xl" : "text-white")}
>
<nav className={cn(container, "flex h-20 items-center justify-between")}>
<a href="#top" className={cn(heading, "text-2xl")}>{name}</a>
<a href="#buchen" className="rounded-full border border-current/40 px-5 py-2.5 text-sm transition-colors hover:border-t-accent hover:bg-t-accent hover:text-t-accent-fg">{cta}</a>
</nav>
</motion.header>
)
}
function nightsBetween(arrive: string, depart: string) {
const nights = Math.round((Date.parse(depart) - Date.parse(arrive)) / DAY)
return Number.isFinite(nights) && nights > 0 ? nights : 0
}
function BookingBar({ booking, rooms, onChange, cta }: { booking: Booking; rooms: HotelData["rooms"]["items"]; onChange: (patch: Partial<Booking>) => void; cta: string }) {
const nights = nightsBetween(booking.arrive, booking.depart)
const room = rooms.find((r) => r.name === booking.room)
return (
<div className="grid gap-2 rounded-3xl bg-white/12 p-2 text-left ring-1 ring-white/20 backdrop-blur-xl md:grid-cols-[1fr_1fr_0.7fr_auto]">
{(["arrive", "depart"] as const).map((key) => (
<label key={key} className="rounded-2xl px-4 py-2.5 hover:bg-white/10">
<span className={cn(label, "block text-white/70")}>{key === "arrive" ? "Anreise" : "Abreise"}</span>
<input type="date" value={booking[key]} onChange={(e) => onChange({ [key]: e.target.value })} className="w-full bg-transparent text-white outline-none [color-scheme:dark]" />
</label>
))}
<label className="rounded-2xl px-4 py-2.5 hover:bg-white/10">
<span className={cn(label, "block text-white/70")}>Gäste</span>
<select value={booking.guests} onChange={(e) => onChange({ guests: e.target.value })} className="w-full bg-transparent text-white outline-none [&>option]:text-zinc-900">
{["1", "2", "3", "4"].map((g) => (
<option key={g} value={g}>{g} {g === "1" ? "Gast" : "Gäste"}</option>
))}
</select>
</label>
<a href="#buchen" className="flex flex-col items-center justify-center rounded-2xl bg-t-accent px-6 py-3 text-t-accent-fg">
<span className="font-semibold">{cta}</span>
{nights > 0 && room && <span className="text-xs opacity-80">{nights} Nächte · ab {Number(room.price) * nights} €</span>}
</a>
</div>
)
}
function Rooms({ rooms, onPick }: { rooms: HotelData["rooms"]; onPick: (room: string) => void }) {
const track = useRef<HTMLDivElement>(null)
const scroll = (direction: number) => track.current?.scrollBy({ left: direction * track.current.clientWidth * 0.8, behavior: "smooth" })
return (
<section className="py-24 md:py-32">
<div className={cn(container, "flex items-end justify-between gap-6")}>
<div>
<Reveal><p className={cn(label, "mb-5 text-t-accent")}>{rooms.eyebrow}</p></Reveal>
<RevealWords text={rooms.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
</div>
<div className="hidden gap-2 md:flex">
<button type="button" aria-label="Zurück" onClick={() => scroll(-1)} className="grid size-12 cursor-pointer place-items-center rounded-full border border-t-fg/20 hover:border-t-accent"><ChevronLeftIcon className="size-5" /></button>
<button type="button" aria-label="Weiter" onClick={() => scroll(1)} className="grid size-12 cursor-pointer place-items-center rounded-full border border-t-fg/20 hover:border-t-accent"><ChevronRightIcon className="size-5" /></button>
</div>
</div>
<div ref={track} className="mt-12 flex snap-x snap-mandatory gap-5 overflow-x-auto px-5 pb-4 [scrollbar-width:none] md:px-[max(2.5rem,calc((100vw-1320px)/2+2.5rem))]">
{rooms.items.map((room, i) => (
<motion.article
key={i}
initial={{ opacity: 0, x: 40 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, ease, delay: i * 0.08 }}
className="group w-[82vw] shrink-0 snap-start sm:w-[420px]"
>
<div className="relative aspect-[4/3] overflow-hidden rounded-2xl">
<TImage src={room.image} alt={room.name} sizes="420px" className="transition-transform duration-[1.2s] group-hover:scale-105" />
<span className="absolute top-4 right-4 rounded-full bg-t-bg/90 px-3 py-1 text-sm">ab {room.price} € / Nacht</span>
</div>
<h3 className={cn(heading, "mt-5 text-3xl")}>{room.name}</h3>
<p className="mt-2 text-t-muted">{room.text}</p>
<div className="mt-4 flex items-center justify-between text-sm">
<span className="flex gap-4 text-t-muted">
<span className="flex items-center gap-1.5"><MaximizeIcon className="size-4" /> {room.size}</span>
<span className="flex items-center gap-1.5"><UsersIcon className="size-4" /> {room.guests}</span>
</span>
<a href="#buchen" onClick={() => onPick(room.name)} className="flex items-center gap-1.5 font-semibold text-t-accent">
<BedDoubleIcon className="size-4" /> Wählen
</a>
</div>
</motion.article>
))}
</div>
</section>
)
}
function BookingForm({ booking, rooms, onChange }: { booking: Booking; rooms: HotelData["rooms"]["items"]; onChange: (patch: Partial<Booking>) => void }) {
const [sent, setSent] = useState(false)
const nights = nightsBetween(booking.arrive, booking.depart)
const room = rooms.find((r) => r.name === booking.room)
const total = room ? Number(room.price) * nights : 0
if (sent) {
return (
<div className="rounded-3xl bg-t-surface p-10 text-center">
<p className={cn(heading, "text-4xl")}>Herzlichen <span className={em}>Dank!</span></p>
<p className="mt-3 text-t-muted">Wir prüfen Ihre Anfrage und melden uns innerhalb weniger Stunden.</p>
<DemoNote className="mt-6 text-sm opacity-60">Demo-Hinweis: Live landet die Anfrage direkt im Buchungssystem.</DemoNote>
</div>
)
}
return (
<form
className="grid gap-4 rounded-3xl bg-t-surface p-6 sm:grid-cols-2 md:p-8"
onSubmit={(event) => {
event.preventDefault()
setSent(true)
}}
>
<TField label="Anreise" type="date" required value={booking.arrive} onChange={(e) => onChange({ arrive: e.target.value })} />
<TField label="Abreise" type="date" required value={booking.depart} onChange={(e) => onChange({ depart: e.target.value })} />
<TSelect label="Zimmer" value={booking.room} onChange={(e) => onChange({ room: e.target.value })} options={rooms.map((r) => r.name)} />
<TSelect label="Gäste" value={booking.guests} onChange={(e) => onChange({ guests: e.target.value })} options={["1", "2", "3", "4"]} />
<TField label="Name" name="name" required autoComplete="name" />
<TField label="E-Mail" name="email" type="email" required autoComplete="email" />
<div className="flex items-center justify-between rounded-2xl bg-t-bg px-5 py-4 sm:col-span-2">
<span className="text-sm text-t-muted">{nights > 0 ? `${nights} Nächte · ${booking.room}` : "Bitte Reisedaten wählen"}</span>
<span className={cn(heading, "text-2xl")}>{total > 0 ? `${total} €` : "–"}</span>
</div>
<button type="submit" className="cursor-pointer rounded-full bg-t-accent py-4 font-semibold text-t-accent-fg sm:col-span-2">Unverbindlich anfragen</button>
</form>
)
}