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

313 lines
20 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, CalendarIcon, ClockIcon, MapPinIcon, TrophyIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { DemoForm } from "../shared/demo-form"
import { TField, TSelect } from "../shared/fields"
import { useCountdown } from "../shared/hooks"
import { TImage } from "../shared/media"
import { Marquee, Reveal, RevealWords, ease, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { verein, type VereinData } from "./definition"
import { fonts } from "./fonts"
const em = "text-t-accent"
const container = "mx-auto w-full max-w-[1320px] px-5 md:px-8"
const display = "font-t-display uppercase [line-height:0.9]"
const stripes = "bg-[repeating-linear-gradient(-45deg,transparent_0,transparent_14px,color-mix(in_oklab,var(--t-fg)_5%,transparent)_14px,color-mix(in_oklab,var(--t-fg)_5%,transparent)_28px)]"
const ALL = "Alle"
function Crest({ short, className, muted }: { short: string; className?: string; muted?: boolean }) {
return (
<svg viewBox="0 0 64 74" aria-hidden className={className}>
<path d="M32 2 L60 10 V36 C60 54 47 66 32 72 C17 66 4 54 4 36 V10 Z" className={muted ? "fill-t-fg/15" : "fill-t-accent"} />
<path d="M32 8 L54 14 V36 C54 50 44 60 32 65 C20 60 10 50 10 36 V14 Z" fill="none" className={muted ? "stroke-t-fg/30" : "stroke-t-accent-fg/60"} strokeWidth="2" />
<text x="32" y="44" textAnchor="middle" className={cn("font-t-display font-bold", muted ? "fill-t-fg" : "fill-t-accent-fg")} style={{ fontSize: short.length > 3 ? 14 : 18 }}>
{short}
</text>
</svg>
)
}
function Countdown({ date }: { date: string }) {
const time = useCountdown(date)
const parts: [number | undefined, string][] = [
[time?.days, "Tage"],
[time?.hours, "Std"],
[time?.minutes, "Min"],
[time?.seconds, "Sek"],
]
return (
<div className="grid grid-cols-4 gap-2">
{parts.map(([value, label]) => (
<div key={label} className="rounded-xl bg-black/50 py-2 text-center backdrop-blur">
<p className={cn(display, "text-4xl tabular-nums")}>{value === undefined ? "--" : String(value).padStart(2, "0")}</p>
<p className="text-[10px] uppercase tracking-widest opacity-70">{label}</p>
</div>
))}
</div>
)
}
export function VereinTemplate({ content }: TemplateProps<VereinData>) {
const { brand, hero, match, results, table, teams, news, sponsors, membership } = content.data
const show = (key: string) => !content.hidden.includes(key)
return (
<TemplateShell def={verein} content={content} fonts={fonts[content.fonts] ?? fonts.stadion}>
<Nav brand={brand} cta={hero.cta} show={show} />
<section id="top" className={cn("relative overflow-hidden pt-28 pb-16 md:pt-36", stripes)}>
<div className={cn(container, "grid items-center gap-12 lg:grid-cols-[1fr_1.05fr]")}>
<div>
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.2 }} className="mb-5 flex items-center gap-2 font-semibold uppercase tracking-widest text-t-accent">
<TrophyIcon className="size-4" /> Seit {brand.founded} · {brand.city}
</motion.p>
<RevealWords as="h1" onMount delay={0.2} text={hero.title} emClassName={em} className={cn(display, "text-[clamp(3.75rem,10vw,9rem)]")} />
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.7, ease, delay: 0.7 }}>
<p className="mt-6 max-w-md text-lg text-t-muted">{hero.text}</p>
<div className="mt-8 flex flex-wrap gap-3">
<a href="#mitglied" className="inline-flex items-center gap-2 bg-t-accent px-7 py-4 font-bold text-t-accent-fg uppercase">{hero.cta} <ArrowRightIcon className="size-4" /></a>
{show("teams") && <a href="#teams" className="border-2 border-t-fg px-7 py-4 font-bold uppercase">Probetraining</a>}
</div>
</motion.div>
</div>
<motion.div initial={{ opacity: 0, y: 40, rotate: 2 }} animate={{ opacity: 1, y: 0, rotate: 0 }} transition={{ duration: 1, ease, delay: 0.3 }} className="relative overflow-hidden rounded-3xl text-white shadow-2xl">
<TImage src={match.image} eager sizes="(min-width: 1024px) 50vw, 100vw" />
<div className="absolute inset-0 bg-gradient-to-b from-black/50 via-black/60 to-black/85" />
<div className="relative p-6 md:p-8">
<div className="flex items-center justify-between text-sm font-semibold uppercase tracking-wider">
<span className="rounded-full bg-t-accent px-3 py-1 text-t-accent-fg">{match.label}</span>
<span className="opacity-80">{match.league}</span>
</div>
<div className="mt-8 grid grid-cols-[1fr_auto_1fr] items-center gap-4 text-center">
<div>
<Crest short={brand.short} className="mx-auto h-24 w-20" />
<p className={cn(display, "mt-3 text-2xl")}>{brand.name}</p>
</div>
<span className={cn(display, "text-5xl opacity-80")}>vs</span>
<div>
<svg viewBox="0 0 64 74" aria-hidden className="mx-auto h-24 w-20">
<path d="M32 2 L60 10 V36 C60 54 47 66 32 72 C17 66 4 54 4 36 V10 Z" className="fill-white/20" />
<text x="32" y="44" textAnchor="middle" className="fill-white font-t-display font-bold" style={{ fontSize: 18 }}>{match.awayShort}</text>
</svg>
<p className={cn(display, "mt-3 text-2xl")}>{match.away}</p>
</div>
</div>
<div className="mt-8"><Countdown date={match.date} /></div>
<div className="mt-6 flex flex-wrap gap-x-6 gap-y-2 text-sm opacity-90">
<span className="flex items-center gap-2"><CalendarIcon className="size-4" /> {match.dateLabel}</span>
<span className="flex items-center gap-2"><MapPinIcon className="size-4" /> {match.venue}</span>
</div>
</div>
</motion.div>
</div>
</section>
{show("results") && (
<section data-order="results" className={cn(container, "py-20")}>
<RevealWords text={results.title} emClassName={em} className={cn(display, "text-[clamp(2.75rem,6vw,5rem)]")} />
<div className="mt-8 grid gap-3 md:grid-cols-2">
{results.items.map((game, i) => {
const [home, away] = game.score.split(":").map((v) => Number(v))
const ours = game.home === brand.name ? home - away : away - home
const outcome = Number.isNaN(ours) ? "" : ours > 0 ? "S" : ours < 0 ? "N" : "U"
return (
<Reveal key={i} delay={(i % 2) * 0.06}>
<div className="flex items-center gap-4 rounded-2xl bg-t-surface p-4">
<span className={cn("grid size-9 shrink-0 place-items-center rounded-lg font-bold", outcome === "S" ? "bg-emerald-600 text-white" : outcome === "N" ? "bg-t-accent text-t-accent-fg" : "bg-t-fg/15")}>{outcome}</span>
<span className="w-14 shrink-0 text-sm text-t-muted">{game.date}</span>
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{game.home} – {game.away}</span>
<span className={cn(display, "text-3xl tabular-nums")}>{game.score}</span>
</div>
</Reveal>
)
})}
</div>
</section>
)}
{show("table") && (
<section data-order="table" className={cn(container, "pb-20")}>
<h2 className={cn(display, "text-[clamp(2.75rem,6vw,5rem)]")}>{table.title}</h2>
<div className="mt-8 overflow-hidden rounded-2xl border border-t-fg/10">
<table className="w-full text-left">
<thead className="bg-t-fg text-sm text-t-bg uppercase">
<tr>
<th className="px-4 py-3">Pl.</th>
<th className="px-4 py-3">Verein</th>
<th className="px-4 py-3 text-right">Sp.</th>
<th className="px-4 py-3 text-right">Pkt.</th>
</tr>
</thead>
<tbody>
{table.items.map((row, i) => {
const ours = row.team === brand.name
return (
<motion.tr key={i} initial={{ opacity: 0, x: -10 }} whileInView={{ opacity: 1, x: 0 }} viewport={{ once: true }} transition={{ delay: i * 0.05 }} className={cn("border-t border-t-fg/10", ours && "bg-t-accent font-bold text-t-accent-fg")}>
<td className={cn(display, "px-4 py-3 text-2xl")}>{i + 1}</td>
<td className="px-4 py-3">{row.team}</td>
<td className="px-4 py-3 text-right">{row.games}</td>
<td className={cn(display, "px-4 py-3 text-right text-2xl")}>{row.points}</td>
</motion.tr>
)
})}
</tbody>
</table>
</div>
</section>
)}
{show("teams") && <Teams data-order="teams" teams={teams} />}
{show("news") && (
<section data-order="news" className={cn(container, "py-20")}>
<RevealWords text={news.title} className={cn(display, "text-[clamp(2.75rem,6vw,5rem)]")} />
<div className="mt-8 grid gap-5 md:grid-cols-3">
{news.items.map((post, i) => (
<Reveal key={i} delay={i * 0.08}>
<article className="group h-full overflow-hidden rounded-2xl bg-t-surface">
<div className="relative aspect-[16/10] overflow-hidden">
<TImage src={post.image} alt={post.title} sizes="(min-width: 768px) 33vw, 100vw" className="transition-transform duration-700 group-hover:scale-105" />
</div>
<div className="p-5">
<p className="text-sm text-t-muted">{post.date}</p>
<h3 className={cn(display, "mt-1 text-3xl")}>{post.title}</h3>
<p className="mt-2 text-sm text-t-muted">{post.text}</p>
</div>
</article>
</Reveal>
))}
</div>
</section>
)}
{show("sponsors") && (
<section data-order="sponsors" className="border-y border-t-fg/10 py-8">
<p className="mb-4 text-center text-sm font-semibold uppercase tracking-widest text-t-muted">{sponsors.title}</p>
<Marquee duration={28}>
{sponsors.items.map((sponsor, i) => (
<span key={i} className={cn(display, "px-10 text-4xl text-t-fg/45")}>{sponsor.name}</span>
))}
</Marquee>
</section>
)}
<section id="mitglied" className={cn("py-20 md:py-28", stripes)}>
<div className={cn(container, "grid gap-12 lg:grid-cols-2")}>
<div>
<RevealWords text={membership.title} emClassName={em} className={cn(display, "text-[clamp(3rem,7vw,6rem)]")} />
<p className="mt-5 max-w-md text-lg text-t-muted">{membership.text}</p>
<div className="mt-8 grid grid-cols-2 gap-3">
{membership.items.map((tier, i) => (
<div key={i} className={cn("rounded-2xl p-5", tier.highlight.trim() ? "bg-t-accent text-t-accent-fg" : "bg-t-surface")}>
<p className="font-bold">{tier.name}</p>
<p className={cn(display, "mt-2 text-5xl")}>{tier.price} €</p>
<p className="text-sm opacity-75">pro Monat · {tier.text}</p>
</div>
))}
</div>
</div>
<Reveal delay={0.1}>
<DemoForm
className="grid gap-4 rounded-3xl bg-t-bg p-6 shadow-xl sm:grid-cols-2 md:p-8"
success={<p className={cn(display, "py-10 text-5xl")}>Willkommen <span className={em}>im Team!</span></p>}
>
<p className={cn(display, "text-4xl sm:col-span-2")}>Mitgliedsantrag</p>
<TField label="Vor- und Nachname" name="name" required autoComplete="name" />
<TField label="Geburtsdatum" name="birthday" type="date" required />
<TSelect label="Beitrag" name="tier" options={membership.items.map((t) => t.name)} />
<TSelect label="Abteilung" name="team" options={[...teams.items.map((t) => t.name), "Noch offen"]} />
<TField label="E-Mail" name="email" type="email" required autoComplete="email" className="sm:col-span-2" />
<button type="submit" className="cursor-pointer bg-t-accent py-4 font-bold text-t-accent-fg uppercase sm:col-span-2">Antrag absenden</button>
</DemoForm>
</Reveal>
</div>
</section>
<footer className="bg-t-fg text-t-bg">
<div className={cn(container, "flex flex-col items-center gap-4 py-10 text-sm md:flex-row md:justify-between")}>
<div className="flex items-center gap-3">
<Crest short={brand.short} className="h-10 w-9" />
<p>{brand.name} von {brand.founded} e. V. · {brand.email}</p>
</div>
<div className="flex gap-6 opacity-70">
<a href="#">Impressum</a>
<a href="#">Datenschutz</a>
<a href="#">Satzung</a>
</div>
</div>
</footer>
</TemplateShell>
)
}
function Nav({ brand, cta, show }: { brand: VereinData["brand"]; cta: string; show: (key: string) => boolean }) {
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 transition-colors duration-500", scrolled && "bg-t-bg/90 shadow-sm backdrop-blur-xl")}
>
<nav className={cn(container, "flex h-20 items-center justify-between gap-4")}>
<a href="#top" className="flex items-center gap-3">
<Crest short={brand.short} className="h-12 w-11" />
<span className="leading-none">
<span className={cn(display, "block text-3xl")}>{brand.name}</span>
<span className="text-xs font-semibold uppercase tracking-widest text-t-muted">seit {brand.founded}</span>
</span>
</a>
<div className="hidden gap-7 font-semibold uppercase md:flex">
{show("results") && <a href="#top" className="hover:text-t-accent">Spiele</a>}
{show("teams") && <a href="#teams" className="hover:text-t-accent">Teams</a>}
<a href="#mitglied" className="hover:text-t-accent">Mitglied werden</a>
</div>
<a href="#mitglied" className="bg-t-accent px-5 py-2.5 text-sm font-bold text-t-accent-fg uppercase">{cta}</a>
</nav>
</motion.header>
)
}
function Teams({ teams }: { teams: VereinData["teams"] }) {
const groups = [ALL, ...new Set(teams.items.map((team) => team.group))]
const [group, setGroup] = useState(ALL)
const visible = teams.items.filter((team) => group === ALL || team.group === group)
return (
<section id="teams" className="bg-t-surface py-20 md:py-28">
<div className={container}>
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
<RevealWords text={teams.title} emClassName={em} className={cn(display, "text-[clamp(2.75rem,6vw,5rem)]")} />
<div className="flex gap-2">
{groups.map((option) => (
<button key={option} type="button" aria-pressed={group === option} onClick={() => setGroup(option)} className={cn("cursor-pointer px-5 py-2.5 font-bold uppercase transition-colors", group === option ? "bg-t-accent text-t-accent-fg" : "bg-t-bg hover:bg-t-fg/10")}>
{option}
</button>
))}
</div>
</div>
<motion.div layout className="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
<AnimatePresence mode="popLayout">
{visible.map((team) => (
<motion.article layout key={team.name} initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} className="group relative aspect-[4/3] overflow-hidden rounded-2xl text-white">
<TImage src={team.image} alt={team.name} sizes="(min-width: 1024px) 33vw, 50vw" className="transition-transform duration-700 group-hover:scale-105" />
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-5">
<p className="text-xs font-semibold uppercase tracking-widest opacity-80">{team.group}</p>
<h3 className={cn(display, "text-4xl")}>{team.name}</h3>
<p className="mt-1 flex items-center gap-2 text-sm"><ClockIcon className="size-4" /> {team.training}</p>
</div>
</motion.article>
))}
</AnimatePresence>
</motion.div>
</div>
</section>
)
}