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

559 lines
32 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, useInView, useMotionValue, useSpring, useTransform } from "motion/react"
import { useEffect, useRef, useState, type PointerEvent } from "react"
import {
ArrowRightIcon,
BotIcon,
ChartNoAxesColumnIcon,
CheckIcon,
InboxIcon,
LockIcon,
MailIcon,
MessageCircleIcon,
PhoneIcon,
PlugIcon,
SparklesIcon,
WorkflowIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { splitList } from "../shared/hooks"
import { TImage } from "../shared/media"
import { Accordion, Marquee, Reveal, RevealWords, ease, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { saas, type SaasData } from "./definition"
import { fonts } from "./fonts"
const em = "bg-gradient-to-r from-t-accent to-t-fg bg-clip-text text-transparent"
const container = "mx-auto w-full max-w-[1200px] px-5 md:px-8"
const heading = "font-t-display font-semibold tracking-[-0.035em]"
const mono = { fontFamily: "var(--font-saas-mono), ui-monospace, monospace" }
const card = "rounded-3xl border border-t-fg/10 bg-t-surface/60"
const featureIcons = [InboxIcon, BotIcon, WorkflowIcon, ChartNoAxesColumnIcon, PlugIcon, LockIcon]
function useTypewriter(text: string, active: boolean, speed = 28) {
const [count, setCount] = useState(0)
useEffect(() => {
if (!active) return
const timer = setInterval(() => setCount((c) => (c >= text.length ? c : c + 2)), speed)
return () => clearInterval(timer)
}, [active, text.length, speed])
return text.slice(0, count)
}
function Eyebrow({ children }: { children: string }) {
return <p className="mb-4 text-sm font-medium text-t-accent">{children}</p>
}
export function SaasTemplate({ content }: TemplateProps<SaasData>) {
const { brand, hero, logos, features, steps, pricing, testimonials, faq, cta } = content.data
const show = (key: string) => !content.hidden.includes(key)
const links = [
{ href: "#funktionen", label: "Funktionen", visible: show("features") },
{ href: "#preise", label: "Preise", visible: show("pricing") },
{ href: "#faq", label: "FAQ", visible: show("faq") },
].filter((link) => link.visible)
return (
<TemplateShell def={saas} content={content} fonts={fonts[content.fonts] ?? fonts.geist}>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-[900px] bg-[radial-gradient(50rem_30rem_at_50%_-5%,color-mix(in_oklab,var(--t-accent)_35%,transparent),transparent_70%)]" />
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[900px] [mask-image:linear-gradient(to_bottom,black,transparent)] bg-[size:56px_56px] bg-[linear-gradient(to_right,color-mix(in_oklab,var(--t-fg)_6%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_oklab,var(--t-fg)_6%,transparent)_1px,transparent_1px)]"
/>
<Nav name={brand.name} cta={hero.cta} links={links} />
<section id="top" className={cn(container, "relative pt-36 pb-10 text-center md:pt-44")}>
<motion.a
href="#funktionen"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease }}
className="inline-flex items-center gap-2 rounded-full border border-t-fg/10 bg-t-surface/70 px-4 py-1.5 text-sm backdrop-blur"
>
<SparklesIcon className="size-4 text-t-accent" /> {hero.badge} <ArrowRightIcon className="size-3.5 text-t-muted" />
</motion.a>
<RevealWords
as="h1"
onMount
delay={0.15}
text={hero.title}
emClassName={em}
className={cn(heading, "mx-auto mt-8 max-w-4xl text-[clamp(2.75rem,7vw,5.75rem)] leading-[1.02]")}
/>
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.6 }}>
<p className="mx-auto mt-7 max-w-2xl text-lg leading-relaxed text-t-muted md:text-xl">{hero.text}</p>
<div className="mt-9 flex flex-wrap justify-center gap-3">
<a href="#preise" className="rounded-full bg-t-accent px-7 py-3.5 font-medium text-t-accent-fg shadow-[0_10px_40px_-10px_var(--t-accent)] transition-transform hover:-translate-y-0.5">
{hero.cta}
</a>
<a href="#funktionen" className="rounded-full border border-t-fg/15 px-7 py-3.5 font-medium transition-colors hover:bg-t-surface">
{hero.ctaSecondary}
</a>
</div>
<p className="mt-5 text-sm text-t-muted">{hero.note}</p>
</motion.div>
<ProductMockup name={brand.name} />
</section>
{show("logos") && (
<section className="py-16">
<p className="mb-8 text-center text-sm text-t-muted">{logos.title}</p>
<div className="[mask-image:linear-gradient(to_right,transparent,black_15%,black_85%,transparent)]">
<Marquee duration={30}>
{logos.items.map((logo, i) => (
<span key={i} className={cn("px-10 text-2xl text-t-fg/50", ["font-semibold tracking-tight", "font-light italic", "font-bold uppercase tracking-widest text-lg", "font-medium"][i % 4])}>
{logo.name}
</span>
))}
</Marquee>
</div>
</section>
)}
{show("features") && (
<section id="funktionen" className={cn(container, "py-24 md:py-32")}>
<div className="mx-auto max-w-2xl text-center">
<Reveal><Eyebrow>{features.eyebrow}</Eyebrow></Reveal>
<RevealWords text={features.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
</div>
<div className="mt-14 grid gap-4 md:grid-cols-3">
{features.items.map((item, i) => {
const Icon = featureIcons[i % featureIcons.length]
const wide = i % 4 === 0 || i % 4 === 3
return (
<Reveal key={i} delay={(i % 3) * 0.08} className={cn(wide && "md:col-span-2")}>
<article className={cn(card, "group flex h-full min-h-72 flex-col overflow-hidden p-7 transition-colors hover:border-t-accent/40")}>
<div className="relative mb-6 flex-1">
<FeatureVisual index={i} />
</div>
<h3 className="flex items-center gap-2 text-lg font-semibold">
<Icon className="size-5 text-t-accent" /> {item.title}
</h3>
<p className="mt-2 text-t-muted">{item.text}</p>
</article>
</Reveal>
)
})}
</div>
</section>
)}
{show("steps") && <Steps steps={steps} name={brand.name} />}
{show("pricing") && <Pricing pricing={pricing} />}
{show("testimonials") && (
<section className={cn(container, "py-24 md:py-32")}>
<RevealWords text={testimonials.title} emClassName={em} className={cn(heading, "text-center text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
<div className="mt-14 gap-4 [column-fill:_balance] sm:columns-2">
{testimonials.items.map((item, i) => (
<Reveal key={i} delay={(i % 2) * 0.1} className="mb-4 break-inside-avoid">
<figure className={cn(card, "p-7")}>
<blockquote className="text-lg leading-relaxed">„{item.quote}“</blockquote>
<figcaption className="mt-6 flex items-center gap-3">
<span className="relative size-11 overflow-hidden rounded-full bg-t-surface">
<TImage src={item.image} alt={item.author} sizes="44px" />
</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>
</section>
)}
{show("faq") && (
<section id="faq" className={cn(container, "max-w-3xl py-24 md:py-32")}>
<RevealWords text={faq.title} emClassName={em} className={cn(heading, "text-center text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
<Accordion
items={faq.items}
className="mt-12 divide-y divide-t-fg/10 border-y border-t-fg/10"
questionClassName="py-6 text-lg font-medium"
answerClassName="pb-6 leading-relaxed text-t-muted"
/>
</section>
)}
<section className={cn(container, "pb-24")}>
<Reveal>
<div className="relative overflow-hidden rounded-[2.5rem] border border-t-fg/10 bg-t-surface px-6 py-20 text-center md:py-28">
<div aria-hidden className="absolute inset-0 bg-[radial-gradient(40rem_20rem_at_50%_120%,color-mix(in_oklab,var(--t-accent)_45%,transparent),transparent_70%)]" />
<RevealWords text={cta.title} emClassName={em} className={cn(heading, "relative mx-auto max-w-3xl text-[clamp(2.25rem,5vw,4.25rem)] leading-[1.05]")} />
<p className="relative mx-auto mt-5 max-w-xl text-lg text-t-muted">{cta.text}</p>
<a href="#preise" className="relative mt-9 inline-flex items-center gap-2 rounded-full bg-t-accent px-8 py-4 font-medium text-t-accent-fg">
{cta.button} <ArrowRightIcon className="size-4" />
</a>
</div>
</Reveal>
</section>
<footer className={cn(container, "flex flex-col gap-6 border-t border-t-fg/10 py-10 text-sm text-t-muted md:flex-row md:justify-between")}>
<p><span className="font-semibold text-t-fg">{brand.name}</span> · {brand.tagline}</p>
<div className="flex gap-6">
<a href="#" className="hover:text-t-fg">Impressum</a>
<a href="#" className="hover:text-t-fg">Datenschutz</a>
<a href="#" className="hover:text-t-fg">AGB</a>
</div>
</footer>
</TemplateShell>
)
}
function Logo({ name }: { name: string }) {
return (
<a href="#top" className="flex items-center gap-2.5 font-semibold tracking-tight">
<span className="size-7 rounded-lg bg-gradient-to-br from-t-accent to-t-fg/80 shadow-[0_0_20px_-4px_var(--t-accent)]" />
{name}
</a>
)
}
function Nav({ name, cta, links }: { name: string; cta: string; links: { href: string; label: string }[] }) {
const { scrolled, hidden } = useScrollNav()
return (
<motion.header animate={{ y: hidden ? "-130%" : "0%" }} transition={{ duration: 0.45, ease }} className="fixed inset-x-0 top-0 z-50 px-3 pt-3">
<nav
className={cn(
"mx-auto flex h-14 max-w-[1100px] items-center justify-between gap-6 rounded-full border px-4 transition-all duration-500",
scrolled ? "border-t-fg/10 bg-t-bg/75 backdrop-blur-xl" : "border-transparent"
)}
>
<Logo name={name} />
<div className="hidden items-center gap-8 text-sm text-t-muted md:flex">
{links.map((link) => (
<a key={link.href} href={link.href} className="transition-colors hover:text-t-fg">{link.label}</a>
))}
</div>
<div className="flex items-center gap-2">
<a href="#" className="hidden rounded-full px-4 py-2 text-sm text-t-muted hover:text-t-fg sm:block">Login</a>
<a href="#preise" className="rounded-full bg-t-fg px-4 py-2 text-sm font-medium text-t-bg">{cta}</a>
</div>
</nav>
</motion.header>
)
}
const tickets = [
{ name: "Anna Weber", text: "Wo bleibt meine Bestellung #4821?", time: "2 Min", icon: MailIcon },
{ name: "Ben Schulz", text: "Kann ich die Größe noch ändern?", time: "8 Min", icon: MessageCircleIcon },
{ name: "Clara Yilmaz", text: "Rückruf wegen Rechnung", time: "15 Min", icon: PhoneIcon },
{ name: "David Ott", text: "Danke, alles angekommen!", time: "1 Std", icon: MailIcon },
]
function ProductMockup({ name }: { name: string }) {
const ref = useRef<HTMLDivElement>(null)
const inView = useInView(ref, { once: true, margin: "0px 0px -20% 0px" })
const x = useMotionValue(0)
const y = useMotionValue(0)
const rotateX = useSpring(useTransform(y, [-0.5, 0.5], [7, -7]), { stiffness: 120, damping: 20 })
const rotateY = useSpring(useTransform(x, [-0.5, 0.5], [-9, 9]), { stiffness: 120, damping: 20 })
const reply = useTypewriter(
"Hallo Anna, deine Bestellung #4821 ist heute Morgen rausgegangen und kommt morgen bis 12 Uhr an. Hier ist dein Tracking-Link – melde dich gern, falls noch etwas offen ist!",
inView
)
function onMove(event: PointerEvent<HTMLDivElement>) {
const rect = event.currentTarget.getBoundingClientRect()
x.set((event.clientX - rect.left) / rect.width - 0.5)
y.set((event.clientY - rect.top) / rect.height - 0.5)
}
return (
<div
ref={ref}
onPointerMove={onMove}
onPointerLeave={() => {
x.set(0)
y.set(0)
}}
style={{ perspective: 1600 }}
className="relative mx-auto mt-16 max-w-5xl text-left md:mt-20"
>
<div aria-hidden className="absolute inset-x-10 top-10 bottom-0 rounded-[3rem] bg-t-accent/35 blur-3xl" />
<motion.div
style={{ rotateX, rotateY }}
initial={{ opacity: 0, y: 80 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1.2, ease, delay: 0.5 }}
className="relative overflow-hidden rounded-2xl border border-t-fg/10 bg-t-bg shadow-[0_50px_100px_-30px_rgba(0,0,0,0.5)]"
>
<div className="flex items-center gap-2 border-b border-t-fg/10 px-4 py-3">
<span className="size-3 rounded-full bg-red-400/80" />
<span className="size-3 rounded-full bg-amber-400/80" />
<span className="size-3 rounded-full bg-emerald-400/80" />
<span className="mx-auto rounded-md bg-t-surface px-3 py-1 text-xs text-t-muted" style={mono}>app.{name.toLowerCase().replace(/[^a-z0-9]/g, "")}.de/inbox</span>
</div>
<div className="grid min-h-[22rem] md:grid-cols-[170px_1fr_1.25fr]">
<aside className="hidden space-y-1 border-r border-t-fg/10 p-3 text-sm md:block">
{[["Posteingang", "24"], ["Mir zugewiesen", "6"], ["Automationen", ""], ["Berichte", ""]].map(([label, count], i) => (
<p key={label} className={cn("flex justify-between rounded-lg px-3 py-2", i === 0 ? "bg-t-surface font-medium" : "text-t-muted")}>
{label}
{count && <span className="rounded-full bg-t-accent px-1.5 text-xs text-t-accent-fg">{count}</span>}
</p>
))}
</aside>
<ul className="border-r border-t-fg/10">
{tickets.map((ticket, i) => (
<motion.li
key={ticket.name}
initial={{ opacity: 0, x: -12 }}
animate={inView ? { opacity: 1, x: 0 } : {}}
transition={{ delay: 0.8 + i * 0.12 }}
className={cn("flex gap-3 border-b border-t-fg/5 px-4 py-3.5", i === 0 && "bg-t-accent/10")}
>
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-t-surface text-sm font-semibold">{ticket.name.charAt(0)}</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between text-sm font-medium">
{ticket.name}
<span className="text-xs font-normal text-t-muted">{ticket.time}</span>
</span>
<span className="flex items-center gap-1.5 truncate text-xs text-t-muted">
<ticket.icon className="size-3 shrink-0" /> {ticket.text}
</span>
</span>
</motion.li>
))}
</ul>
<div className="hidden flex-col gap-3 p-5 md:flex">
<p className="w-fit max-w-[85%] rounded-2xl rounded-bl-md bg-t-surface px-4 py-3 text-sm">Hallo, wo bleibt meine Bestellung #4821? Ich brauche sie bis Freitag.</p>
<div className="rounded-2xl border border-t-accent/40 bg-t-accent/5 p-4">
<p className="flex items-center gap-1.5 text-xs font-medium text-t-accent">
<SparklesIcon className="size-3.5" /> KI-Vorschlag
</p>
<p className="mt-2 min-h-[5.5rem] text-sm leading-relaxed">
{reply}
<span className="ml-0.5 inline-block h-4 w-1 animate-pulse bg-t-accent align-middle" />
</p>
<div className="mt-3 flex gap-2">
<span className="rounded-lg bg-t-accent px-3 py-1.5 text-xs font-medium text-t-accent-fg">Senden</span>
<span className="rounded-lg border border-t-fg/15 px-3 py-1.5 text-xs">Bearbeiten</span>
</div>
</div>
<div className="mt-auto grid grid-cols-2 gap-2 text-xs">
<p className="rounded-xl bg-t-surface p-3"><span className="block text-lg font-semibold">2 Min</span><span className="text-t-muted">Ø Antwortzeit</span></p>
<p className="rounded-xl bg-t-surface p-3"><span className="block text-lg font-semibold">63 %</span><span className="text-t-muted">automatisch gelöst</span></p>
</div>
</div>
</div>
</motion.div>
</div>
)
}
function FeatureVisual({ index }: { index: number }) {
const ref = useRef<HTMLDivElement>(null)
const inView = useInView(ref, { once: true })
const typed = useTypewriter("Gern! Ich habe dir einen neuen Termin für Donnerstag reserviert …", inView && index % 6 === 1, 45)
const variant = index % 6
return (
<div ref={ref} className="flex h-full min-h-32 items-center justify-center">
{variant === 0 && (
<div className="w-full max-w-sm space-y-2">
{[MailIcon, MessageCircleIcon, PhoneIcon].map((Icon, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 12 }}
animate={inView ? { opacity: 1, y: 0 } : {}}
transition={{ delay: i * 0.15 }}
className="flex items-center gap-3 rounded-xl border border-t-fg/10 bg-t-bg px-3 py-2.5 text-sm"
>
<Icon className="size-4 text-t-accent" />
<span className="h-2 flex-1 rounded-full bg-t-fg/10" />
<span className="h-2 w-10 rounded-full bg-t-accent/40" />
</motion.div>
))}
</div>
)}
{variant === 1 && <p className="w-full rounded-2xl rounded-bl-md bg-t-bg p-4 text-sm leading-relaxed">{typed}<span className="ml-0.5 inline-block h-3.5 w-1 animate-pulse bg-t-accent align-middle" /></p>}
{variant === 2 && (
<div className="w-full space-y-2.5">
{["Rechnungen → Buchhaltung", "VIP-Kunden priorisieren", "Nach 24 h nachfassen"].map((rule, i) => (
<div key={rule} className="flex items-center justify-between rounded-xl bg-t-bg px-3 py-2.5 text-sm">
{rule}
<motion.span className="relative h-5 w-9 rounded-full bg-t-fg/15" animate={inView ? { backgroundColor: "var(--t-accent)" } : {}} transition={{ delay: 0.4 + i * 0.3 }}>
<motion.span className="absolute top-0.5 left-0.5 size-4 rounded-full bg-white" animate={inView ? { x: 16 } : {}} transition={{ delay: 0.4 + i * 0.3 }} />
</motion.span>
</div>
))}
</div>
)}
{variant === 3 && (
<div className="flex h-32 w-full items-end gap-2">
{[38, 52, 45, 70, 62, 84, 78, 96].map((height, i) => (
<motion.span
key={i}
className="flex-1 rounded-t-md bg-gradient-to-t from-t-accent/30 to-t-accent"
initial={{ height: 0 }}
animate={inView ? { height: `${height}%` } : {}}
transition={{ duration: 0.8, ease, delay: i * 0.06 }}
/>
))}
</div>
)}
{variant === 4 && (
<div className="grid grid-cols-4 gap-3">
{[MailIcon, MessageCircleIcon, PhoneIcon, PlugIcon, InboxIcon, WorkflowIcon, BotIcon, ChartNoAxesColumnIcon].map((Icon, i) => (
<motion.span
key={i}
initial={{ opacity: 0, scale: 0.6 }}
animate={inView ? { opacity: 1, scale: 1 } : {}}
transition={{ delay: i * 0.06, type: "spring", bounce: 0.4 }}
className="grid size-12 place-items-center rounded-2xl border border-t-fg/10 bg-t-bg"
>
<Icon className="size-5 text-t-accent" />
</motion.span>
))}
</div>
)}
{variant === 5 && (
<div className="text-center">
<span className="mx-auto grid size-16 place-items-center rounded-2xl bg-t-accent text-t-accent-fg shadow-[0_0_40px_-6px_var(--t-accent)]">
<LockIcon className="size-7" />
</span>
<p className="mt-4 text-xs font-medium tracking-widest text-t-muted uppercase">Hosted in Germany</p>
</div>
)}
</div>
)
}
function Steps({ steps, name }: { steps: SaasData["steps"]; name: string }) {
const ref = useRef<HTMLDivElement>(null)
const inView = useInView(ref, { once: true, margin: "0px 0px -20% 0px" })
const cli = name.toLowerCase().replace(/[^a-z0-9]/g, "") || "app"
const lines = [
`$ ${cli} connect --mail support@shop.de`,
"✓ Postfach verbunden · 2.341 Nachrichten",
`$ ${cli} learn --help-center`,
"✓ 128 Artikel gelernt",
`$ ${cli} autopilot on`,
"✓ 63 % der Anfragen werden automatisch beantwortet",
]
return (
<section className={cn(container, "grid items-center gap-12 py-24 md:py-32 lg:grid-cols-2")}>
<div>
<Reveal><Eyebrow>{steps.eyebrow}</Eyebrow></Reveal>
<RevealWords text={steps.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
<ol className="mt-10 space-y-6">
{steps.items.map((step, i) => (
<Reveal key={i} delay={i * 0.1}>
<li className="flex gap-5">
<span className="grid size-10 shrink-0 place-items-center rounded-full border border-t-accent/40 text-sm font-semibold text-t-accent" style={mono}>{i + 1}</span>
<div>
<p className="text-lg font-semibold">{step.title}</p>
<p className="mt-1 text-t-muted">{step.text}</p>
</div>
</li>
</Reveal>
))}
</ol>
</div>
<div ref={ref} className="overflow-hidden rounded-2xl border border-t-fg/10 bg-[#0b0b10] text-sm text-zinc-200 shadow-2xl" style={mono}>
<div className="flex gap-2 border-b border-white/10 px-4 py-3">
<span className="size-3 rounded-full bg-white/15" />
<span className="size-3 rounded-full bg-white/15" />
<span className="size-3 rounded-full bg-white/15" />
</div>
<div className="space-y-2 p-6">
{lines.map((line, i) => (
<motion.p
key={line}
initial={{ opacity: 0 }}
animate={inView ? { opacity: 1 } : {}}
transition={{ delay: 0.3 + i * 0.45 }}
className={line.startsWith("✓") ? "text-emerald-400" : ""}
>
{line}
</motion.p>
))}
<span className="inline-block h-4 w-2 animate-pulse bg-zinc-300" />
</div>
</div>
</section>
)
}
function Pricing({ pricing }: { pricing: SaasData["pricing"] }) {
const [yearly, setYearly] = useState(true)
const discount = Math.min(90, Math.max(0, Number(pricing.discount) || 0))
return (
<section id="preise" className={cn(container, "py-24 md:py-32")}>
<div className="mx-auto max-w-2xl text-center">
<Reveal><Eyebrow>{pricing.eyebrow}</Eyebrow></Reveal>
<RevealWords text={pricing.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
<div className="mt-8 inline-flex rounded-full border border-t-fg/10 bg-t-surface p-1 text-sm">
{[false, true].map((option) => (
<button
key={String(option)}
type="button"
aria-pressed={yearly === option}
onClick={() => setYearly(option)}
className={cn("relative cursor-pointer rounded-full px-5 py-2", yearly === option ? "text-t-bg" : "text-t-muted")}
>
{yearly === option && <motion.span layoutId="saas-billing" className="absolute inset-0 rounded-full bg-t-fg" transition={{ type: "spring", bounce: 0.2, duration: 0.5 }} />}
<span className="relative">{option ? `Jährlich · −${discount} %` : "Monatlich"}</span>
</button>
))}
</div>
</div>
<div className="mt-14 grid gap-4 lg:grid-cols-3">
{pricing.plans.map((plan, i) => {
const featured = Boolean(plan.highlight.trim())
const base = Number(plan.price.replace(",", ".")) || 0
const price = Math.round(yearly ? base * (1 - discount / 100) : base)
return (
<Reveal key={i} delay={i * 0.08}>
<article
className={cn(
"relative flex h-full flex-col rounded-3xl border p-8",
featured ? "border-t-accent bg-t-surface shadow-[0_0_60px_-20px_var(--t-accent)]" : "border-t-fg/10 bg-t-surface/50"
)}
>
{featured && <span className="absolute -top-3 left-8 rounded-full bg-t-accent px-3 py-1 text-xs font-medium text-t-accent-fg">Am beliebtesten</span>}
<h3 className="text-lg font-semibold">{plan.name}</h3>
<p className="text-sm text-t-muted">{plan.text}</p>
<p className="mt-6 flex items-end gap-1">
<span className="relative h-14 overflow-hidden">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={price}
initial={{ y: "100%", opacity: 0 }}
animate={{ y: "0%", opacity: 1 }}
exit={{ y: "-100%", opacity: 0 }}
transition={{ duration: 0.4, ease }}
className={cn(heading, "block text-5xl")}
>
{price} €
</motion.span>
</AnimatePresence>
</span>
<span className="pb-2 text-sm text-t-muted">/ Monat</span>
</p>
<ul className="mt-8 flex-1 space-y-3 text-sm">
{splitList(plan.features, "\n").map((feature) => (
<li key={feature} className="flex items-center gap-2.5">
<CheckIcon className="size-4 text-t-accent" /> {feature}
</li>
))}
</ul>
<a href="#" className={cn("mt-8 rounded-full py-3 text-center text-sm font-medium transition-opacity hover:opacity-85", featured ? "bg-t-accent text-t-accent-fg" : "bg-t-fg text-t-bg")}>
{plan.name} wählen
</a>
</article>
</Reveal>
)
})}
</div>
</section>
)
}