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

422 lines
25 KiB
TypeScript

"use client"
import { AnimatePresence, motion } from "motion/react"
import { useState } from "react"
import { ArrowUpRightIcon, CheckIcon, MoveHorizontalIcon } 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 { splitList } from "../shared/hooks"
import { TImage } from "../shared/media"
import { Counter, Marquee, Reveal, RevealWords, ease, useScrollNav, wordmarkSize } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { fitness, type FitnessData } from "./definition"
import { fonts } from "./fonts"
const em = "text-t-accent"
const container = "mx-auto w-full max-w-[1440px] px-5 md:px-10"
const display = "font-t-display uppercase tracking-[-0.02em]"
const DAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
function Eyebrow({ children }: { children: string }) {
return <p className="mb-5 text-xs font-semibold uppercase tracking-[0.3em] text-t-accent">— {children}</p>
}
function SkewButton({ href, children, ghost }: { href: string; children: string; ghost?: boolean }) {
return (
<a
href={href}
className={cn(
"group inline-flex -skew-x-12 items-center px-7 py-4 transition-transform hover:-translate-y-0.5",
ghost ? "ring-1 ring-t-fg/30 hover:ring-t-accent" : "bg-t-accent text-t-accent-fg"
)}
>
<span className="flex skew-x-12 items-center gap-2 text-sm font-bold uppercase tracking-wider">
{children}
<ArrowUpRightIcon className="size-4 transition-transform group-hover:rotate-45" />
</span>
</a>
)
}
export function FitnessTemplate({ content }: TemplateProps<FitnessData>) {
const { brand, hero, stats, marquee, programs, classes, transformation, pricing, coaches, trial } = content.data
const show = (key: string) => !content.hidden.includes(key)
const links = [
{ href: "#training", label: "Training", visible: show("programs") },
{ href: "#kurse", label: "Kursplan", visible: show("classes") },
{ href: "#preise", label: "Preise", visible: show("pricing") },
{ href: "#team", label: "Coaches", visible: show("coaches") },
].filter((link) => link.visible)
return (
<TemplateShell def={fitness} content={content} fonts={fonts[content.fonts] ?? fonts.power}>
<Nav name={brand.name} cta={trial.cta} links={links} />
<section id="top" className="relative flex min-h-svh flex-col justify-end overflow-hidden">
<motion.div className="absolute inset-0" initial={{ scale: 1.2 }} animate={{ scale: 1 }} transition={{ duration: 2.2, ease }}>
<TImage src={hero.image} eager className="contrast-125 grayscale" />
</motion.div>
<div className="absolute inset-0 bg-t-accent/10 mix-blend-multiply" />
<div className="absolute inset-0 bg-gradient-to-t from-t-bg via-t-bg/50 to-t-bg/20" />
<div className={cn(container, "relative pt-36 pb-10")}>
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: 0.8, ease, delay: 0.2 }}>
<Eyebrow>{hero.eyebrow}</Eyebrow>
</motion.div>
<RevealWords
as="h1"
onMount
delay={0.3}
text={hero.title}
emClassName={em}
className={cn(display, "text-[clamp(3.2rem,10.5vw,11rem)] leading-[0.88] font-black")}
/>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease, delay: 0.9 }}
className="mt-10 flex flex-col gap-8 md:flex-row md:items-end md:justify-between"
>
<p className="max-w-md text-lg leading-relaxed text-t-fg/80">{hero.text}</p>
<div className="flex flex-wrap gap-4">
<SkewButton href="#probetraining">{hero.cta}</SkewButton>
{show("classes") && <SkewButton data-order="classes" href="#kurse" ghost>{hero.ctaSecondary}</SkewButton>}
</div>
</motion.div>
{show("stats") && (
<dl data-order="stats" className="mt-14 grid grid-cols-2 border-t border-t-fg/15 md:grid-cols-4">
{stats.items.map((stat, i) => (
<div key={i} className={cn("py-6 pr-4", i > 0 && "md:border-l md:border-t-fg/15 md:pl-8")}>
<dd className={cn(display, "text-4xl md:text-5xl")}>
<Counter value={stat.value} />
<span className="text-t-accent">{stat.suffix}</span>
</dd>
<dt className="mt-2 text-xs uppercase tracking-[0.2em] text-t-muted">{stat.label}</dt>
</div>
))}
</dl>
)}
</div>
</section>
{show("marquee") && (
<div data-order="marquee" className="-rotate-2 scale-105 bg-t-accent py-4 text-t-accent-fg">
<Marquee duration={18}>
{marquee.items.map((item, i) => (
<span key={i} className={cn(display, "flex items-center gap-8 px-4 text-3xl font-black md:text-5xl")}>
{item.text}
<span className="text-2xl">✱</span>
</span>
))}
</Marquee>
</div>
)}
{show("programs") && <Programs data-order="programs" programs={programs} />}
{show("classes") && <Schedule data-order="classes" classes={classes} />}
{show("transformation") && <Transformation data-order="transformation" data={transformation} />}
{show("pricing") && (
<section data-order="pricing" id="preise" className={cn(container, "py-24 md:py-36")}>
<Reveal><Eyebrow>{pricing.eyebrow}</Eyebrow></Reveal>
<RevealWords text={pricing.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,6vw,5.5rem)] leading-[0.92] font-black")} />
<div className="mt-14 grid gap-5 lg:grid-cols-3">
{pricing.plans.map((plan, i) => {
const featured = Boolean(plan.highlight.trim())
return (
<Reveal key={i} delay={i * 0.1}>
<article className={cn("relative flex h-full flex-col p-8 md:p-10", featured ? "bg-t-accent text-t-accent-fg" : "bg-t-surface")}>
{featured && <span className="absolute top-6 right-6 bg-t-bg px-3 py-1 text-xs font-bold uppercase tracking-wider text-t-fg">Beliebt</span>}
<h3 className={cn(display, "text-2xl")}>{plan.name}</h3>
<p className="mt-6 flex items-end gap-2">
<span className={cn(display, "text-6xl md:text-7xl")}>{plan.price}€</span>
<span className={cn("pb-2 text-sm", featured ? "opacity-70" : "text-t-muted")}>{plan.period}</span>
</p>
<ul className="mt-8 flex-1 space-y-3">
{splitList(plan.features, "\n").map((feature) => (
<li key={feature} className="flex items-start gap-3">
<CheckIcon className={cn("mt-0.5 size-4 shrink-0", !featured && "text-t-accent")} />
{feature}
</li>
))}
</ul>
<a
href="#probetraining"
className={cn("mt-10 block py-4 text-center text-sm font-bold uppercase tracking-wider transition-opacity hover:opacity-85", featured ? "bg-t-bg text-t-fg" : "bg-t-fg text-t-bg")}
>
Jetzt starten
</a>
</article>
</Reveal>
)
})}
</div>
</section>
)}
{show("coaches") && (
<section data-order="coaches" id="team" className={cn(container, "pb-24 md:pb-36")}>
<Reveal><Eyebrow>{coaches.eyebrow}</Eyebrow></Reveal>
<RevealWords text={coaches.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,6vw,5.5rem)] leading-[0.92] font-black")} />
<div className="mt-12 grid grid-cols-2 gap-3 md:grid-cols-4 md:gap-5">
{coaches.members.map((member, i) => (
<Reveal key={i} delay={i * 0.08}>
<div className="group relative aspect-[3/4] overflow-hidden bg-t-surface">
<TImage src={member.image} alt={member.name} sizes="(min-width: 768px) 25vw, 50vw" className="grayscale transition duration-700 group-hover:scale-105 group-hover:grayscale-0" />
<div className="absolute inset-x-0 bottom-0 translate-y-[calc(100%-4.5rem)] bg-t-accent p-5 text-t-accent-fg transition-transform duration-500 group-hover:translate-y-0">
<p className={cn(display, "text-lg leading-tight")}>{member.name}</p>
<p className="mt-3 text-sm opacity-80">{member.role}</p>
</div>
</div>
</Reveal>
))}
</div>
</section>
)}
<section id="probetraining" className="relative overflow-hidden bg-t-surface py-24 md:py-36">
<div className={cn(container, "grid gap-14 lg:grid-cols-2")}>
<div>
<RevealWords text={trial.title} emClassName={em} className={cn(display, "text-[clamp(2.75rem,7vw,6.5rem)] leading-[0.9] font-black")} />
<Reveal delay={0.15}>
<p className="mt-8 max-w-md text-lg leading-relaxed text-t-muted">{trial.text}</p>
<p className="mt-8 text-sm text-t-muted">{brand.address} · {brand.phone}</p>
</Reveal>
</div>
<Reveal delay={0.1}>
<DemoForm
className="grid gap-6 bg-t-bg p-7 md:p-10"
success={
<div className="py-8">
<p className={cn(display, "text-4xl text-t-accent")}>Let&apos;s go!</p>
<p className="mt-3 text-t-muted">Wir melden uns heute noch mit deinem Termin fürs Probetraining.</p>
</div>
}
>
<TField label="Name" name="name" required autoComplete="name" variant="line" />
<TField label="Telefon" name="phone" type="tel" required autoComplete="tel" variant="line" />
<TSelect label="Was willst du testen?" name="class" variant="line" options={[...new Set(classes.items.map((item) => item.name))]} />
<button type="submit" className="mt-2 -skew-x-12 cursor-pointer bg-t-accent py-4 text-t-accent-fg transition-transform hover:-translate-y-0.5">
<span className="block skew-x-12 text-sm font-bold uppercase tracking-wider">{trial.cta}</span>
</button>
</DemoForm>
</Reveal>
</div>
</section>
<footer className="overflow-hidden pt-16">
<div className={cn(container, "flex flex-col gap-6 text-sm text-t-muted md:flex-row md:justify-between")}>
<p>{brand.name} {brand.suffix} · {brand.address}</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>
</div>
<p
aria-hidden
style={{ fontSize: wordmarkSize(brand.name, 30) }}
className={cn(display, "mt-8 text-center leading-[0.8] font-black whitespace-nowrap text-transparent [-webkit-text-stroke:1px_var(--t-accent)]")}
>
{brand.name}
</p>
</footer>
</TemplateShell>
)
}
function Nav({ name, cta, links }: { name: string; cta: string; 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 transition-colors duration-500", scrolled && "bg-t-bg/85 backdrop-blur-xl")}
>
<nav className={cn(container, "flex h-20 items-center justify-between gap-6")}>
<a href="#top" className={cn(display, "text-2xl font-black")}>
{name}<span className="text-t-accent">.</span>
</a>
<div className="hidden items-center gap-8 md:flex">
{links.map((link) => (
<a key={link.href} href={link.href} className="text-xs font-semibold uppercase tracking-[0.2em] text-t-fg/70 transition-colors hover:text-t-accent">
{link.label}
</a>
))}
</div>
<a href="#probetraining" className="-skew-x-12 bg-t-accent px-5 py-2.5 text-t-accent-fg">
<span className="block skew-x-12 text-xs font-bold uppercase tracking-wider">{cta}</span>
</a>
</nav>
</motion.header>
)
}
function Programs({ programs }: { programs: FitnessData["programs"] }) {
const [active, setActive] = useState(0)
return (
<section id="training" className={cn(container, "py-24 md:py-36")}>
<Reveal><Eyebrow>{programs.eyebrow}</Eyebrow></Reveal>
<RevealWords text={programs.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,6vw,5.5rem)] leading-[0.92] font-black")} />
<div className="mt-14 flex flex-col gap-3 md:h-[34rem] md:flex-row">
{programs.items.map((item, i) => {
const open = active === i
return (
<button
key={i}
type="button"
onMouseEnter={() => setActive(i)}
onFocus={() => setActive(i)}
onClick={() => setActive(i)}
className={cn(
"group relative h-72 cursor-pointer overflow-hidden text-left transition-[flex-grow] duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] md:h-auto",
open ? "md:flex-[3.2]" : "md:flex-1"
)}
>
<TImage src={item.image} sizes="(min-width: 768px) 50vw, 100vw" className={cn("transition duration-700", open ? "scale-100" : "scale-110 grayscale")} />
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
<span className={cn(display, "absolute top-5 left-5 text-sm text-t-accent")}>{String(i + 1).padStart(2, "0")}</span>
<div className="absolute inset-x-0 bottom-0 p-6 text-white md:p-8">
<h3 className={cn(display, "text-3xl font-black md:text-4xl", !open && "md:origin-bottom-left md:-rotate-90 md:translate-x-8 md:whitespace-nowrap")}>
{item.title}
</h3>
<AnimatePresence>
{open && (
<motion.p
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease, delay: 0.2 }}
className="mt-3 max-w-sm text-white/80"
>
{item.text}
</motion.p>
)}
</AnimatePresence>
</div>
</button>
)
})}
</div>
</section>
)
}
function Schedule({ classes }: { classes: FitnessData["classes"] }) {
const days = DAYS.filter((day) => classes.items.some((item) => item.day === day))
const [day, setDay] = useState(days[0] ?? "Mo")
const [booked, setBooked] = useState<string[]>([])
const current = days.includes(day) ? day : days[0]
return (
<section id="kurse" className="bg-t-surface py-24 md:py-36">
<div className={container}>
<div className="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
<div>
<Reveal><Eyebrow>{classes.eyebrow}</Eyebrow></Reveal>
<RevealWords text={classes.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,6vw,5.5rem)] leading-[0.92] font-black")} />
</div>
<div role="tablist" className="flex flex-wrap gap-1">
{days.map((d) => (
<button
key={d}
type="button"
role="tab"
aria-selected={d === current}
onClick={() => setDay(d)}
className={cn("relative cursor-pointer px-4 py-2.5 text-sm font-bold uppercase", d === current ? "text-t-accent-fg" : "text-t-muted hover:text-t-fg")}
>
{d === current && <motion.span layoutId="fitness-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.ul key={current} initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} transition={{ duration: 0.35, ease }} className="mt-12 border-t border-t-fg/10">
{classes.items.filter((item) => item.day === current).map((item) => {
const id = `${item.day}-${item.time}-${item.name}`
const isBooked = booked.includes(id)
const level = Math.min(3, Math.max(1, Number(item.level) || 1))
return (
<li key={id} className="grid grid-cols-[5rem_1fr_auto] items-center gap-4 border-b border-t-fg/10 py-6 md:grid-cols-[8rem_1fr_10rem_8rem_auto]">
<span className={cn(display, "text-2xl text-t-accent md:text-3xl")}>{item.time}</span>
<div>
<p className={cn(display, "text-lg md:text-2xl")}>{item.name}</p>
<p className="text-sm text-t-muted md:hidden">mit {item.trainer}</p>
</div>
<p className="hidden text-sm text-t-muted md:block">mit {item.trainer}</p>
<span className="hidden items-end gap-1 md:flex" aria-label={`Intensität ${level} von 3`}>
{[1, 2, 3].map((bar) => (
<span key={bar} className={cn("w-2 rounded-sm", bar <= level ? "bg-t-accent" : "bg-t-fg/15")} style={{ height: 6 + bar * 6 }} />
))}
</span>
<button
type="button"
onClick={() => setBooked((list) => (isBooked ? list.filter((entry) => entry !== id) : [...list, id]))}
className={cn(
"flex cursor-pointer items-center gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider transition-colors",
isBooked ? "bg-t-accent text-t-accent-fg" : "ring-1 ring-t-fg/20 hover:ring-t-accent"
)}
>
{isBooked && <CheckIcon className="size-3.5" />}
{isBooked ? "Gebucht" : "Platz sichern"}
</button>
</li>
)
})}
</motion.ul>
</AnimatePresence>
</div>
</section>
)
}
function Transformation({ data }: { data: FitnessData["transformation"] }) {
const [position, setPosition] = useState(50)
return (
<section className={cn(container, "grid items-center gap-14 py-24 md:py-36 lg:grid-cols-[1fr_1.2fr]")}>
<div>
<Reveal><Eyebrow>{data.eyebrow}</Eyebrow></Reveal>
<RevealWords text={data.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,5.5vw,5rem)] leading-[0.92] font-black")} />
<Reveal delay={0.1}>
<p className="mt-6 max-w-md text-lg leading-relaxed text-t-muted">{data.text}</p>
<blockquote className="mt-10 border-l-4 border-t-accent pl-6">
<p className="text-xl leading-snug">„{data.quote}“</p>
<footer className="mt-3 text-sm text-t-muted">{data.author}</footer>
</blockquote>
</Reveal>
</div>
<Reveal delay={0.1}>
<div className="relative aspect-[4/3] overflow-hidden bg-t-surface select-none">
<TImage src={data.image} sizes="(min-width: 1024px) 55vw, 100vw" />
<div className="absolute inset-0" style={{ clipPath: `inset(0 ${100 - position}% 0 0)` }}>
<TImage src={data.image} sizes="(min-width: 1024px) 55vw, 100vw" className="brightness-50 grayscale" />
</div>
<div className="pointer-events-none absolute inset-y-0 w-0.5 bg-t-accent" style={{ left: `${position}%` }}>
<span className="absolute top-1/2 left-1/2 grid size-12 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-t-accent text-t-accent-fg shadow-xl">
<MoveHorizontalIcon className="size-5" />
</span>
</div>
<span className="pointer-events-none absolute top-4 left-4 bg-black/70 px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">{data.before}</span>
<span className="pointer-events-none absolute top-4 right-4 bg-t-accent px-3 py-1 text-xs font-bold uppercase tracking-wider text-t-accent-fg">{data.after}</span>
<input
type="range"
min={0}
max={100}
value={position}
onChange={(event) => setPosition(Number(event.target.value))}
aria-label="Vorher/Nachher vergleichen"
className="absolute inset-0 h-full w-full cursor-ew-resize opacity-0"
/>
</div>
</Reveal>
</section>
)
}