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

381 lines
23 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 { useState } from "react"
import { ArrowRightIcon, MapPinIcon, MinusIcon, PlusIcon, ShoppingBagIcon, XIcon } 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 { TImage } from "../shared/media"
import { Reveal, RevealWords, ease, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { cafe, type CafeData } from "./definition"
import { fonts, script } from "./fonts"
type Basket = Record<string, number>
const em = "text-t-accent"
const container = "mx-auto w-full max-w-[1240px] px-5 md:px-8"
const heading = "font-t-display font-semibold tracking-[-0.02em]"
const ALL = "Alles"
const price = (value: string) => Number(value.replace(",", ".")) || 0
const euro = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" })
function Wave({ className, flip }: { className?: string; flip?: boolean }) {
return (
<svg viewBox="0 0 1440 60" preserveAspectRatio="none" aria-hidden className={cn("block h-10 w-full md:h-14", flip && "rotate-180", className)}>
<path d="M0 30 C 120 0, 240 60, 360 30 S 600 0, 720 30 S 960 60, 1080 30 S 1320 0, 1440 30 V60 H0 Z" fill="currentColor" />
</svg>
)
}
function Sticker({ children, className, rotate = -8 }: { children: string; className?: string; rotate?: number }) {
return (
<motion.span
initial={{ scale: 0, rotate: rotate - 20 }}
animate={{ scale: 1, rotate: [rotate, rotate + 4, rotate] }}
transition={{ scale: { type: "spring", bounce: 0.5, delay: 0.9 }, rotate: { duration: 4, repeat: Infinity, ease: "easeInOut" } }}
className={cn("absolute z-10 rounded-full border-2 border-dashed border-t-fg/30 bg-t-bg px-4 py-2 text-sm font-bold shadow-lg", className)}
>
{children}
</motion.span>
)
}
export function CafeTemplate({ content }: TemplateProps<CafeData>) {
const { brand, hero, fresh, products, story, breakfast, social, visit } = content.data
const show = (key: string) => !content.hidden.includes(key)
const [basket, setBasket] = useState<Basket>({})
const [open, setOpen] = useState(false)
const count = Object.values(basket).reduce((sum, n) => sum + n, 0)
const change = (name: string, delta: number) =>
setBasket((current) => {
const next = { ...current, [name]: Math.max(0, (current[name] ?? 0) + delta) }
if (next[name] === 0) delete next[name]
return next
})
return (
<TemplateShell def={cafe} content={content} fonts={fonts[content.fonts] ?? fonts.rund}>
<Nav name={brand.name} count={count} onBasket={() => setOpen(true)} />
<section id="top" className="relative overflow-hidden bg-t-surface pt-32 md:pt-40">
<div className={cn(container, "grid items-center gap-14 pb-20 lg:grid-cols-[1.1fr_1fr]")}>
<div>
<RevealWords as="h1" onMount delay={0.15} text={hero.title} emClassName={em} className={cn(heading, "text-[clamp(3rem,7vw,6rem)] leading-[0.98]")} />
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.6 }}>
<p className="mt-6 max-w-md text-lg leading-relaxed text-t-muted">{hero.text}</p>
<a href="#auslage" className="mt-8 inline-flex items-center gap-2 rounded-full bg-t-accent px-7 py-4 font-bold text-t-accent-fg shadow-[0_8px_0_0] shadow-t-fg/20 transition-transform hover:-translate-y-1">
{hero.cta} <ArrowRightIcon className="size-4" />
</a>
</motion.div>
</div>
<div className="relative mx-auto aspect-square w-full max-w-[520px]">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 1, ease }}
className="absolute inset-[8%] overflow-hidden rounded-[45%_55%_52%_48%/48%_45%_55%_52%] border-8 border-t-bg shadow-2xl"
>
<TImage src={hero.image} eager sizes="(min-width: 1024px) 40vw, 90vw" />
</motion.div>
{[
{ src: hero.imageSecondary, className: "top-0 right-0 size-[34%]", delay: 0.5 },
{ src: hero.imageThird, className: "bottom-2 left-0 size-[30%]", delay: 0.7 },
].map((photo, i) => (
<motion.div
key={i}
initial={{ scale: 0 }}
animate={{ scale: 1, y: [0, -10, 0] }}
transition={{ scale: { type: "spring", bounce: 0.4, delay: photo.delay }, y: { duration: 5 + i, repeat: Infinity, ease: "easeInOut" } }}
className={cn("absolute overflow-hidden rounded-full border-[6px] border-t-bg shadow-xl", photo.className)}
>
<TImage src={photo.src} sizes="200px" />
</motion.div>
))}
<Sticker className="bottom-[18%] -right-2 bg-t-accent text-t-accent-fg" rotate={8}>{hero.sticker}</Sticker>
<Sticker className="top-[10%] -left-4">{hero.stickerSecondary}</Sticker>
</div>
</div>
<Wave className="text-t-bg" />
</section>
{show("fresh") && (
<section data-order="fresh" className={cn(container, "py-16 md:py-20")}>
<Reveal>
<div className="mx-auto max-w-3xl -rotate-1 rounded-[2rem] border-[10px] border-[#7a5a3c] bg-[#22302a] p-8 text-[#f5efe3] shadow-2xl md:p-12">
<p className="text-center text-5xl" style={script}>{fresh.title}</p>
<ul className="mt-8 space-y-3 text-lg">
{fresh.items.map((item, i) => {
const soldOut = item.status.toLowerCase().includes("aus")
return (
<li key={i} className={cn("flex items-baseline gap-3", soldOut && "opacity-50")}>
<span className={cn(soldOut && "line-through")}>{item.name}</span>
{item.status && <span className={cn("rounded-full px-2 py-0.5 text-xs font-bold", soldOut ? "bg-white/15" : "bg-t-accent text-t-accent-fg")}>{item.status}</span>}
<span className="flex-1 border-b-2 border-dotted border-white/25" />
<span>{item.price} €</span>
</li>
)
})}
</ul>
<p className="mt-8 text-center text-sm opacity-60">{fresh.note}</p>
</div>
</Reveal>
</section>
)}
<Products products={products} basket={basket} onChange={change} />
{show("story") && (
<section data-order="story" className="relative bg-t-surface">
<Wave flip className="text-t-bg" />
<div className={cn(container, "grid items-center gap-14 py-20 md:grid-cols-2")}>
<div className="relative mx-auto aspect-[4/5] w-full max-w-md">
<Reveal className="absolute inset-0 rotate-[-4deg] overflow-hidden rounded-3xl border-8 border-t-bg shadow-xl">
<TImage src={story.image} sizes="(min-width: 768px) 40vw, 90vw" />
</Reveal>
<Reveal delay={0.2} className="absolute -right-4 -bottom-8 w-1/2 rotate-[6deg] overflow-hidden rounded-3xl border-8 border-t-bg shadow-xl">
<div className="relative aspect-square">
<TImage src={story.imageSecondary} sizes="200px" />
</div>
</Reveal>
</div>
<div>
<Reveal><p className="mb-4 font-bold text-t-accent">{story.eyebrow}</p></Reveal>
<RevealWords text={story.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4rem)] leading-[1.02]")} />
<Reveal delay={0.1}><p className="mt-6 text-lg leading-relaxed text-t-muted">{story.text}</p></Reveal>
</div>
</div>
<Wave className="text-t-bg" />
</section>
)}
{show("breakfast") && (
<section data-order="breakfast" className={cn(container, "py-20 md:py-28")}>
<RevealWords text={breakfast.title} emClassName={em} className={cn(heading, "text-center text-[clamp(2.5rem,5vw,4rem)]")} />
<div className="mx-auto mt-12 grid max-w-4xl gap-4 md:grid-cols-2">
{breakfast.items.map((item, i) => (
<Reveal key={i} delay={(i % 2) * 0.08}>
<div className="flex h-full gap-4 rounded-3xl border-2 border-t-fg/10 p-6 transition-colors hover:border-t-accent">
<div className="flex-1">
<p className={cn(heading, "text-xl")}>{item.name}</p>
<p className="mt-1 text-sm text-t-muted">{item.text}</p>
</div>
<p className={cn(heading, "text-xl text-t-accent")}>{item.price} €</p>
</div>
</Reveal>
))}
</div>
</section>
)}
{show("social") && (
<section data-order="social" className="pb-20">
<p className={cn(heading, "mb-6 text-center text-2xl")}>Folge uns: <span className="text-t-accent">{social.handle}</span></p>
<div className="grid grid-cols-3 md:grid-cols-6">
{social.images.map((image, i) => (
<div key={i} className="group relative aspect-square overflow-hidden">
<TImage src={image.src} sizes="(min-width: 768px) 17vw, 33vw" className="transition-transform duration-500 group-hover:scale-110" />
</div>
))}
</div>
</section>
)}
<section id="besuch" className={cn(container, "pb-24")}>
<div className="grid gap-8 rounded-[2.5rem] bg-t-accent p-8 text-t-accent-fg md:grid-cols-2 md:p-14">
<div>
<RevealWords text={visit.title} emClassName="underline decoration-wavy underline-offset-8" className={cn(heading, "text-[clamp(2.5rem,5vw,4rem)]")} />
<p className="mt-4 text-lg opacity-85">{visit.text}</p>
<p className="mt-8 flex items-center gap-2 font-bold"><MapPinIcon className="size-5" /> {brand.address}</p>
</div>
<dl className="self-center rounded-3xl bg-t-bg p-6 text-t-fg">
{visit.hours.map((row, i) => (
<div key={i} className="flex justify-between border-b border-t-fg/10 py-3 last:border-0">
<dt>{row.days}</dt>
<dd className="font-bold">{row.time}</dd>
</div>
))}
</dl>
</div>
</section>
<footer className={cn(container, "flex flex-col items-center gap-3 pb-10 text-center text-sm text-t-muted")}>
<p className="text-3xl text-t-fg" style={script}>{brand.name}</p>
<p>{brand.tagline} · {brand.phone}</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>
</footer>
<BasketDrawer open={open} basket={basket} items={products.items} onChange={change} onClose={() => setOpen(false)} onDone={() => setBasket({})} />
</TemplateShell>
)
}
function Nav({ name, count, onBasket }: { name: string; count: number; onBasket: () => void }) {
const { scrolled, hidden } = useScrollNav()
return (
<motion.header animate={{ y: hidden ? "-130%" : "0%" }} transition={{ duration: 0.45, ease }} className="fixed inset-x-0 top-0 z-50 px-3 pt-3">
<nav className={cn("mx-auto flex h-16 max-w-[1240px] items-center justify-between rounded-full px-5 transition-all duration-500", scrolled && "bg-t-bg/90 shadow-lg backdrop-blur-xl")}>
<a href="#top" className="text-2xl text-t-accent" style={script}>{name}</a>
<div className="flex items-center gap-2">
<a href="#besuch" className="hidden rounded-full px-4 py-2 font-semibold hover:bg-t-fg/5 sm:block">Öffnungszeiten</a>
<button type="button" onClick={onBasket} aria-label={`Tüte mit ${count} Artikeln`} className="relative grid size-11 cursor-pointer place-items-center rounded-full bg-t-fg text-t-bg">
<ShoppingBagIcon className="size-5" />
<AnimatePresence>
{count > 0 && (
<motion.span key={count} initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className="absolute -top-1 -right-1 grid size-5 place-items-center rounded-full bg-t-accent text-[11px] font-bold text-t-accent-fg">
{count}
</motion.span>
)}
</AnimatePresence>
</button>
</div>
</nav>
</motion.header>
)
}
function Products({ products, basket, onChange }: { products: CafeData["products"]; basket: Basket; onChange: (name: string, delta: number) => void }) {
const categories = [ALL, ...new Set(products.items.map((item) => item.category))]
const [filter, setFilter] = useState(ALL)
const visible = products.items.filter((item) => filter === ALL || item.category === filter)
return (
<section id="auslage" className={cn(container, "py-16 md:py-24")}>
<div className="text-center">
<Reveal><p className="mb-3 font-bold text-t-accent">{products.eyebrow}</p></Reveal>
<RevealWords text={products.title} emClassName={em} className={cn(heading, "text-[clamp(2.5rem,5vw,4rem)]")} />
<div className="mt-8 flex flex-wrap justify-center gap-2">
{categories.map((category) => (
<button
key={category}
type="button"
aria-pressed={filter === category}
onClick={() => setFilter(category)}
className={cn("cursor-pointer rounded-full px-5 py-2.5 font-bold transition-all", filter === category ? "bg-t-fg text-t-bg" : "bg-t-surface hover:-translate-y-0.5")}
>
{category}
</button>
))}
</div>
</div>
<motion.div layout className="mt-12 grid grid-cols-2 gap-4 md:gap-6 lg:grid-cols-4">
<AnimatePresence mode="popLayout">
{visible.map((item, i) => (
<motion.article
layout
key={item.name}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
whileHover={{ rotate: i % 2 ? 1.5 : -1.5, y: -6 }}
transition={{ type: "spring", stiffness: 260, damping: 20 }}
className="flex flex-col overflow-hidden rounded-[1.75rem] bg-t-surface"
>
<div className="relative aspect-square">
<TImage src={item.image} alt={item.name} sizes="(min-width: 1024px) 25vw, 50vw" />
<span className="absolute top-3 left-3 rounded-full bg-t-bg px-3 py-1 text-xs font-bold">{item.category}</span>
</div>
<div className="flex flex-1 flex-col p-4 md:p-5">
<h3 className={cn(heading, "text-lg md:text-xl")}>{item.name}</h3>
<p className="mt-1 flex-1 text-sm text-t-muted">{item.text}</p>
<div className="mt-4 flex items-center justify-between">
<span className={cn(heading, "text-xl")}>{item.price} €</span>
<button
type="button"
aria-label={`${item.name} in die Tüte`}
onClick={() => onChange(item.name, 1)}
className="flex h-10 min-w-10 cursor-pointer items-center justify-center gap-1 rounded-full bg-t-accent px-3 font-bold text-t-accent-fg transition-transform active:scale-90"
>
<PlusIcon className="size-4" />
{basket[item.name] ? basket[item.name] : null}
</button>
</div>
</div>
</motion.article>
))}
</AnimatePresence>
</motion.div>
</section>
)
}
function BasketDrawer({ open, basket, items, onChange, onClose, onDone }: {
open: boolean
basket: Basket
items: CafeData["products"]["items"]
onChange: (name: string, delta: number) => void
onClose: () => void
onDone: () => void
}) {
const [sent, setSent] = useState(false)
const lines = items.filter((item) => basket[item.name])
const total = lines.reduce((sum, item) => sum + price(item.price) * basket[item.name], 0)
const close = () => {
setSent(false)
onClose()
}
return (
<AnimatePresence>
{open && (
<>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={close} className="fixed inset-0 z-[80] bg-black/40" />
<motion.aside
role="dialog"
aria-label="Deine Tüte"
initial={{ y: "100%" }}
animate={{ y: "0%" }}
exit={{ y: "100%" }}
transition={{ duration: 0.45, ease }}
className="fixed inset-x-0 bottom-0 z-[90] mx-auto max-h-[85vh] max-w-lg overflow-y-auto rounded-t-[2rem] bg-t-bg p-6 text-t-fg shadow-2xl"
>
<div className="flex items-center justify-between">
<p className={cn(heading, "text-2xl")}>Deine Tüte</p>
<button type="button" aria-label="Schließen" onClick={close} className="grid size-10 cursor-pointer place-items-center rounded-full bg-t-surface"><XIcon className="size-5" /></button>
</div>
{sent ? (
<div className="py-10 text-center">
<p className="text-4xl text-t-accent" style={script}>Bis gleich!</p>
<p className="mt-3 text-t-muted">Deine Bestellung liegt zur Abholung bereit.</p>
<DemoNote className="mt-4 text-sm opacity-60">Demo-Hinweis: Live kommt die Vorbestellung direkt in der Backstube an.</DemoNote>
</div>
) : lines.length === 0 ? (
<p className="py-10 text-center text-t-muted">Noch leer – such dir was Leckeres aus!</p>
) : (
<form
className="mt-6 grid gap-4"
onSubmit={(event) => {
event.preventDefault()
setSent(true)
onDone()
}}
>
<ul className="divide-y divide-t-fg/10">
{lines.map((item) => (
<li key={item.name} className="flex items-center gap-3 py-3">
<span className="flex-1 font-semibold">{item.name}</span>
<button type="button" aria-label="Weniger" onClick={() => onChange(item.name, -1)} className="grid size-8 cursor-pointer place-items-center rounded-full bg-t-surface"><MinusIcon className="size-4" /></button>
<span className="w-5 text-center font-bold">{basket[item.name]}</span>
<button type="button" aria-label="Mehr" onClick={() => onChange(item.name, 1)} className="grid size-8 cursor-pointer place-items-center rounded-full bg-t-surface"><PlusIcon className="size-4" /></button>
<span className="w-20 text-right">{euro.format(price(item.price) * basket[item.name])}</span>
</li>
))}
</ul>
<p className="flex justify-between text-lg font-bold">Summe <span>{euro.format(total)}</span></p>
<TSelect label="Abholung" variant="soft" options={["Heute, so schnell wie möglich", "Heute Nachmittag", "Morgen früh ab 7 Uhr"]} />
<TField label="Dein Name" name="name" required variant="soft" />
<button type="submit" className="cursor-pointer rounded-full bg-t-accent py-4 font-bold text-t-accent-fg">Verbindlich vorbestellen</button>
</form>
)}
</motion.aside>
</>
)}
</AnimatePresence>
)
}