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

412 lines
25 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 { motion, useScroll } from "motion/react"
import { Fragment, useRef, useState } from "react"
import { ArrowDownRightIcon, ArrowRightIcon, CheckIcon, PauseIcon, PlayIcon, QuoteIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { DemoForm } from "../shared/demo-form"
import { TField } from "../shared/fields"
import { TImage } from "../shared/media"
import { Reveal, RevealWords, ease, parseRich, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { coach, type CoachData } from "./definition"
import { fonts, script } from "./fonts"
const em = "italic text-t-accent"
const container = "mx-auto w-full max-w-[1280px] px-5 md:px-8"
const heading = "font-t-display font-normal tracking-[-0.02em]"
function Eyebrow({ children }: { children: string }) {
return <p className="mb-5 text-sm font-semibold uppercase tracking-[0.2em] text-t-accent">{children}</p>
}
// Wörter gleiten herein, hervorgehobene Wörter bekommen eine handgezeichnete Linie
function MarkedTitle({ text }: { text: string }) {
const lines: { word: string; em: boolean; index: number }[][] = []
let count = 0
for (const tokens of parseRich(text)) {
const line = []
for (const token of tokens) {
line.push({ ...token, index: count })
count += 1
}
lines.push(line)
}
return (
<h1 className={cn(heading, "text-[clamp(2.5rem,4.8vw,4.25rem)] leading-[1.05]")} 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="relative inline-block">
<span className="mb-[-0.12em] inline-block overflow-hidden pb-[0.12em] align-top">
<motion.span
className={cn("inline-block", token.em && em)}
initial={{ y: "105%" }}
animate={{ y: "0%" }}
transition={{ duration: 1, ease, delay: 0.2 + token.index * 0.06 }}
>
{token.word}
</motion.span>
</span>
{token.em && (
<svg viewBox="0 0 200 20" preserveAspectRatio="none" aria-hidden className="absolute -bottom-[0.1em] left-0 h-[0.3em] w-full overflow-visible text-t-accent">
<motion.path
d="M3 14 C 40 5, 90 4, 120 10 S 175 17, 197 7"
fill="none"
stroke="currentColor"
strokeWidth="5"
strokeLinecap="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.9, ease, delay: 1.1 + token.index * 0.05 }}
/>
</svg>
)}
</span>
</Fragment>
))}
</span>
))}
</h1>
)
}
export function CoachTemplate({ content }: TemplateProps<CoachData>) {
const { brand, hero, press, pains, story, offers, results, podcast, leadmagnet, contact } = content.data
const show = (key: string) => !content.hidden.includes(key)
const links = [
{ href: "#ueber-mich", label: "Über mich", visible: show("story") },
{ href: "#angebote", label: "Angebote", visible: show("offers") },
{ href: "#podcast", label: "Podcast", visible: show("podcast") },
].filter((link) => link.visible)
return (
<TemplateShell def={coach} content={content} fonts={fonts[content.fonts] ?? fonts.editorial}>
<Nav name={brand.name} cta={contact.cta} links={links} />
<section id="top" className={cn(container, "grid items-center gap-14 pt-32 pb-16 md:grid-cols-[1.1fr_1fr] md:pt-40 md:pb-24")}>
<div>
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease }}>
<Eyebrow>{hero.eyebrow}</Eyebrow>
</motion.div>
<MarkedTitle text={hero.title} />
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.7 }}>
<p className="mt-8 max-w-lg text-lg leading-relaxed text-t-muted">{hero.text}</p>
<div className="mt-9 flex flex-wrap gap-3">
<a href="#kontakt" className="inline-flex items-center gap-2 rounded-full bg-t-accent px-7 py-4 font-semibold text-t-accent-fg transition-transform hover:-translate-y-0.5">
{hero.cta} <ArrowRightIcon className="size-4" />
</a>
{show("offers") && (
<a href="#angebote" className="rounded-full px-7 py-4 font-semibold ring-1 ring-t-fg/20 transition-colors hover:bg-t-surface">{hero.ctaSecondary}</a>
)}
</div>
</motion.div>
</div>
<div className="relative mx-auto w-full max-w-[480px]">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{
opacity: 1,
scale: 1,
borderRadius: ["58% 42% 38% 62% / 52% 38% 62% 48%", "42% 58% 62% 38% / 38% 56% 44% 62%", "58% 42% 38% 62% / 52% 38% 62% 48%"],
}}
transition={{ opacity: { duration: 1 }, scale: { duration: 1.2, ease }, borderRadius: { duration: 14, repeat: Infinity, ease: "easeInOut" } }}
className="relative aspect-[4/5] overflow-hidden bg-t-surface"
>
<TImage src={hero.image} eager sizes="(min-width: 768px) 40vw, 90vw" />
</motion.div>
<motion.figure
initial={{ opacity: 0, y: 20, rotate: -6 }}
animate={{ opacity: 1, y: 0, rotate: -3 }}
transition={{ duration: 0.9, ease, delay: 1 }}
className="absolute bottom-8 -left-4 max-w-60 rounded-2xl bg-t-bg p-5 shadow-xl md:-left-12"
>
<QuoteIcon className="size-5 text-t-accent" />
<blockquote className={cn(heading, "mt-2 text-lg leading-snug")}>{hero.quote}</blockquote>
</motion.figure>
<motion.span
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", bounce: 0.4, delay: 1.3 }}
className="absolute top-10 -right-2 rounded-full bg-t-accent px-4 py-2 text-sm font-semibold text-t-accent-fg shadow-lg md:-right-6"
>
{hero.badge}
</motion.span>
</div>
</section>
{show("press") && (
<section className="border-y border-t-fg/10 py-8">
<div className={cn(container, "flex flex-col items-center gap-6 md:flex-row md:justify-between")}>
<p className="text-sm text-t-muted">{press.title}</p>
<div className="flex flex-wrap justify-center gap-x-10 gap-y-3">
{press.items.map((item, i) => (
<span key={i} className={cn(heading, "text-xl text-t-fg/55 italic")}>{item.name}</span>
))}
</div>
</div>
</section>
)}
{show("pains") && (
<section className={cn(container, "grid gap-12 py-24 md:grid-cols-2 md:py-32")}>
<div>
<Reveal><Eyebrow>{pains.eyebrow}</Eyebrow></Reveal>
<RevealWords text={pains.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
</div>
<div>
<ul className="space-y-4">
{pains.items.map((item, i) => (
<Reveal key={i} delay={i * 0.12}>
<li className="flex items-start gap-4 rounded-2xl bg-t-surface p-5 text-lg">
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-t-bg text-t-accent">
<XIcon className="size-4" />
</span>
{item.text}
</li>
</Reveal>
))}
</ul>
<Reveal delay={0.5}>
<a href="#kontakt" className="mt-8 inline-flex items-center gap-3 text-4xl text-t-accent" style={script}>
{pains.closing} <ArrowDownRightIcon className="size-7" />
</a>
</Reveal>
</div>
</section>
)}
{show("story") && <Story story={story} />}
{show("offers") && (
<section id="angebote" className={cn(container, "py-24 md:py-32")}>
<Reveal><Eyebrow>{offers.eyebrow}</Eyebrow></Reveal>
<RevealWords text={offers.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
<div className="mt-14 grid gap-5 lg:grid-cols-3">
{offers.items.map((offer, i) => {
const featured = Boolean(offer.highlight.trim())
return (
<Reveal key={i} delay={i * 0.1}>
<article className={cn("flex h-full flex-col rounded-[2rem] p-8 transition-transform duration-500 hover:-translate-y-1.5", featured ? "bg-t-accent text-t-accent-fg" : "bg-t-surface")}>
<span className={cn(heading, "text-6xl opacity-30")}>0{i + 1}</span>
<h3 className={cn(heading, "mt-4 text-3xl")}>{offer.title}</h3>
<p className={cn("mt-3 flex-1 leading-relaxed", featured ? "opacity-85" : "text-t-muted")}>{offer.text}</p>
<p className="mt-8 font-semibold">{offer.price}</p>
<a href="#kontakt" className={cn("mt-5 inline-flex items-center justify-between rounded-full px-5 py-3.5 font-semibold", featured ? "bg-t-bg text-t-fg" : "bg-t-fg text-t-bg")}>
{offer.cta} <ArrowRightIcon className="size-4" />
</a>
</article>
</Reveal>
)
})}
</div>
</section>
)}
{show("results") && (
<section className="bg-t-surface py-24 md:py-32">
<div className={container}>
<Reveal><Eyebrow>{results.eyebrow}</Eyebrow></Reveal>
<RevealWords text={results.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
<div className="mt-12 grid gap-5 md:grid-cols-3">
{results.items.map((item, i) => (
<Reveal key={i} delay={i * 0.1}>
<figure className="flex h-full flex-col rounded-[2rem] bg-t-bg p-7">
<blockquote className="flex-1 text-lg leading-relaxed">„{item.quote}“</blockquote>
<figcaption className="mt-6 flex items-center gap-3">
<span className="relative size-12 overflow-hidden rounded-full bg-t-surface">
<TImage src={item.image} alt={item.author} sizes="48px" />
</span>
<span className="text-sm">
<span className="block font-semibold">{item.author}</span>
<span className="text-t-muted">{item.role}</span>
</span>
</figcaption>
</figure>
</Reveal>
))}
</div>
</div>
</section>
)}
{show("podcast") && <Podcast podcast={podcast} />}
{show("leadmagnet") && (
<section className={cn(container, "pb-24 md:pb-32")}>
<div className="grid items-center gap-12 rounded-[2.5rem] bg-t-fg px-6 py-14 text-t-bg md:grid-cols-[0.8fr_1.2fr] md:px-14">
<div className="[perspective:1200px]">
<motion.div
initial={{ rotateY: -40, opacity: 0 }}
whileInView={{ rotateY: -22, opacity: 1 }}
whileHover={{ rotateY: -8 }}
viewport={{ once: true }}
transition={{ duration: 1, ease }}
className="relative mx-auto h-80 w-56 [transform-style:preserve-3d]"
>
<div className="absolute inset-0 rounded-r-xl bg-[repeating-linear-gradient(90deg,#f5f5f4,#f5f5f4_2px,#d6d3d1_3px)] [transform:rotateY(180deg)_translateZ(14px)]" />
<div className="absolute inset-y-0 left-0 w-7 bg-t-accent brightness-75 [transform:rotateY(-90deg)_translateZ(14px)]" />
<div className="absolute inset-y-1 right-0 w-7 bg-[repeating-linear-gradient(90deg,#fafaf9,#fafaf9_2px,#e7e5e4_3px)] [transform:rotateY(90deg)_translateZ(14px)]" />
<div className="absolute inset-0 flex flex-col rounded-r-xl bg-t-accent p-6 text-t-accent-fg shadow-2xl [transform:translateZ(14px)]">
<p className="text-xs uppercase tracking-widest opacity-70">Workbook</p>
<p className={cn(heading, "mt-4 text-3xl leading-tight")}>{leadmagnet.book}</p>
<p className="mt-auto text-2xl" style={script}>{brand.name}</p>
</div>
</motion.div>
</div>
<div>
<RevealWords text={leadmagnet.title} emClassName={em} className={cn(heading, "text-[clamp(2rem,4vw,3.25rem)] leading-[1.08]")} />
<p className="mt-4 text-lg opacity-75">{leadmagnet.text}</p>
<DemoForm
className="mt-8 grid gap-3 sm:grid-cols-[1fr_1fr_auto]"
success={
<p className="mt-8 flex items-center gap-3 text-lg">
<CheckIcon className="size-6 text-t-accent" /> Ist unterwegs – schau in dein Postfach!
</p>
}
>
<TField label="Vorname" name="name" required autoComplete="given-name" variant="glass" />
<TField label="E-Mail" name="email" type="email" required autoComplete="email" variant="glass" />
<button type="submit" className="cursor-pointer self-end rounded-xl bg-t-accent px-6 py-3 font-semibold text-t-accent-fg">{leadmagnet.cta}</button>
</DemoForm>
</div>
</div>
</section>
)}
<section id="kontakt" className="mx-auto max-w-[900px] px-5 pb-24 text-center md:pb-32">
<RevealWords text={contact.title} emClassName={em} className={cn(heading, "text-[clamp(3rem,8vw,6.5rem)] leading-none")} />
<Reveal delay={0.1}>
<p className="mx-auto mt-6 max-w-xl text-lg leading-relaxed text-t-muted">{contact.text}</p>
<a href={`mailto:${brand.email}`} className="mt-9 inline-flex items-center gap-2 rounded-full bg-t-accent px-8 py-4 font-semibold text-t-accent-fg transition-transform hover:-translate-y-0.5">
{contact.cta} <ArrowRightIcon className="size-4" />
</a>
<p className="mt-5 text-sm text-t-muted">oder schreib mir: {brand.email}</p>
</Reveal>
</section>
<footer className={cn(container, "flex flex-col gap-4 border-t border-t-fg/10 py-10 text-sm text-t-muted md:flex-row md:justify-between")}>
<p><span className={cn(heading, "text-lg text-t-fg")}>{brand.name}</span> · {brand.role}</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>
</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(heading, "text-2xl")}>{name}</a>
<div className="hidden items-center gap-8 text-sm font-medium 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>
<a href="#kontakt" className="rounded-full bg-t-fg px-5 py-2.5 text-sm font-semibold text-t-bg">{cta}</a>
</nav>
</motion.header>
)
}
function Story({ story }: { story: CoachData["story"] }) {
const ref = useRef<HTMLOListElement>(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start 0.8", "end 0.6"] })
return (
<section id="ueber-mich" className={cn(container, "grid gap-14 py-24 md:grid-cols-2 md:py-32")}>
<div>
<Reveal>
<div className="relative aspect-[4/5] overflow-hidden rounded-[2.5rem] bg-t-surface">
<TImage src={story.image} sizes="(min-width: 768px) 45vw, 100vw" />
</div>
</Reveal>
</div>
<div>
<Reveal><Eyebrow>{story.eyebrow}</Eyebrow></Reveal>
<RevealWords text={story.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
<Reveal delay={0.1}><p className="mt-6 text-lg leading-relaxed text-t-muted">{story.text}</p></Reveal>
<ol ref={ref} className="relative mt-10 space-y-7 pl-8">
<span className="absolute top-2 bottom-2 left-[5px] w-0.5 bg-t-fg/10" />
<motion.span style={{ scaleY: scrollYProgress }} className="absolute top-2 bottom-2 left-[5px] w-0.5 origin-top bg-t-accent" />
{story.milestones.map((milestone, i) => (
<Reveal key={i} delay={i * 0.06}>
<li className="relative">
<span className="absolute top-1.5 -left-8 size-3 rounded-full bg-t-accent ring-4 ring-t-bg" />
<p className="text-sm font-semibold text-t-accent">{milestone.year}</p>
<p className="mt-0.5">{milestone.text}</p>
</li>
</Reveal>
))}
</ol>
<Reveal delay={0.2}>
<p className="mt-10 text-5xl text-t-accent" style={script}>{story.signature}</p>
</Reveal>
</div>
</section>
)
}
function Podcast({ podcast }: { podcast: CoachData["podcast"] }) {
const [playing, setPlaying] = useState<number | null>(null)
return (
<section id="podcast" className={cn(container, "grid gap-12 py-24 md:grid-cols-[0.8fr_1.2fr] md:py-32")}>
<div>
<Reveal><Eyebrow>{podcast.eyebrow}</Eyebrow></Reveal>
<RevealWords text={podcast.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.08]")} />
</div>
<ul className="space-y-3">
{podcast.items.map((episode, i) => {
const active = playing === i
return (
<Reveal key={i} delay={i * 0.08}>
<li className={cn("rounded-2xl p-4 transition-colors md:p-5", active ? "bg-t-accent text-t-accent-fg" : "bg-t-surface")}>
<div className="flex items-center gap-4">
<button
type="button"
aria-label={active ? "Pause" : `Folge ${episode.number} abspielen`}
onClick={() => setPlaying(active ? null : i)}
className={cn("grid size-12 shrink-0 cursor-pointer place-items-center rounded-full", active ? "bg-t-bg text-t-fg" : "bg-t-accent text-t-accent-fg")}
>
{active ? <PauseIcon className="size-5 fill-current" /> : <PlayIcon className="ml-0.5 size-5 fill-current" />}
</button>
<div className="min-w-0 flex-1">
<p className={cn("text-xs font-semibold", active ? "opacity-80" : "text-t-muted")}>Folge {episode.number} · {episode.duration}</p>
<p className={cn(heading, "truncate text-xl")}>{episode.title}</p>
</div>
{active && (
<span className="hidden h-8 items-end gap-0.5 sm:flex" aria-hidden>
{[0, 1, 2, 3, 4, 5].map((bar) => (
<motion.span key={bar} className="w-1 rounded-full bg-current" animate={{ height: [6, 24, 10, 28, 8] }} transition={{ duration: 1, repeat: Infinity, delay: bar * 0.1 }} />
))}
</span>
)}
</div>
{active && (
<div className="mt-4 h-1 overflow-hidden rounded-full bg-current/20">
<motion.div className="h-full bg-current" initial={{ width: "0%" }} animate={{ width: "100%" }} transition={{ duration: 40, ease: "linear" }} />
</div>
)}
</li>
</Reveal>
)
})}
</ul>
</section>
)
}