mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
313 lines
20 KiB
TypeScript
313 lines
20 KiB
TypeScript
"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>
|
||
)
|
||
}
|