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