since3-portal/modules/templates/catalog/event/template.tsx
2026-10-05 08:23:02 +02:00

363 lines
21 KiB
TypeScript

"use client"
import { AnimatePresence, motion } from "motion/react"
import { Fragment, useState } from "react"
import { ArrowUpRightIcon, CalendarIcon, CheckIcon, MapPinIcon, TicketIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { splitList, useCountdown } from "../shared/hooks"
import { TImage } from "../shared/media"
import { Accordion, Marquee, ParallaxImage, Reveal, RevealWords, ease, parseRich, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { event, type EventData } from "./definition"
import { fonts } from "./fonts"
const em = "text-t-accent"
const container = "mx-auto w-full max-w-[1400px] px-5 md:px-10"
// Zeilenhöhe als Arbitrary Property: tailwind-merge würde leading-* neben text-[…] verwerfen
const display = "font-t-display uppercase [line-height:0.85] tracking-[-0.01em]"
const label = "text-xs uppercase tracking-[0.2em]"
function Eyebrow({ children }: { children: string }) {
return <p className={cn(label, "mb-5 text-t-accent")}>[ {children} ]</p>
}
// Buchstaben klappen nacheinander auf; der Startindex jedes Worts bestimmt die Verzögerung
function KineticTitle({ text }: { text: string }) {
const lines: { word: string; em: boolean; start: number }[][] = []
let count = 0
for (const tokens of parseRich(text)) {
const line = []
for (const token of tokens) {
line.push({ ...token, start: count })
count += token.word.length
}
lines.push(line)
}
return (
<h1 className={cn(display, "text-[clamp(5rem,19vw,20rem)] [perspective:800px]")} aria-label={text.replace(/\*/g, "").replace(/\n/g, " ")}>
{lines.map((tokens, l) => (
<span key={l} aria-hidden className="block">
{tokens.map((token, t) => (
<Fragment key={t}>
{t > 0 && " "}
<span className={cn("inline-block whitespace-nowrap", token.em && em)}>
{[...token.word].map((char, c) => (
<motion.span
key={c}
className="inline-block origin-bottom"
initial={{ opacity: 0, rotateX: -90, y: "30%" }}
animate={{ opacity: 1, rotateX: 0, y: "0%" }}
transition={{ duration: 0.9, ease, delay: 0.3 + (token.start + c) * 0.06 }}
>
{char}
</motion.span>
))}
</span>
</Fragment>
))}
</span>
))}
</h1>
)
}
function Digit({ value }: { value: string }) {
return (
<span className="relative inline-flex h-[1em] w-[0.6em] justify-center overflow-hidden">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={value} initial={{ y: "-100%" }} animate={{ y: "0%" }} exit={{ y: "100%" }} transition={{ duration: 0.35, ease }} className="absolute">
{value}
</motion.span>
</AnimatePresence>
</span>
)
}
function Countdown({ date, className }: { date: string; className?: string }) {
const time = useCountdown(date)
const parts = [
{ value: time?.days, label: "Tage" },
{ value: time?.hours, label: "Stunden" },
{ value: time?.minutes, label: "Minuten" },
{ value: time?.seconds, label: "Sekunden" },
]
return (
<div className={cn("flex gap-5 md:gap-10", className)}>
{parts.map((part) => (
<div key={part.label}>
<p className={cn(display, "flex text-5xl tabular-nums md:text-7xl")}>
{(part.value === undefined ? "--" : String(part.value).padStart(2, "0")).split("").map((char, i) => (
<Digit key={i} value={char} />
))}
</p>
<p className={cn(label, "mt-2 text-t-muted")}>{part.label}</p>
</div>
))}
</div>
)
}
export function EventTemplate({ content }: TemplateProps<EventData>) {
const { brand, hero, marquee, speakers, agenda, tickets, venue, partners, faq } = content.data
const show = (key: string) => !content.hidden.includes(key)
const links = [
{ href: "#speaker", label: "Speaker", visible: show("speakers") },
{ href: "#programm", label: "Programm", visible: show("agenda") },
{ href: "#tickets", label: "Tickets", visible: true },
{ href: "#location", label: "Location", visible: show("venue") },
].filter((link) => link.visible)
return (
<TemplateShell def={event} content={content} fonts={fonts[content.fonts] ?? fonts.techno}>
<Nav brand={brand} hero={hero} links={links} />
<section id="top" className="relative overflow-hidden pt-32 pb-16 md:pt-40">
<div className="absolute inset-0 opacity-25">
<TImage src={hero.image} eager className="grayscale" />
</div>
<div className="absolute inset-0 bg-gradient-to-b from-t-bg/60 via-t-bg/80 to-t-bg" />
<motion.div
aria-hidden
className="absolute top-1/4 -left-40 size-[36rem] rounded-full bg-t-accent/30 blur-[120px]"
animate={{ x: [0, 120, 0], y: [0, 60, 0] }}
transition={{ duration: 14, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.div
aria-hidden
className="absolute -right-40 bottom-0 size-[30rem] rounded-full bg-t-accent/20 blur-[120px]"
animate={{ x: [0, -100, 0], y: [0, -80, 0] }}
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
/>
<div className={cn(container, "relative")}>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.2 }} className={cn(label, "flex flex-wrap gap-x-8 gap-y-2 text-t-muted")}>
<span className="text-t-accent">● {hero.eyebrow}</span>
<span className="flex items-center gap-2"><CalendarIcon className="size-3.5" /> {hero.dateLabel}</span>
<span className="flex items-center gap-2"><MapPinIcon className="size-3.5" /> {hero.location}</span>
</motion.div>
<div className="mt-8">
<KineticTitle text={hero.title} />
</div>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 1.1 }} className="mt-12 grid gap-10 lg:grid-cols-[1fr_auto] lg:items-end">
<div>
<p className="max-w-xl text-lg leading-relaxed text-t-fg/80">{hero.text}</p>
<a href="#tickets" className="group mt-8 inline-flex items-center gap-3 bg-t-accent px-8 py-4 font-bold text-t-accent-fg uppercase transition-transform hover:-translate-y-1">
<TicketIcon className="size-5" /> {hero.cta}
<ArrowUpRightIcon className="size-5 transition-transform group-hover:rotate-45" />
</a>
</div>
<Countdown date={hero.date} />
</motion.div>
</div>
</section>
{show("marquee") && (
<section aria-label="Themen" className="space-y-3 py-12">
<div className="-rotate-1 bg-t-accent py-3 text-t-accent-fg">
<Marquee duration={25}>
{marquee.items.map((item, i) => (
<span key={i} className={cn(display, "px-6 text-4xl md:text-6xl")}>{item.text} ✦</span>
))}
</Marquee>
</div>
<div className="rotate-1 border-y border-t-fg/15 py-3">
<Marquee duration={30} reverse>
{marquee.items.map((item, i) => (
<span key={i} className={cn(display, "px-6 text-4xl text-transparent [-webkit-text-stroke:1px_var(--t-fg)] md:text-6xl")}>{item.text} ✦</span>
))}
</Marquee>
</div>
</section>
)}
{show("speakers") && (
<section id="speaker" className={cn(container, "py-24 md:py-32")}>
<Reveal><Eyebrow>{speakers.eyebrow}</Eyebrow></Reveal>
<RevealWords text={speakers.title} emClassName={em} className={cn(display, "text-[clamp(3rem,8vw,7.5rem)]")} />
<div className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{speakers.items.map((speaker, i) => (
<Reveal key={i} delay={(i % 3) * 0.08}>
<article className="group">
<div className="relative aspect-[4/5] overflow-hidden bg-t-surface">
<TImage src={speaker.image} alt={speaker.name} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" className="grayscale transition duration-700 group-hover:scale-105 group-hover:grayscale-0" />
<div className="absolute inset-0 bg-t-accent opacity-0 mix-blend-multiply transition-opacity duration-500 group-hover:opacity-40" />
<span className={cn(label, "absolute top-4 left-4 bg-t-bg/80 px-2 py-1 text-t-fg backdrop-blur")}>{String(i + 1).padStart(2, "0")}</span>
</div>
<h3 className={cn(display, "mt-4 text-4xl")}>{speaker.name}</h3>
<p className={cn(label, "mt-1 text-t-muted")}>{speaker.role} · {speaker.company}</p>
</article>
</Reveal>
))}
</div>
</section>
)}
{show("agenda") && <Agenda agenda={agenda} />}
<section id="tickets" className={cn(container, "py-24 md:py-32")}>
<Reveal><Eyebrow>{tickets.eyebrow}</Eyebrow></Reveal>
<RevealWords text={tickets.title} emClassName={em} className={cn(display, "text-[clamp(3rem,8vw,7.5rem)]")} />
<div className="mt-14 grid gap-4 lg:grid-cols-3">
{tickets.items.map((ticket, i) => {
const featured = Boolean(ticket.highlight.trim())
const left = Number(ticket.left)
return (
<Reveal key={i} delay={i * 0.1}>
<article className={cn("relative flex h-full flex-col border p-8", featured ? "border-t-accent bg-t-accent text-t-accent-fg" : "border-t-fg/15 bg-t-surface")}>
<p className={cn(label, featured ? "opacity-80" : "text-t-muted")}>{ticket.text}</p>
<h3 className={cn(display, "mt-3 text-5xl")}>{ticket.name}</h3>
<p className={cn(display, "mt-6 text-7xl")}>{ticket.price} €</p>
<ul className="mt-8 flex-1 space-y-2.5 text-sm">
{splitList(ticket.features, "\n").map((feature) => (
<li key={feature} className="flex items-start gap-2.5">
<CheckIcon className={cn("mt-0.5 size-4 shrink-0", !featured && "text-t-accent")} /> {feature}
</li>
))}
</ul>
{left > 0 && (
<div className="mt-8">
<p className={cn(label, "mb-2")}>Nur noch {left} verfügbar</p>
<div className={cn("h-1.5 overflow-hidden", featured ? "bg-t-accent-fg/20" : "bg-t-fg/10")}>
<motion.div
className={cn("h-full", featured ? "bg-t-accent-fg" : "bg-t-accent")}
initial={{ width: "0%" }}
whileInView={{ width: `${Math.max(8, Math.min(96, 100 - left))}%` }}
viewport={{ once: true }}
transition={{ duration: 1.2, ease }}
/>
</div>
</div>
)}
<a href="#" className={cn("mt-8 block py-4 text-center font-bold uppercase transition-opacity hover:opacity-85", featured ? "bg-t-bg text-t-fg" : "bg-t-fg text-t-bg")}>
Ticket kaufen
</a>
</article>
</Reveal>
)
})}
</div>
</section>
{show("venue") && (
<section id="location" className={cn(container, "grid items-center gap-10 pb-24 md:pb-32 lg:grid-cols-[1.3fr_1fr]")}>
<ParallaxImage src={venue.image} offset={50} className="aspect-[16/10]" sizes="(min-width: 1024px) 55vw, 100vw" />
<div>
<Reveal><Eyebrow>{venue.eyebrow}</Eyebrow></Reveal>
<RevealWords text={venue.title} emClassName={em} className={cn(display, "text-[clamp(3rem,7vw,6rem)]")} />
<Reveal delay={0.1}>
<p className="mt-6 text-lg leading-relaxed text-t-muted">{venue.text}</p>
<p className="mt-6 flex items-center gap-2 font-bold"><MapPinIcon className="size-4 text-t-accent" /> {venue.address}</p>
</Reveal>
</div>
</section>
)}
{show("partners") && (
<section className="border-y border-t-fg/10 py-12">
<div className={cn(container, "flex flex-col items-center gap-8 md:flex-row md:justify-between")}>
<p className={cn(label, "text-t-muted")}>{partners.title}</p>
<div className="flex flex-wrap justify-center gap-x-12 gap-y-4">
{partners.items.map((partner, i) => (
<span key={i} className={cn(display, "text-3xl text-t-fg/50")}>{partner.name}</span>
))}
</div>
</div>
</section>
)}
{show("faq") && (
<section className={cn(container, "max-w-4xl py-24")}>
<Accordion items={faq.items} className="divide-y divide-t-fg/15 border-y border-t-fg/15" questionClassName="py-6 text-lg font-bold" answerClassName="pb-6 leading-relaxed text-t-muted" />
</section>
)}
<footer className="overflow-hidden border-t border-t-fg/10 py-16">
<div className={cn(container, "text-center")}>
<p className={cn(display, "text-[clamp(3rem,10vw,9rem)]")}>Wir sehen <span className={em}>uns.</span></p>
<Countdown date={hero.date} className="mt-8 justify-center" />
<div className={cn(label, "mt-14 flex flex-wrap justify-center gap-6 text-t-muted")}>
<span>{brand.name} · {brand.suffix}</span>
<a href="#" className="hover:text-t-fg">Impressum</a>
<a href="#" className="hover:text-t-fg">Datenschutz</a>
</div>
</div>
</footer>
</TemplateShell>
)
}
function Nav({ brand, hero, links }: { brand: EventData["brand"]; hero: EventData["hero"]; 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/85 backdrop-blur-xl" : "border-transparent")}
>
<nav className={cn(container, "flex h-20 items-center justify-between gap-6")}>
<a href="#top" className={cn(display, "text-3xl")}>{brand.name}</a>
<div className={cn(label, "hidden items-center gap-8 md:flex")}>
{links.map((link) => (
<a key={link.href} href={link.href} className="text-t-fg/70 transition-colors hover:text-t-accent">{link.label}</a>
))}
</div>
<div className="flex items-center gap-3">
<span className={cn(label, "hidden border border-t-fg/20 px-3 py-2 lg:block")}>{hero.dateLabel}</span>
<a href="#tickets" className="bg-t-accent px-5 py-2.5 text-sm font-bold text-t-accent-fg uppercase">{hero.cta}</a>
</div>
</nav>
</motion.header>
)
}
function Agenda({ agenda }: { agenda: EventData["agenda"] }) {
const days = [...new Set(agenda.items.map((item) => item.day))]
const [day, setDay] = useState(days[0] ?? "")
const current = days.includes(day) ? day : days[0]
return (
<section id="programm" className="bg-t-surface py-24 md:py-32">
<div className={container}>
<div className="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
<div>
<Reveal><Eyebrow>{agenda.eyebrow}</Eyebrow></Reveal>
<RevealWords text={agenda.title} emClassName={em} className={cn(display, "text-[clamp(3rem,8vw,7.5rem)]")} />
</div>
<div role="tablist" className="flex gap-2">
{days.map((d) => (
<button
key={d}
type="button"
role="tab"
aria-selected={d === current}
onClick={() => setDay(d)}
className={cn("relative cursor-pointer px-6 py-3 font-bold uppercase", d === current ? "text-t-accent-fg" : "text-t-muted hover:text-t-fg")}
>
{d === current && <motion.span layoutId="event-day" className="absolute inset-0 bg-t-accent" transition={{ type: "spring", bounce: 0.2, duration: 0.5 }} />}
<span className="relative">{d}</span>
</button>
))}
</div>
</div>
<AnimatePresence mode="wait">
<motion.ol key={current} initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.35, ease }} className="relative mt-14 ml-3 border-l-2 border-t-fg/15">
{agenda.items.filter((item) => item.day === current).map((item, i) => (
<li key={`${item.time}-${i}`} className="relative grid gap-2 pb-10 pl-8 md:grid-cols-[8rem_1fr_auto] md:items-baseline md:gap-8">
<span className="absolute top-2 -left-[9px] size-4 rounded-full border-4 border-t-surface bg-t-accent" />
<span className={cn(display, "text-4xl text-t-accent")}>{item.time}</span>
<div>
<p className={cn(display, "text-3xl md:text-4xl")}>{item.title}</p>
{item.speaker && <p className="mt-1 text-t-muted">{item.speaker}</p>}
</div>
<span className={cn(label, "w-fit border border-t-fg/20 px-3 py-1.5")}>{item.room}</span>
</li>
))}
</motion.ol>
</AnimatePresence>
</div>
</section>
)
}