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

386 lines
21 KiB
TypeScript

"use client"
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react"
import { useRef, useState, type InputHTMLAttributes } from "react"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { DemoForm } from "../shared/demo-form"
import { TImage } from "../shared/media"
import { HorizontalScroll, Marquee, ParallaxImage, Reveal, RevealWords, Rich, ease, useScrollNav, wordmarkSize } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { restaurant, type RestaurantData } from "./definition"
import { fonts } from "./fonts"
const em = "italic text-t-accent"
const eyebrow = "mb-6 text-[11px] font-medium uppercase tracking-[0.32em] text-t-accent"
const container = "mx-auto w-full max-w-[1400px] px-6 md:px-10"
export function RestaurantTemplate({ content }: TemplateProps<RestaurantData>) {
const { brand, hero, marquee, about, menu, gallery, quote, reservation } = content.data
const show = (key: string) => !content.hidden.includes(key)
const links = [
{ href: "#karte", label: "Karte", visible: show("menu") },
{ href: "#ueber-uns", label: "Über uns", visible: show("about") },
{ href: "#galerie", label: "Einblicke", visible: show("gallery") },
{ href: "#reservieren", label: "Kontakt", visible: true },
].filter((link) => link.visible)
return (
<TemplateShell def={restaurant} content={content} fonts={fonts[content.fonts] ?? fonts.klassisch}>
<Nav name={brand.name} cta={hero.cta} links={links} />
<Hero hero={hero} />
{show("marquee") && (
<section data-order="marquee" className="border-y border-t-fg/10 py-8 md:py-10">
<Marquee duration={38}>
{marquee.items.map((item, i) => (
<span key={i} className="flex items-center font-t-display text-4xl italic md:text-6xl">
<span className="px-8 md:px-14">{item.text}</span>
<span className="text-2xl not-italic text-t-accent md:text-3xl">✦</span>
</span>
))}
</Marquee>
</section>
)}
{show("about") && (
<section data-order="about" id="ueber-uns" className={cn(container, "grid gap-20 py-28 md:grid-cols-12 md:gap-10 md:py-44")}>
<div className="md:col-span-6 md:pt-20">
<Reveal><p className={eyebrow}>{about.eyebrow}</p></Reveal>
<RevealWords
text={about.title}
emClassName={em}
className="font-t-display text-[clamp(2.5rem,5.2vw,4.75rem)] leading-[1.02] tracking-[-0.015em]"
/>
<Reveal delay={0.15}>
<p className="mt-10 max-w-lg text-lg leading-relaxed text-t-muted">{about.text}</p>
</Reveal>
<Reveal delay={0.25}>
<p className="mt-10 font-t-display text-2xl italic">— {about.signature}</p>
</Reveal>
</div>
<div className="relative md:col-span-5 md:col-start-8">
<ParallaxImage src={about.image} offset={50} className="aspect-[3/4]" sizes="(min-width: 768px) 40vw, 100vw" />
<Reveal delay={0.2} className="absolute -bottom-12 -left-4 w-[46%] md:-left-28">
<div className="relative aspect-square overflow-hidden border-[10px] border-t-bg">
<TImage src={about.imageSecondary} sizes="(min-width: 768px) 20vw, 45vw" />
</div>
</Reveal>
</div>
</section>
)}
{show("menu") && <Menu data-order="menu" menu={menu} />}
{show("gallery") && (
<div data-order="gallery" id="galerie">
<HorizontalScroll
trackClassName="items-start gap-5 px-6 md:gap-8 md:px-10"
header={
<div className={cn(container, "mb-10 flex items-end justify-between md:mb-14")}>
<div>
<p className={eyebrow}>{gallery.eyebrow}</p>
<h2 className="font-t-display text-[clamp(2.5rem,5vw,4.5rem)] leading-[0.98]">
<Rich text={gallery.title} emClassName={em} />
</h2>
</div>
<p className="hidden text-sm text-t-muted md:block">Scrollen zum Entdecken →</p>
</div>
}
>
{gallery.images.map((image, i) => (
<figure key={i} className={cn("group shrink-0", i % 2 === 1 && "mt-12 md:mt-20")}>
<div className="relative h-[42vh] w-[74vw] overflow-hidden md:h-[50vh] md:w-[32vw]">
<TImage
src={image.src}
alt={image.caption}
sizes="(min-width: 768px) 32vw, 74vw"
className="transition-transform duration-[1.4s] ease-out group-hover:scale-105"
/>
</div>
<figcaption className="mt-4 flex items-baseline gap-3 text-sm text-t-muted">
<span className="font-t-display text-base italic text-t-accent">{String(i + 1).padStart(2, "0")}</span>
{image.caption}
</figcaption>
</figure>
))}
</HorizontalScroll>
</div>
)}
{show("quote") && (
<section data-order="quote" className="mx-auto max-w-[1100px] px-6 py-28 text-center md:py-44">
<Reveal>
<span aria-hidden className="block font-t-display text-8xl leading-[0.5] text-t-accent">“</span>
</Reveal>
<RevealWords
as="p"
text={quote.text}
className="mt-8 font-t-display text-[clamp(1.9rem,4vw,3.5rem)] italic leading-[1.15] tracking-[-0.01em]"
/>
<Reveal delay={0.3}>
<p className="mt-10 text-[11px] uppercase tracking-[0.32em] text-t-muted">{quote.author}</p>
</Reveal>
</section>
)}
<section id="reservieren" className="border-t border-t-fg/10">
<div className={cn(container, "grid gap-16 py-28 md:grid-cols-2 md:gap-24 md:py-40")}>
<div>
<Reveal><p className={eyebrow}>{reservation.eyebrow}</p></Reveal>
<RevealWords
text={reservation.title}
emClassName={em}
className="font-t-display text-[clamp(2.5rem,5vw,4.5rem)] leading-[1.0] tracking-[-0.015em]"
/>
<Reveal delay={0.15}>
<p className="mt-8 max-w-md text-lg leading-relaxed text-t-muted">{reservation.text}</p>
<dl className="mt-12 divide-y divide-t-fg/10 border-y border-t-fg/10">
{reservation.hours.map((row, i) => (
<div key={i} className="flex items-baseline justify-between gap-6 py-4">
<dt className="text-t-muted">{row.days}</dt>
<dd className="font-t-display text-xl">{row.time}</dd>
</div>
))}
</dl>
<div className="mt-10 grid gap-1.5 text-sm">
<p className="text-t-muted">{reservation.address}</p>
<a href={`tel:${reservation.phone.replace(/\s/g, "")}`} className="w-fit hover:text-t-accent">{reservation.phone}</a>
<a href={`mailto:${reservation.email}`} className="w-fit hover:text-t-accent">{reservation.email}</a>
</div>
</Reveal>
</div>
<Reveal delay={0.1}>
<DemoForm
className="bg-t-surface p-8 md:p-12"
success={
<div className="py-10">
<p className="font-t-display text-5xl italic text-t-accent">Grazie!</p>
<p className="mt-4 text-t-muted">Wir bestätigen Ihre Reservierung in Kürze per E-Mail.</p>
</div>
}
>
<p className="font-t-display text-3xl">Tischanfrage</p>
<div className="mt-8 grid gap-7 sm:grid-cols-2">
<Line label="Name" name="name" autoComplete="name" required className="sm:col-span-2" />
<Line label="Datum" name="date" type="date" required />
<Line label="Uhrzeit" name="time" type="time" defaultValue="19:00" required />
<Line label="Personen" name="guests" type="number" min={1} max={12} defaultValue={2} />
<Line label="Telefon" name="phone" type="tel" autoComplete="tel" />
<Line label="Anmerkungen" name="note" placeholder="Allergien, Anlass, Wunschplatz …" className="sm:col-span-2" />
</div>
<button
type="submit"
className="group mt-10 flex w-full items-center justify-center gap-3 rounded-full bg-t-accent py-4 text-sm font-medium text-t-accent-fg transition-transform hover:scale-[1.01]"
>
Anfrage senden
<ArrowRightIcon className="size-4 transition-transform group-hover:translate-x-1" />
</button>
</DemoForm>
</Reveal>
</div>
</section>
<footer className="overflow-hidden border-t border-t-fg/10 pt-16">
<div className={cn(container, "flex flex-col gap-8 text-sm text-t-muted md:flex-row md:items-center md:justify-between")}>
<p>{brand.name} · {brand.tagline} · {reservation.address}</p>
<div className="flex gap-8">
<a href="#" className="hover:text-t-fg">Instagram</a>
<a href="#" className="hover:text-t-fg">Impressum</a>
<a href="#" className="hover:text-t-fg">Datenschutz</a>
</div>
</div>
<motion.p
aria-hidden
initial={{ y: "35%", opacity: 0 }}
whileInView={{ y: "0%", opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 1.4, ease }}
style={{ fontSize: wordmarkSize(brand.name) }}
className="mt-10 select-none whitespace-nowrap text-center font-t-display italic leading-[0.8] tracking-[-0.03em] text-t-accent"
>
{brand.name}
</motion.p>
</footer>
</TemplateShell>
)
}
function Nav({ name, cta, links }: { name: string; cta: string; links: { href: string; label: 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 border-b transition-colors duration-500",
scrolled ? "border-t-fg/10 bg-t-bg/80 backdrop-blur-xl" : "border-transparent"
)}
>
<nav className={cn(container, "flex h-20 items-center justify-between")}>
<a href="#top" className="font-t-display text-2xl italic tracking-tight">{name}</a>
<div className="hidden items-center gap-10 text-sm md:flex">
{links.map((link) => (
<a key={link.href} href={link.href} className="group relative text-t-fg/75 transition-colors hover:text-t-fg">
{link.label}
<span className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-0 bg-t-accent transition-transform duration-500 group-hover:scale-x-100" />
</a>
))}
</div>
<a
href="#reservieren"
className="rounded-full border border-t-fg/25 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 Hero({ hero }: { hero: RestaurantData["hero"] }) {
const ref = useRef<HTMLElement>(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end start"] })
const y = useTransform(scrollYProgress, [0, 1], ["0%", "28%"])
const opacity = useTransform(scrollYProgress, [0, 0.65], [1, 0])
return (
<section ref={ref} id="top" className="relative flex min-h-svh items-end overflow-hidden">
<motion.div className="absolute inset-0" style={{ y }}>
<motion.div className="absolute inset-0" initial={{ scale: 1.18 }} animate={{ scale: 1 }} transition={{ duration: 2.6, ease }}>
<TImage src={hero.image} eager />
</motion.div>
</motion.div>
<div className="absolute inset-0 bg-gradient-to-t from-t-bg via-t-bg/30 to-t-bg/70" />
<motion.div style={{ opacity }} className={cn(container, "relative pt-40 pb-16 md:pb-24")}>
<motion.p
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease, delay: 0.3 }}
className={eyebrow}
>
{hero.eyebrow}
</motion.p>
<RevealWords
as="h1"
onMount
delay={0.45}
text={hero.title}
emClassName={em}
className="font-t-display text-[clamp(3.4rem,10vw,10rem)] leading-[0.9] tracking-[-0.025em]"
/>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, ease, delay: 1.1 }}
className="mt-10 flex flex-col gap-8 md:flex-row md:items-end md:justify-between"
>
<p className="max-w-md text-base leading-relaxed text-t-fg/80 md:text-lg">{hero.text}</p>
<div className="flex flex-wrap items-center gap-6">
<span className="flex items-center gap-2.5 text-sm text-t-fg/75">
<span className="size-2 rounded-full bg-emerald-400 shadow-[0_0_12px] shadow-emerald-400/70" />
{hero.hours}
</span>
<a
href="#reservieren"
className="group inline-flex items-center gap-3 rounded-full bg-t-accent px-7 py-4 text-sm font-medium text-t-accent-fg transition-transform hover:scale-[1.03]"
>
{hero.cta}
<ArrowRightIcon className="size-4 transition-transform group-hover:translate-x-1" />
</a>
</div>
</motion.div>
</motion.div>
</section>
)
}
function Menu({ menu }: { menu: RestaurantData["menu"] }) {
const categoryOf = (dish: { category: string }) => dish.category.trim() || "Weitere"
const categories = [...new Set(menu.dishes.map(categoryOf))]
const [selected, setSelected] = useState(0)
const current = categories[Math.min(selected, categories.length - 1)]
return (
<section id="karte" className="bg-t-surface py-28 md:py-40">
<div className="mx-auto max-w-[1100px] px-6 md:px-10">
<div className="text-center">
<Reveal><p className={eyebrow}>{menu.eyebrow}</p></Reveal>
<RevealWords
text={menu.title}
emClassName={em}
className="font-t-display text-[clamp(2.75rem,6vw,5.5rem)] leading-none tracking-[-0.02em]"
/>
<Reveal delay={0.15}>
<p className="mx-auto mt-6 max-w-md text-t-muted">{menu.text}</p>
</Reveal>
</div>
<Reveal delay={0.2} className="mt-14 flex flex-wrap justify-center gap-2">
<div role="tablist" aria-label={menu.eyebrow} className="flex flex-wrap justify-center gap-2">
{categories.map((category, i) => (
<button
key={category}
type="button"
role="tab"
aria-selected={category === current}
onClick={() => setSelected(i)}
className={cn(
"relative cursor-pointer rounded-full px-5 py-2.5 text-sm transition-colors duration-300",
category === current ? "text-t-accent-fg" : "text-t-fg/65 hover:text-t-fg"
)}
>
{category === current && (
<motion.span
layoutId="restaurant-menu-tab"
className="absolute inset-0 rounded-full bg-t-accent"
transition={{ type: "spring", bounce: 0.18, duration: 0.6 }}
/>
)}
<span className="relative">{category}</span>
</button>
))}
</div>
</Reveal>
<AnimatePresence mode="wait">
<motion.ul
key={current}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.5, ease }}
className="mt-16 grid gap-x-16 gap-y-11 md:grid-cols-2"
>
{menu.dishes.filter((dish) => categoryOf(dish) === current).map((dish, i) => (
<li key={i}>
<div className="flex items-baseline gap-4">
<h3 className="font-t-display text-2xl leading-tight">{dish.name}</h3>
<span className="flex-1 -translate-y-1 border-b border-dotted border-t-fg/25" />
<span className="font-t-display text-xl text-t-accent">{dish.price}</span>
</div>
<p className="mt-2 text-sm leading-relaxed text-t-muted">{dish.description}</p>
</li>
))}
</motion.ul>
</AnimatePresence>
</div>
</section>
)
}
function Line({ label, className, ...props }: InputHTMLAttributes<HTMLInputElement> & { label: string }) {
return (
<label className={cn("block", className)}>
<span className="text-[11px] uppercase tracking-[0.24em] text-t-muted">{label}</span>
<input
{...props}
className="mt-2 w-full border-b border-t-fg/20 bg-transparent py-2.5 text-base outline-none transition-colors placeholder:text-t-muted/60 focus:border-t-accent"
/>
</label>
)
}