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