mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
412 lines
25 KiB
TypeScript
412 lines
25 KiB
TypeScript
"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 data-order="offers" 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 data-order="press" 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 data-order="pains" 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 data-order="story" story={story} />}
|
||
|
||
{show("offers") && (
|
||
<section data-order="offers" 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 data-order="results" 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 data-order="podcast" podcast={podcast} />}
|
||
|
||
{show("leadmagnet") && (
|
||
<section data-order="leadmagnet" 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>
|
||
)
|
||
}
|