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