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