since3-portal/modules/templates/catalog/blocks/offer.tsx
2026-10-11 18:22:32 +02:00

255 lines
14 KiB
TypeScript

"use client"
import { motion } from "motion/react"
import type { PointerEvent, ReactNode } from "react"
import { ArrowUpRightIcon, CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { DemoForm } from "../shared/demo-form"
import { TArea, TField } from "../shared/fields"
import { splitList } from "../shared/hooks"
import { Accordion, Reveal } from "../shared/motion"
import type { CtaPortalData, FaqData, KontaktFormularData, PaketeData } from "./definitions"
import type { BlockProps } from "./types"
import { Action, actionClass, container, Eyebrow, Heading, Magnetic, telHref } from "./ui"
// ── Pakete ───────────────────────────────────────────────────────────────────
type Plan = PaketeData["plans"][number]
// Umlaufender Lichtrand: ein drehender Kegelverlauf hinter der Karte, sichtbar im 1-px-Rand
function GlowBorder({ children }: { children: ReactNode }) {
return (
<div className="relative h-full overflow-hidden rounded-[1.75rem] bg-t-fg/10 p-px">
<motion.div
aria-hidden
className="absolute top-1/2 left-1/2 aspect-square w-[250%] -translate-x-1/2 -translate-y-1/2 bg-[conic-gradient(from_0deg,transparent_0deg,var(--t-accent)_70deg,transparent_140deg,transparent_180deg,var(--t-accent)_250deg,transparent_320deg)]"
animate={{ rotate: 360 }}
transition={{ duration: 7, repeat: Infinity, ease: "linear" }}
/>
<div className="relative h-full rounded-[calc(1.75rem-1px)] bg-t-bg">{children}</div>
</div>
)
}
function PlanCard({ plan, href, featured }: { plan: Plan; href: string; featured: boolean }) {
const features = splitList(plan.features, "\n")
return (
<div
className={cn(
"flex h-full flex-col rounded-[inherit] p-7 md:p-9",
featured && "bg-[radial-gradient(120%_60%_at_50%_0%,color-mix(in_oklab,var(--t-accent)_14%,transparent),transparent)]"
)}
>
<div className="flex items-center justify-between gap-4">
<h3 className="font-t-display text-2xl tracking-[-0.01em]">{plan.name}</h3>
{featured && <span className="rounded-full bg-t-accent px-3 py-1 text-xs font-medium text-t-accent-fg">{plan.ribbon}</span>}
</div>
{plan.text.trim() && <p className="mt-2 text-sm text-t-muted">{plan.text}</p>}
<p className="mt-8 flex flex-wrap items-baseline gap-x-2">
<span className="font-t-display text-5xl tracking-[-0.03em]">{plan.price}</span>
{plan.period.trim() && <span className="text-sm text-t-muted">{plan.period}</span>}
</p>
{features.length > 0 && (
<ul className="mt-8 flex flex-col gap-3 border-t border-t-fg/10 pt-8 text-sm">
{features.map((feature, i) => (
<li key={i} className="flex gap-3">
<CheckIcon className="mt-0.5 size-4 shrink-0 text-t-accent" />
{feature}
</li>
))}
</ul>
)}
<div className="mt-10 flex flex-1 items-end">
<Action href={href} variant={featured ? "solid" : "outline"} className="w-full">
{plan.cta}
</Action>
</div>
</div>
)
}
export function Pakete({ data }: BlockProps<PaketeData>) {
return (
<section className={cn(container, "py-24 md:py-36")}>
<div className="mx-auto max-w-3xl text-center">
<Eyebrow className="justify-center">{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6" />
{data.text.trim() && <p className="mx-auto mt-6 max-w-xl text-lg text-t-muted">{data.text}</p>}
</div>
<div className={cn("mx-auto mt-14 grid gap-4 md:mt-16", data.plans.length >= 3 ? "max-w-6xl lg:grid-cols-3" : "max-w-4xl md:grid-cols-2")}>
{data.plans.map((plan, i) => (
<Reveal key={i} delay={i * 0.08} className="h-full">
{plan.ribbon.trim() ? (
<GlowBorder>
<PlanCard plan={plan} href={data.href} featured />
</GlowBorder>
) : (
<div className="h-full rounded-[1.75rem] border border-t-fg/10">
<PlanCard plan={plan} href={data.href} featured={false} />
</div>
)}
</Reveal>
))}
</div>
</section>
)
}
// ── FAQ ──────────────────────────────────────────────────────────────────────
export function Faq({ data, brand }: BlockProps<FaqData>) {
return (
<section className={cn(container, "grid gap-12 py-24 md:grid-cols-12 md:gap-6 md:py-36")}>
<div className="md:col-span-4">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6 text-[clamp(2.5rem,4.5vw,4.5rem)]" />
{data.text.trim() && <p className="mt-6 max-w-sm text-t-muted">{data.text}</p>}
{brand.email.trim() && (
<a href={`mailto:${brand.email}`} className="mt-5 inline-block text-sm font-medium underline decoration-t-accent decoration-2 underline-offset-4">
{brand.email}
</a>
)}
</div>
<Reveal className="md:col-span-7 md:col-start-6">
<Accordion
items={data.items}
className="border-t border-t-fg/15"
itemClassName="border-b border-t-fg/15"
questionClassName="py-6 font-t-display text-xl tracking-[-0.01em] md:py-7 md:text-2xl"
answerClassName="max-w-2xl pb-7 leading-relaxed text-t-muted"
/>
</Reveal>
</section>
)
}
// ── Leuchtender Aufruf ───────────────────────────────────────────────────────
export function CtaPortal({ data }: BlockProps<CtaPortalData>) {
function onMove(event: PointerEvent<HTMLDivElement>) {
const rect = event.currentTarget.getBoundingClientRect()
event.currentTarget.style.setProperty("--x", `${event.clientX - rect.left}px`)
event.currentTarget.style.setProperty("--y", `${event.clientY - rect.top}px`)
}
return (
<section className="p-3 md:p-6">
<div onPointerMove={onMove} className="relative isolate overflow-hidden rounded-[2rem] bg-t-surface px-6 py-24 text-center md:rounded-[3rem] md:py-36">
<motion.div
aria-hidden
className="absolute top-full left-1/2 -z-10 aspect-square w-[130%] max-w-[1600px] -translate-x-1/2 -translate-y-[38%] rounded-full"
style={{ background: "radial-gradient(closest-side, var(--t-accent), transparent)" }}
animate={{ scale: [1, 1.08], opacity: [0.5, 0.8] }}
transition={{ duration: 5, repeat: Infinity, repeatType: "mirror", ease: "easeInOut" }}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 [background:radial-gradient(520px_circle_at_var(--x,50%)_var(--y,50%),color-mix(in_oklab,var(--t-accent)_22%,transparent),transparent_60%)]"
/>
<div className="relative mx-auto max-w-4xl">
<Eyebrow className="justify-center">{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6 text-[clamp(2.75rem,7vw,7rem)] leading-[0.92]" />
{data.text.trim() && (
<Reveal delay={0.1}>
<p className="mx-auto mt-6 max-w-lg text-lg text-t-muted">{data.text}</p>
</Reveal>
)}
<Reveal delay={0.2} className="mt-10">
<Magnetic>
<Action href={data.href} className="px-8 py-4 text-base">
{data.cta}
</Action>
</Magnetic>
</Reveal>
</div>
</div>
</section>
)
}
// ── Kontakt mit Formular ─────────────────────────────────────────────────────
export function KontaktFormular({ data, brand }: BlockProps<KontaktFormularData>) {
const topics = splitList(data.topics)
const details = [
{ label: "E-Mail", value: brand.email, href: `mailto:${brand.email}` },
{ label: "Telefon", value: brand.phone, href: telHref(brand.phone) },
{ label: "Adresse", value: brand.address, href: "" },
{ label: "Erreichbar", value: data.hours, href: "" },
].filter((detail) => detail.value.trim())
return (
<section className={cn(container, "py-24 md:py-36")}>
<div className="grid gap-14 md:grid-cols-12 md:gap-6">
<div className="md:col-span-5">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6 text-[clamp(2.75rem,6vw,6rem)] leading-[0.92]" />
{data.text.trim() && (
<Reveal delay={0.1}>
<p className="mt-8 max-w-md text-lg leading-relaxed text-t-muted">{data.text}</p>
</Reveal>
)}
<Reveal delay={0.15}>
<dl className="mt-12 grid gap-6 sm:grid-cols-2 md:grid-cols-1">
{details.map((detail) => (
<div key={detail.label}>
<dt className="text-[11px] tracking-[0.22em] text-t-muted uppercase">{detail.label}</dt>
<dd className="mt-1.5 text-lg">
{detail.href ? (
<a
href={detail.href}
className="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat pb-0.5 transition-[background-size] duration-500 hover:bg-[length:100%_1px]"
>
{detail.value}
</a>
) : (
detail.value
)}
</dd>
</div>
))}
</dl>
</Reveal>
</div>
<Reveal delay={0.1} className="md:col-span-6 md:col-start-7">
<DemoForm
className="rounded-[1.75rem] border border-t-fg/10 bg-t-surface/50 p-6 md:p-10"
success={
<div className="py-12 text-center">
<p className="font-t-display text-4xl tracking-[-0.02em] md:text-5xl">Danke!</p>
<p className="mx-auto mt-4 max-w-sm text-t-muted">{data.success}</p>
</div>
}
>
{topics.length > 0 && (
<fieldset className="mb-8">
<legend className="text-[11px] tracking-[0.22em] uppercase opacity-60">Worum geht es?</legend>
<div className="mt-3 flex flex-wrap gap-2">
{topics.map((topic, i) => (
<label key={topic} className="cursor-pointer">
<input type="radio" name="topic" value={topic} defaultChecked={i === 0} className="peer sr-only" />
<span className="block rounded-full border border-t-fg/15 px-4 py-2 text-sm transition-colors peer-checked:border-t-accent peer-checked:bg-t-accent peer-checked:text-t-accent-fg peer-focus-visible:ring-2 peer-focus-visible:ring-t-accent/50">
{topic}
</span>
</label>
))}
</div>
</fieldset>
)}
<div className="grid gap-6 sm:grid-cols-2">
<TField variant="line" label="Name" name="name" autoComplete="name" required />
<TField variant="line" label="E-Mail" name="email" type="email" autoComplete="email" required />
<TField variant="line" label="Telefon (optional)" name="phone" type="tel" autoComplete="tel" className="sm:col-span-2" />
<TArea variant="line" label="Nachricht" name="message" rows={4} required className="sm:col-span-2" />
</div>
<button type="submit" className={cn(actionClass("solid"), "mt-10 w-full sm:w-auto")}>
{data.button.trim() || "Senden"}
<ArrowUpRightIcon className="size-4 transition-transform duration-300 group-hover:rotate-45" />
</button>
</DemoForm>
</Reveal>
</div>
</section>
)
}