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

412 lines
23 KiB
TypeScript

"use client"
import { motion } from "motion/react"
import { useState } from "react"
import {
ArrowLeftIcon,
ArrowRightIcon,
BadgeCheckIcon,
CheckIcon,
CircleCheckIcon,
ClockIcon,
LeafIcon,
PhoneIcon,
PiggyBankIcon,
ShieldCheckIcon,
StarIcon,
SunIcon,
WrenchIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { TField } from "../shared/fields"
import { splitList, useCountdown } from "../shared/hooks"
import { TImage } from "../shared/media"
import { Accordion, Marquee, Reveal, RevealWords, StepPanel, ease, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { kampagne, type KampagneData } from "./definition"
import { fonts } from "./fonts"
const em = "text-t-accent"
const container = "mx-auto w-full max-w-[1240px] px-5 md:px-8"
const heading = "font-t-display tracking-[-0.03em]"
const trustIcons = [BadgeCheckIcon, ShieldCheckIcon, PiggyBankIcon, ClockIcon]
const benefitIcons = [WrenchIcon, BadgeCheckIcon, ClockIcon, LeafIcon]
const euro = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR", maximumFractionDigits: 0 })
function Stars({ className }: { className?: string }) {
return (
<span className={cn("flex text-amber-400", className)}>
{Array.from({ length: 5 }, (_, i) => (
<StarIcon key={i} className="size-4 fill-current" />
))}
</span>
)
}
function Countdown({ deadline, compact }: { deadline: string; compact?: boolean }) {
const time = useCountdown(deadline)
const parts = [
{ value: time?.days, label: "Tage" },
{ value: time?.hours, label: "Std" },
{ value: time?.minutes, label: "Min" },
{ value: time?.seconds, label: "Sek" },
]
const pad = (value?: number) => (value === undefined ? "--" : String(value).padStart(2, "0"))
if (compact) {
return <span className="font-mono tabular-nums">{time?.done ? "beendet" : `${pad(time?.days)}T ${pad(time?.hours)}:${pad(time?.minutes)}:${pad(time?.seconds)}`}</span>
}
return (
<div className="flex gap-2">
{parts.map((part) => (
<div key={part.label} className="w-16 rounded-2xl bg-t-fg py-3 text-center text-t-bg md:w-20">
<p className={cn(heading, "text-2xl tabular-nums md:text-3xl")}>{pad(part.value)}</p>
<p className="text-[10px] uppercase tracking-wider opacity-60">{part.label}</p>
</div>
))}
</div>
)
}
export function KampagneTemplate({ content }: TemplateProps<KampagneData>) {
const { brand, offer, hero, quiz, trust, benefits, calculator, steps, reviews, guarantee, faq } = content.data
const show = (key: string) => !content.hidden.includes(key)
return (
<TemplateShell def={kampagne} content={content} fonts={fonts[content.fonts] ?? fonts.stark} className="max-md:[--t-safe-bottom:4.75rem]">
<div className="bg-t-fg py-2.5 text-center text-sm text-t-bg">
<span className="font-semibold text-t-accent">{offer.badge}:</span> {offer.deadlineLabel} <Countdown deadline={offer.deadline} compact />
</div>
<Nav brand={brand} cta={quiz.title} />
<section id="top" className={cn(container, "grid items-start gap-12 pt-10 pb-16 md:pt-16 lg:grid-cols-[1.1fr_0.9fr]")}>
<div className="lg:pt-6">
<motion.span initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease }} className="inline-flex items-center gap-2 rounded-full bg-t-accent px-4 py-1.5 text-sm font-bold text-t-accent-fg">
<SunIcon className="size-4" /> {offer.badge} · nur bis {offer.deadline.split("-").reverse().join(".")}
</motion.span>
<RevealWords as="h1" onMount delay={0.1} text={hero.title} emClassName={em} className={cn(heading, "mt-6 text-[clamp(2.75rem,6.5vw,5.5rem)] leading-[0.98]")} />
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.5 }}>
<p className="mt-6 max-w-xl text-lg leading-relaxed text-t-muted">{hero.text}</p>
<ul className="mt-6 space-y-2.5">
{splitList(hero.bullets, "\n").map((bullet) => (
<li key={bullet} className="flex items-center gap-3 font-medium">
<span className="grid size-6 place-items-center rounded-full bg-t-accent text-t-accent-fg">
<CheckIcon className="size-3.5" />
</span>
{bullet}
</li>
))}
</ul>
<div className="mt-8 flex items-center gap-3 text-sm">
<Stars />
<span className="font-bold">{hero.rating}</span>
<span className="text-t-muted">aus {hero.reviews}</span>
</div>
<div className="mt-8">
<p className="mb-3 text-sm font-semibold">{offer.deadlineLabel}</p>
<Countdown deadline={offer.deadline} />
</div>
</motion.div>
</div>
<motion.div id="angebot" initial={{ opacity: 0, y: 30 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.9, ease, delay: 0.3 }} className="scroll-mt-6">
<Quiz quiz={quiz} image={hero.image} />
</motion.div>
</section>
{show("trust") && (
<section className="border-y border-t-fg/10 bg-t-surface">
<ul className={cn(container, "grid grid-cols-2 gap-4 py-6 md:grid-cols-4")}>
{trust.items.map((item, i) => {
const Icon = trustIcons[i % trustIcons.length]
return (
<li key={i} className="flex items-center gap-3 text-sm font-semibold">
<Icon className="size-6 shrink-0 text-t-accent" /> {item.label}
</li>
)
})}
</ul>
</section>
)}
{show("benefits") && (
<section className={cn(container, "py-20 md:py-28")}>
<RevealWords text={benefits.title} emClassName={em} className={cn(heading, "text-center text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
<div className="mt-12 grid gap-4 md:grid-cols-2">
{benefits.items.map((item, i) => {
const Icon = benefitIcons[i % benefitIcons.length]
return (
<Reveal key={i} delay={(i % 2) * 0.08}>
<article className="flex h-full gap-5 rounded-3xl border border-t-fg/10 p-7">
<span className="grid size-14 shrink-0 place-items-center rounded-2xl bg-t-accent text-t-accent-fg">
<Icon className="size-6" />
</span>
<div>
<h3 className={cn(heading, "text-xl")}>{item.title}</h3>
<p className="mt-2 leading-relaxed text-t-muted">{item.text}</p>
</div>
</article>
</Reveal>
)
})}
</div>
</section>
)}
{show("calculator") && <Calculator calculator={calculator} />}
{show("steps") && (
<section className={cn(container, "py-20 md:py-28")}>
<RevealWords text={steps.title} emClassName={em} className={cn(heading, "text-center text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
<ol className="mt-12 grid gap-4 md:grid-cols-4">
{steps.items.map((step, i) => (
<Reveal key={i} delay={i * 0.1}>
<li className="relative h-full rounded-3xl bg-t-surface p-6">
<span className={cn(heading, "text-5xl text-t-accent")}>{i + 1}</span>
<p className={cn(heading, "mt-4 text-xl")}>{step.title}</p>
<p className="mt-1 text-t-muted">{step.text}</p>
</li>
</Reveal>
))}
</ol>
</section>
)}
{show("reviews") && (
<section className="bg-t-surface py-20 md:py-28">
<div className={cn(container, "text-center")}>
<Stars className="justify-center" />
<RevealWords text={reviews.title} emClassName={em} className={cn(heading, "mt-4 text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
<p className="mt-3 text-t-muted">Ø {hero.rating} von 5 Sternen · {hero.reviews}</p>
</div>
<div className="mt-12">
<Marquee duration={45}>
{reviews.items.map((review, i) => (
<figure key={i} className="mx-3 w-80 shrink-0 rounded-3xl bg-t-bg p-6 shadow-sm">
<Stars />
<blockquote className="mt-3 leading-relaxed">„{review.quote}“</blockquote>
<figcaption className="mt-4 text-sm font-semibold text-t-muted">{review.author}</figcaption>
</figure>
))}
</Marquee>
</div>
</section>
)}
{show("guarantee") && (
<section className={cn(container, "py-16")}>
<Reveal>
<div className="flex flex-col items-center gap-6 rounded-[2rem] border-2 border-dashed border-t-accent p-8 text-center md:flex-row md:text-left">
<ShieldCheckIcon className="size-16 shrink-0 text-t-accent" />
<div>
<RevealWords text={guarantee.title} emClassName={em} className={cn(heading, "text-3xl")} />
<p className="mt-2 text-t-muted">{guarantee.text}</p>
</div>
</div>
</Reveal>
</section>
)}
{show("faq") && (
<section className={cn(container, "max-w-3xl py-16 md:py-24")}>
<h2 className={cn(heading, "text-center text-[clamp(2rem,4vw,3rem)]")}>Häufige Fragen</h2>
<Accordion items={faq.items} className="mt-10 space-y-3" itemClassName="rounded-2xl bg-t-surface px-6" questionClassName="py-5 font-semibold" answerClassName="pb-6 leading-relaxed text-t-muted" />
</section>
)}
<section className={cn(container, "pb-24")}>
<div className="rounded-[2.5rem] bg-t-fg px-6 py-16 text-center text-t-bg md:py-20">
<p className={cn(heading, "mx-auto max-w-2xl text-[clamp(2rem,4.5vw,3.5rem)] leading-[1.05]")}>Sichern Sie sich den Aktionspreis</p>
<p className="mt-4 opacity-70">{offer.deadlineLabel} <Countdown deadline={offer.deadline} compact /></p>
<a href="#angebot" className="mt-8 inline-flex items-center gap-2 rounded-full bg-t-accent px-8 py-4 font-bold text-t-accent-fg transition-transform hover:scale-105">
{quiz.title} <ArrowRightIcon className="size-4" />
</a>
</div>
</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>{brand.name} · {brand.suffix} · {brand.phone}</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>
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-t-fg/10 bg-t-bg/95 p-3 backdrop-blur md:hidden">
<a href="#angebot" className="flex items-center justify-between rounded-full bg-t-accent px-5 py-3.5 font-bold text-t-accent-fg">
{quiz.title}
<span className="text-xs font-medium opacity-80"><Countdown deadline={offer.deadline} compact /></span>
</a>
</div>
</TemplateShell>
)
}
function Nav({ brand, cta }: { brand: KampagneData["brand"]; cta: string }) {
const { scrolled } = useScrollNav()
return (
<header className={cn("sticky top-0 z-50 border-b transition-colors duration-500", scrolled ? "border-t-fg/10 bg-t-bg/90 backdrop-blur-xl" : "border-transparent bg-t-bg")}>
<nav className={cn(container, "flex h-18 items-center justify-between gap-4 py-3")}>
<a href="#top" className="flex items-center gap-2.5">
<span className="grid size-10 place-items-center rounded-full bg-t-accent text-t-accent-fg">
<SunIcon className="size-5" />
</span>
<span className="leading-tight">
<span className={cn(heading, "block text-lg")}>{brand.name}</span>
<span className="text-xs text-t-muted">{brand.suffix}</span>
</span>
</a>
<div className="flex items-center gap-3">
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className="hidden items-center gap-2 text-sm font-semibold md:flex">
<PhoneIcon className="size-4 text-t-accent" /> {brand.phone}
</a>
<a href="#angebot" className="hidden rounded-full bg-t-fg px-5 py-2.5 text-sm font-bold text-t-bg sm:block">{cta}</a>
</div>
</nav>
</header>
)
}
function Quiz({ quiz, image }: { quiz: KampagneData["quiz"]; image: string }) {
const questions = quiz.questions.filter((item) => item.question.trim())
const [step, setStep] = useState(0)
const [answers, setAnswers] = useState<string[]>([])
const [done, setDone] = useState(false)
const total = questions.length + 1
const progress = done ? 100 : (step / total) * 100
return (
<div className="overflow-hidden rounded-[2rem] bg-t-bg shadow-[0_40px_80px_-30px_rgba(0,0,0,0.35)] ring-1 ring-t-fg/10">
<div className="relative h-36 overflow-hidden">
<TImage src={image} eager sizes="(min-width: 1024px) 40vw, 100vw" />
<div className="absolute inset-0 bg-gradient-to-t from-black/75 to-black/10" />
<div className="absolute inset-x-6 bottom-4 text-white">
<p className={cn(heading, "text-2xl")}>{quiz.title}</p>
<p className="text-sm opacity-80">{quiz.text}</p>
</div>
</div>
<div className="p-6 md:p-8">
<div className="mb-2 flex justify-between text-xs font-semibold text-t-muted">
<span>{done ? "Fertig" : `Schritt ${step + 1} von ${total}`}</span>
<span>{Math.round(progress)} %</span>
</div>
<div className="mb-7 h-2 overflow-hidden rounded-full bg-t-surface">
<motion.div className="h-full rounded-full bg-t-accent" animate={{ width: `${progress}%` }} transition={{ duration: 0.5, ease }} />
</div>
{done ? (
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="py-6 text-center">
<CircleCheckIcon className="mx-auto size-16 text-t-accent" />
<p className={cn(heading, "mt-4 text-2xl")}>{quiz.success}</p>
<p className="mt-4 text-sm opacity-60">Demo-Hinweis: Live landet die Anfrage direkt im CRM bzw. Postfach.</p>
</motion.div>
) : (
<StepPanel step={step}>
{step < questions.length ? (
<div>
<p className={cn(heading, "text-xl md:text-2xl")}>{questions[step].question}</p>
<div className="mt-5 grid gap-2.5 sm:grid-cols-2">
{splitList(questions[step].options, "|").map((option) => (
<button
key={option}
type="button"
onClick={() => {
setAnswers((list) => [...list.slice(0, step), option])
setStep(step + 1)
}}
className={cn(
"cursor-pointer rounded-2xl border-2 px-4 py-4 text-left font-semibold transition-all hover:-translate-y-0.5 hover:border-t-accent",
answers[step] === option ? "border-t-accent bg-t-surface" : "border-t-fg/10"
)}
>
{option}
</button>
))}
</div>
</div>
) : (
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault()
setDone(true)
}}
>
<p className={cn(heading, "text-xl md:text-2xl")}>Wohin dürfen wir Ihr Angebot schicken?</p>
<TField label="Name" name="name" required autoComplete="name" />
<div className="grid grid-cols-[7rem_1fr] gap-3">
<TField label="PLZ" name="zip" required inputMode="numeric" />
<TField label="Telefon" name="phone" type="tel" required autoComplete="tel" />
</div>
<button type="submit" className="mt-1 inline-flex cursor-pointer items-center justify-center gap-2 rounded-full bg-t-accent py-4 font-bold text-t-accent-fg transition-transform hover:scale-[1.02]">
Kostenloses Angebot anfordern <ArrowRightIcon className="size-4" />
</button>
<p className="text-center text-xs text-t-muted">100 % kostenlos und unverbindlich</p>
</form>
)}
</StepPanel>
)}
{!done && step > 0 && (
<button type="button" onClick={() => setStep(step - 1)} className="mt-5 flex cursor-pointer items-center gap-1.5 text-sm text-t-muted hover:text-t-fg">
<ArrowLeftIcon className="size-4" /> Zurück
</button>
)}
</div>
</div>
)
}
function Calculator({ calculator }: { calculator: KampagneData["calculator"] }) {
const min = Number(calculator.min) || 50
const max = Math.max(min + 10, Number(calculator.max) || 400)
const factor = Number(calculator.factor.replace(",", ".")) || 0.7
const [bill, setBill] = useState(Math.round((min + max) / 2 / 10) * 10)
const yearly = bill * 12 * factor
return (
<section className="bg-t-fg py-20 text-t-bg md:py-28">
<div className={cn(container, "grid items-center gap-12 lg:grid-cols-2")}>
<div>
<RevealWords text={calculator.title} emClassName={em} className={cn(heading, "text-[clamp(2.25rem,4.5vw,3.75rem)] leading-[1.05]")} />
<p className="mt-4 text-lg opacity-70">{calculator.text}</p>
<div className="mt-10">
<div className="flex items-end justify-between">
<span className="text-sm opacity-70">Stromrechnung pro Monat</span>
<span className={cn(heading, "text-4xl")}>{euro.format(bill)}</span>
</div>
<input
type="range"
min={min}
max={max}
step={10}
value={bill}
onChange={(event) => setBill(Number(event.target.value))}
aria-label="Monatliche Stromrechnung"
className="mt-4 w-full cursor-pointer accent-[var(--t-accent)]"
/>
<div className="mt-1 flex justify-between text-xs opacity-50">
<span>{euro.format(min)}</span>
<span>{euro.format(max)}</span>
</div>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{[
{ label: "Ersparnis pro Jahr", value: yearly },
{ label: "Ersparnis in 20 Jahren", value: yearly * 20 },
].map((item) => (
<div key={item.label} className="rounded-3xl bg-t-bg/10 p-7">
<p className="text-sm opacity-70">{item.label}</p>
<motion.p key={Math.round(item.value)} initial={{ opacity: 0.4, y: 6 }} animate={{ opacity: 1, y: 0 }} className={cn(heading, "mt-2 text-4xl text-t-accent md:text-5xl")}>
{euro.format(item.value)}
</motion.p>
</div>
))}
<p className="text-xs opacity-50 sm:col-span-2">Unverbindliche Beispielrechnung. Die genaue Ersparnis ermitteln wir im kostenlosen Check.</p>
</div>
</div>
</section>
)
}