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