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

237 lines
13 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 { AnimatePresence, motion } from "motion/react"
import { useEffect, useState } from "react"
import { BellRingIcon, FlameIcon, InfoIcon, LeafIcon, SearchIcon, WheatOffIcon, WifiIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { DemoNote } from "../shared/demo"
import { splitList } from "../shared/hooks"
import { TImage } from "../shared/media"
import { ease } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { speisekarte, type SpeisekarteData } from "./definition"
import { fonts } from "./fonts"
type Dish = SpeisekarteData["menu"]["items"][number]
const column = "mx-auto w-full max-w-2xl px-4"
const heading = "font-t-display font-semibold tracking-[-0.01em]"
const FILTERS = [
{ key: "vegetarisch", label: "Vegetarisch", icon: LeafIcon },
{ key: "vegan", label: "Vegan", icon: LeafIcon },
{ key: "glutenfrei", label: "Glutenfrei", icon: WheatOffIcon },
] as const
const slug = (text: string) => `kat-${text.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`
const tagsOf = (dish: Dish) => splitList(dish.tags).map((tag) => tag.toLowerCase())
export function SpeisekarteTemplate({ content }: TemplateProps<SpeisekarteData>) {
const { brand, special, menu, legend } = content.data
const show = (key: string) => !content.hidden.includes(key)
const [query, setQuery] = useState("")
const [filters, setFilters] = useState<string[]>([])
const [called, setCalled] = useState(false)
const matches = (dish: Dish) => {
const tags = tagsOf(dish)
// vegan zählt auch als vegetarisch
const fits = filters.every((filter) => tags.includes(filter) || (filter === "vegetarisch" && tags.includes("vegan")))
const text = `${dish.name} ${dish.description}`.toLowerCase()
return fits && (!query.trim() || text.includes(query.trim().toLowerCase()))
}
const categories = [...new Set(menu.items.map((dish) => dish.category))]
const visible = categories.map((category) => ({ category, dishes: menu.items.filter((dish) => dish.category === category && matches(dish)) })).filter((group) => group.dishes.length)
useEffect(() => {
if (!called) return
const timer = setTimeout(() => setCalled(false), 3500)
return () => clearTimeout(timer)
}, [called])
return (
<TemplateShell def={speisekarte} content={content} fonts={fonts[content.fonts] ?? fonts.trattoria} className="bg-t-surface pb-24 [--t-safe-bottom:4.5rem]">
<header className="bg-t-bg">
<div className={cn(column, "pt-10 pb-6 text-center")}>
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="inline-block rounded-full bg-t-accent px-3 py-1 text-xs font-bold text-t-accent-fg">{brand.table}</motion.p>
<motion.h1 initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease }} className={cn(heading, "mt-3 text-4xl")}>
{brand.name}
</motion.h1>
<p className="mt-2 text-sm text-t-muted">{brand.note}</p>
<p className="mt-1 flex items-center justify-center gap-1.5 text-xs text-t-muted"><WifiIcon className="size-3.5" /> {brand.wifi}</p>
</div>
</header>
<Tabs categories={visible.map((group) => group.category)} query={query} onQuery={setQuery} filters={filters} onFilters={setFilters} />
{show("special") && (
<section data-order="special" className={cn(column, "mt-5")}>
<motion.article initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease, delay: 0.2 }} className="overflow-hidden rounded-3xl bg-t-bg shadow-sm">
<div className="relative aspect-[16/8]">
<TImage src={special.image} alt={special.name} eager sizes="672px" />
<span className="absolute top-3 left-3 rounded-full bg-t-accent px-3 py-1 text-xs font-bold text-t-accent-fg">{special.title}</span>
</div>
<div className="flex items-start justify-between gap-4 p-5">
<div>
<h2 className={cn(heading, "text-2xl")}>{special.name}</h2>
<p className="mt-1 text-sm text-t-muted">{special.text}</p>
</div>
<p className={cn(heading, "text-xl text-t-accent")}>{special.price} €</p>
</div>
</motion.article>
</section>
)}
<section className={cn(column, "mt-5 space-y-8")}>
{visible.length === 0 && <p className="py-16 text-center text-t-muted">Nichts gefunden – probier einen anderen Filter.</p>}
{visible.map((group) => (
<div key={group.category} id={slug(group.category)} className="scroll-mt-32">
<h2 className={cn(heading, "mb-3 px-1 text-2xl")}>{group.category}</h2>
<ul className="divide-y divide-t-fg/5 overflow-hidden rounded-3xl bg-t-bg">
{group.dishes.map((dish) => (
<DishRow key={dish.name} dish={dish} />
))}
</ul>
</div>
))}
</section>
{show("legend") && (
<section data-order="legend" className={cn(column, "mt-8")}>
<p className="flex items-start gap-2 rounded-2xl bg-t-bg p-4 text-xs leading-relaxed text-t-muted">
<InfoIcon className="mt-0.5 size-4 shrink-0" /> {legend.text}
</p>
</section>
)}
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-t-fg/10 bg-t-bg/95 p-3 backdrop-blur">
<div className="mx-auto max-w-2xl">
<button
type="button"
onClick={() => setCalled(true)}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-full bg-t-fg py-3.5 font-semibold text-t-bg transition-transform active:scale-[0.98]"
>
<BellRingIcon className="size-4" /> Service an {brand.table} rufen
</button>
</div>
</div>
<AnimatePresence>
{called && (
<motion.div
role="status"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="fixed inset-x-4 top-4 z-[70] mx-auto max-w-sm rounded-2xl bg-t-accent px-5 py-4 text-center font-semibold text-t-accent-fg shadow-xl"
>
Kommt sofort! Der Service ist auf dem Weg zu {brand.table}.
<DemoNote as="span" className="mt-1 block text-xs font-normal opacity-80">Demo: Live erscheint der Ruf auf dem Tablet des Teams.</DemoNote>
</motion.div>
)}
</AnimatePresence>
</TemplateShell>
)
}
function Tabs({ categories, query, onQuery, filters, onFilters }: {
categories: string[]
query: string
onQuery: (query: string) => void
filters: string[]
onFilters: (filters: string[]) => void
}) {
const [active, setActive] = useState(categories[0] ?? "")
const key = categories.join("|")
// Die gerade sichtbare Kategorie hervorheben
useEffect(() => {
const sections = key.split("|").map((category) => document.getElementById(slug(category))).filter((el): el is HTMLElement => el !== null)
const observer = new IntersectionObserver(
(entries) => {
const visible = entries.filter((entry) => entry.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]
if (visible) setActive(visible.target.id)
},
{ rootMargin: "-140px 0px -60% 0px" }
)
sections.forEach((section) => observer.observe(section))
return () => observer.disconnect()
}, [key])
return (
<div className="sticky top-0 z-30 border-b border-t-fg/10 bg-t-bg/95 backdrop-blur">
<div className={cn(column, "space-y-3 py-3")}>
<label className="flex items-center gap-2 rounded-full bg-t-surface px-4 py-2.5">
<SearchIcon className="size-4 text-t-muted" />
<input value={query} onChange={(e) => onQuery(e.target.value)} placeholder="Gericht suchen …" aria-label="Gericht suchen" className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-t-muted" />
</label>
<div className="flex gap-2 overflow-x-auto [scrollbar-width:none]">
{FILTERS.map((filter) => {
const on = filters.includes(filter.key)
return (
<button
key={filter.key}
type="button"
aria-pressed={on}
onClick={() => onFilters(on ? filters.filter((f) => f !== filter.key) : [...filters, filter.key])}
className={cn("flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-semibold transition-colors", on ? "border-t-accent bg-t-accent text-t-accent-fg" : "border-t-fg/15")}
>
<filter.icon className="size-3.5" /> {filter.label}
</button>
)
})}
</div>
<nav className="-mx-1 flex gap-1 overflow-x-auto [scrollbar-width:none]">
{categories.map((category) => {
const isActive = active === slug(category)
return (
<a key={category} href={`#${slug(category)}`} className={cn("relative shrink-0 rounded-full px-4 py-2 text-sm font-semibold", isActive ? "text-t-accent-fg" : "text-t-muted")}>
{isActive && <motion.span layoutId="karte-tab" className="absolute inset-0 rounded-full bg-t-accent" transition={{ type: "spring", bounce: 0.2, duration: 0.5 }} />}
<span className="relative">{category}</span>
</a>
)
})}
</nav>
</div>
</div>
)
}
function DishRow({ dish }: { dish: Dish }) {
const [open, setOpen] = useState(false)
const tags = tagsOf(dish)
return (
<li>
<button type="button" onClick={() => setOpen(!open)} aria-expanded={open} className="flex w-full cursor-pointer gap-4 p-4 text-left">
<div className="min-w-0 flex-1">
<p className="font-semibold">{dish.name}</p>
<p className="mt-0.5 text-sm text-t-muted">{dish.description}</p>
{tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{tags.map((tag) => (
<span key={tag} className="flex items-center gap-1 rounded-full bg-t-surface px-2 py-0.5 text-[11px] font-semibold capitalize">
{tag === "scharf" ? <FlameIcon className="size-3 text-t-accent" /> : tag === "glutenfrei" ? <WheatOffIcon className="size-3" /> : <LeafIcon className="size-3 text-emerald-600" />}
{tag}
</span>
))}
</div>
)}
<p className={cn(heading, "mt-2 text-lg text-t-accent")}>{dish.price} €</p>
</div>
{dish.image && (
<div className="relative size-24 shrink-0 overflow-hidden rounded-2xl">
<TImage src={dish.image} alt={dish.name} sizes="96px" />
</div>
)}
</button>
<AnimatePresence initial={false}>
{open && (
<motion.p initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="overflow-hidden px-4 text-xs text-t-muted">
<span className="block pb-4">Allergene: {dish.allergens || "keine kennzeichnungspflichtigen"}</span>
</motion.p>
)}
</AnimatePresence>
</li>
)
}