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