since3-portal/modules/templates/catalog/nutzfahrzeuge/template.tsx
2026-10-10 02:38:50 +02:00

642 lines
38 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 } from "motion/react"
import { useState } from "react"
import {
ArrowRightIcon,
ArrowUpRightIcon,
BanknoteIcon,
CalendarIcon,
FileCheckIcon,
GaugeIcon,
HammerIcon,
MapPinIcon,
PhoneIcon,
SettingsIcon,
ShieldCheckIcon,
SlidersHorizontalIcon,
TruckIcon,
WrenchIcon,
XIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import type { TemplateProps } from "../../lib/types"
import { DemoForm } from "../shared/demo-form"
import { TArea, TField, TSelect } from "../shared/fields"
import { splitList } from "../shared/hooks"
import { TImage } from "../shared/media"
import { Counter, Reveal, RevealWords, ease, useScrollNav } from "../shared/motion"
import { TemplateShell } from "../shared/shell"
import { nutzfahrzeuge, type NutzfahrzeugeData } from "./definition"
import { fonts } from "./fonts"
type Vehicle = NutzfahrzeugeData["vehicles"]["items"][number]
const em = "text-t-accent"
const container = "mx-auto w-full max-w-[1400px] px-5 md:px-8"
const display = "font-t-display font-bold uppercase tracking-[-0.01em]"
const serviceIcons = [WrenchIcon, BanknoteIcon, FileCheckIcon, HammerIcon, ShieldCheckIcon, ShieldCheckIcon]
const ALL = "Alle"
const euro = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR", maximumFractionDigits: 0 })
const SORTS = { price: "Preis aufsteigend", priceDesc: "Preis absteigend", km: "Kilometerstand", year: "Neueste zuerst" } as const
const num = (value: string) => Number(value.replace(/\./g, "").replace(",", ".")) || 0
// Annuität: Rate = Betrag · i / (1 − (1 + i)^−n)
function monthlyRate(amount: number, ratePercent: number, months: number) {
const i = ratePercent / 100 / 12
if (amount <= 0) return 0
return i === 0 ? amount / months : (amount * i) / (1 - (1 + i) ** -months)
}
function Eyebrow({ children }: { children: string }) {
return (
<p className="mb-5 flex items-center gap-3 text-xs font-semibold uppercase tracking-[0.25em] text-t-accent">
<span className="h-0.5 w-8 bg-t-accent" />
{children}
</p>
)
}
export function NutzfahrzeugeTemplate({ content }: TemplateProps<NutzfahrzeugeData>) {
const { brand, hero, stats, vehicles, financing, purchase, about, services, testimonials, locations } = content.data
const show = (key: string) => !content.hidden.includes(key)
const rate = num(vehicles.rate) || 4.9
const types = [ALL, ...new Set(vehicles.items.map((v) => v.type))]
const [type, setType] = useState(ALL)
const [maxPrice, setMaxPrice] = useState(0)
const [selected, setSelected] = useState<Vehicle | null>(null)
const links = [
{ href: "#bestand", label: "Bestand", visible: true },
{ href: "#ueber-uns", label: "Über uns", visible: show("about") },
{ href: "#finanzierung", label: "Finanzierung", visible: show("financing") },
{ href: "#ankauf", label: "Ankauf", visible: show("purchase") },
{ href: "#standorte", label: locations.eyebrow, visible: true },
].filter((link) => link.visible)
return (
<TemplateShell def={nutzfahrzeuge} content={content} fonts={fonts[content.fonts] ?? fonts.technik}>
<Nav brand={brand} links={links} />
<section id="top" className="relative flex min-h-svh flex-col justify-end overflow-hidden">
<motion.div className="absolute inset-0" initial={{ scale: 1.15 }} animate={{ scale: 1 }} transition={{ duration: 2, ease }}>
<TImage src={hero.image} eager />
</motion.div>
<div className="absolute inset-0 bg-gradient-to-r from-t-bg via-t-bg/75 to-t-bg/10" />
<div className="absolute inset-0 bg-gradient-to-t from-t-bg via-transparent to-transparent" />
<motion.div
aria-hidden
initial={{ x: "-100%" }}
animate={{ x: "0%" }}
transition={{ duration: 1.2, ease, delay: 0.2 }}
className="absolute top-0 bottom-0 left-[38%] hidden w-24 -skew-x-12 bg-t-accent/15 lg:block"
/>
<div className={cn(container, "relative pt-36 pb-10")}>
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: 0.7, ease, delay: 0.2 }}>
<Eyebrow>{hero.eyebrow}</Eyebrow>
</motion.div>
<RevealWords as="h1" onMount delay={0.3} text={hero.title} emClassName={em} className={cn(display, "max-w-4xl text-[clamp(3rem,8vw,7.5rem)] leading-[0.92]")} />
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 0.8 }}>
<p className="mt-7 max-w-xl text-lg leading-relaxed text-t-fg/80">{hero.text}</p>
<div className="mt-8 flex flex-wrap gap-3">
<a href="#bestand" className="inline-flex items-center gap-2 bg-t-accent px-7 py-4 font-bold text-t-accent-fg uppercase transition-transform hover:-translate-y-0.5">
{hero.cta} <ArrowRightIcon className="size-4" />
</a>
{show("purchase") && (
<a href="#ankauf" className="inline-flex items-center gap-2 border border-t-fg/30 px-7 py-4 font-bold uppercase transition-colors hover:border-t-accent hover:text-t-accent">
{hero.ctaSecondary}
</a>
)}
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease, delay: 1.1 }}
className={cn("mt-12 grid gap-3 border border-t-fg/10 bg-t-surface/90 p-4 backdrop-blur md:grid-cols-[1fr_1fr_auto] md:items-end", vehicles.items.length === 0 && "hidden")}
>
<TSelect label="Fahrzeugtyp" variant="glass" value={type} onChange={(e) => setType(e.target.value)} options={types} />
<TSelect
label="Preis bis"
variant="glass"
value={maxPrice ? euro.format(maxPrice) : "Beliebig"}
onChange={(e) => setMaxPrice(num(e.target.value.replace(/[^\d]/g, "")))}
options={["Beliebig", ...[40000, 60000, 80000, 100000].map((v) => euro.format(v))]}
/>
<a href="#bestand" className="flex items-center justify-center gap-2 bg-t-accent px-6 py-3.5 font-bold text-t-accent-fg uppercase">
<SlidersHorizontalIcon className="size-4" /> Fahrzeuge finden
</a>
</motion.div>
</div>
</section>
{show("stats") && (
<section data-order="stats" className="bg-t-accent py-10 text-t-accent-fg">
<dl className={cn(container, "grid grid-cols-2 gap-8 md:grid-cols-4")}>
{stats.items.map((stat, i) => (
<div key={i}>
<dd className={cn(display, "text-5xl")}>
<Counter value={stat.value} />
{stat.suffix}
</dd>
<dt className="mt-1 text-sm font-medium opacity-80">{stat.label}</dt>
</div>
))}
</dl>
</section>
)}
<Inventory vehicles={vehicles} rate={rate} types={types} type={type} maxPrice={maxPrice} onType={setType} onReset={() => setMaxPrice(0)} onSelect={setSelected} />
{show("financing") && <Financing data={financing} rate={rate} />}
{show("purchase") && (
<section data-order="purchase" id="ankauf" className="relative overflow-hidden py-24 md:py-32">
<TImage src={purchase.image} sizes="100vw" />
<div className="absolute inset-0 bg-t-bg/85" />
<div className={cn(container, "relative grid items-center gap-12 lg:grid-cols-2")}>
<div>
<Reveal><Eyebrow>{purchase.eyebrow}</Eyebrow></Reveal>
<RevealWords text={purchase.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,6vw,5rem)] leading-[0.95]")} />
<Reveal delay={0.1}><p className="mt-6 max-w-md text-lg text-t-fg/80">{purchase.text}</p></Reveal>
<ol className="mt-8 grid gap-3 sm:grid-cols-3">
{splitList(purchase.steps).map((step, i) => (
<li key={step} className="border-l-2 border-t-accent pl-3 text-sm">
<span className={cn(display, "block text-2xl text-t-accent")}>0{i + 1}</span>
{step}
</li>
))}
</ol>
</div>
<Reveal delay={0.1}>
<DemoForm
className="grid gap-4 bg-t-surface p-6 sm:grid-cols-2 md:p-8"
success={<p className={cn(display, "py-8 text-3xl")}>Danke! Ihr Angebot kommt <span className={em}>in 24 Stunden.</span></p>}
>
<TField label="Marke & Modell" name="model" required variant="glass" className="sm:col-span-2" />
<TField label="Erstzulassung" name="year" inputMode="numeric" variant="glass" />
<TField label="Kilometerstand" name="km" inputMode="numeric" variant="glass" />
<TField label="Name" name="name" required autoComplete="name" variant="glass" />
<TField label="Telefon" name="phone" type="tel" required autoComplete="tel" variant="glass" />
<button type="submit" className="cursor-pointer bg-t-accent py-4 font-bold text-t-accent-fg uppercase sm:col-span-2">Kostenloses Angebot anfordern</button>
</DemoForm>
</Reveal>
</div>
</section>
)}
{show("about") && (
<section data-order="about" id="ueber-uns" className={cn(container, "grid items-center gap-12 py-24 md:py-32 lg:grid-cols-2")}>
<Reveal>
<div className="relative aspect-[4/5] overflow-hidden">
<TImage src={about.image} sizes="(min-width: 1024px) 45vw, 100vw" />
<div className="absolute inset-x-0 bottom-0 h-1.5 bg-t-accent" />
</div>
</Reveal>
<div>
<Reveal><Eyebrow>{about.eyebrow}</Eyebrow></Reveal>
<RevealWords text={about.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,5vw,4.5rem)] leading-[0.95]")} />
<Reveal delay={0.1}>
<div className="mt-6 space-y-4 text-lg leading-relaxed text-t-muted">
{splitList(about.text, /\n+/).map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
<ul className="mt-8 flex flex-wrap gap-2">
{splitList(about.values).map((value) => (
<li key={value} className="flex items-center gap-2 border border-t-fg/15 px-4 py-2 text-sm font-semibold uppercase tracking-wider">
<ShieldCheckIcon className="size-4 text-t-accent" /> {value}
</li>
))}
</ul>
</Reveal>
</div>
</section>
)}
{show("services") && (
<section data-order="services" className={cn(container, "grid gap-12 py-24 md:py-32 lg:grid-cols-[1fr_1.3fr]")}>
<div>
<Reveal><Eyebrow>{services.eyebrow}</Eyebrow></Reveal>
<RevealWords text={services.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,5vw,4.5rem)] leading-[0.95]")} />
<Reveal delay={0.1}>
<div className="relative mt-8 aspect-[4/3] overflow-hidden">
<TImage src={services.image} sizes="(min-width: 1024px) 40vw, 100vw" />
</div>
</Reveal>
</div>
<div className="grid gap-px self-end bg-t-fg/10 sm:grid-cols-2">
{services.items.map((item, i) => {
const Icon = serviceIcons[i % serviceIcons.length]
return (
<Reveal key={i} delay={(i % 2) * 0.08} className={cn("bg-t-bg p-6", i === services.items.length - 1 && i % 2 === 0 && "sm:col-span-2")}>
<Icon className="size-7 text-t-accent" />
<h3 className={cn(display, "mt-4 text-xl")}>{item.title}</h3>
<p className="mt-2 text-sm text-t-muted">{item.text}</p>
</Reveal>
)
})}
</div>
</section>
)}
{show("testimonials") && (
<section data-order="testimonials" className="bg-t-surface py-20">
<div className={cn(container, "grid gap-5 md:grid-cols-3")}>
{testimonials.items.map((item, i) => (
<Reveal key={i} delay={i * 0.1}>
<figure className="h-full border-t-2 border-t-accent bg-t-bg p-6">
<blockquote className="leading-relaxed">„{item.quote}“</blockquote>
<figcaption className="mt-5 text-sm">
<span className="font-bold">{item.author}</span> <span className="text-t-muted">· {item.role}</span>
</figcaption>
</figure>
</Reveal>
))}
</div>
</section>
)}
<section id="standorte" className={cn(container, "py-24")}>
<Reveal><Eyebrow>{locations.eyebrow}</Eyebrow></Reveal>
<RevealWords text={locations.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,5vw,4.5rem)] leading-[0.95]")} />
{locations.text && (
<Reveal delay={0.1}><p className="mt-6 max-w-2xl text-lg leading-relaxed text-t-muted">{locations.text}</p></Reveal>
)}
<div className="mt-10 grid gap-4 lg:grid-cols-[1fr_1.2fr]">
<div className="flex flex-col gap-4">
{locations.items.map((location, i) => (
<Reveal key={i} delay={i * 0.1}>
<div className="flex h-full flex-col gap-3 border border-t-fg/10 p-6 md:p-8">
<h3 className={cn(display, "text-2xl")}>{location.name}</h3>
<p className="flex items-center gap-2 text-t-muted"><MapPinIcon className="size-4 shrink-0 text-t-accent" /> {location.address}</p>
<p className="flex items-center gap-2 text-t-muted"><CalendarIcon className="size-4 shrink-0 text-t-accent" /> {location.hours}</p>
<div className="mt-2 flex flex-wrap items-center gap-x-6 gap-y-2">
<a href={`tel:${location.phone.replace(/\s/g, "")}`} className="flex items-center gap-2 font-bold"><PhoneIcon className="size-4 text-t-accent" /> {location.phone}</a>
<a
href={`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(location.address)}`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 text-sm font-semibold text-t-fg/70 uppercase transition-colors hover:text-t-accent"
>
Route planen <ArrowUpRightIcon className="size-4" />
</a>
</div>
</div>
</Reveal>
))}
<Reveal delay={0.2} className="flex-1">
<div className="flex h-full flex-col justify-end gap-1 bg-t-accent p-6 text-t-accent-fg md:p-8">
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.25em]">Direkt erreichen</p>
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className={cn(display, "text-[clamp(1.75rem,3vw,2.5rem)] [line-height:1.1]")}>{brand.phone}</a>
<a href={`mailto:${brand.email}`} className="break-all font-semibold hover:underline">{brand.email}</a>
</div>
</Reveal>
</div>
<Reveal delay={0.1}>
<DemoForm
className="grid gap-4 bg-t-surface p-6 sm:grid-cols-2 md:p-8"
success={<p className={cn(display, "py-8 text-3xl")}>Danke! Wir melden uns <span className={em}>schnellstmöglich.</span></p>}
>
<p className={cn(display, "text-2xl sm:col-span-2")}>Anfrage senden</p>
<TField label="Name" name="name" required autoComplete="name" variant="glass" />
<TField label="Telefon" name="phone" type="tel" autoComplete="tel" variant="glass" />
<TField label="E-Mail" name="email" type="email" required autoComplete="email" variant="glass" />
<TSelect label="Anliegen" name="topic" variant="glass" options={["Frage zu einem Fahrzeug", "Besichtigungstermin", "Fahrzeug verkaufen", "Sonstiges"]} />
<TArea label="Nachricht" name="message" variant="glass" className="sm:col-span-2" />
<button type="submit" className="cursor-pointer bg-t-accent py-4 font-bold text-t-accent-fg uppercase sm:col-span-2">Absenden</button>
</DemoForm>
</Reveal>
</div>
</section>
<footer className="border-t border-t-fg/10">
<div className={cn(container, "flex flex-col gap-4 py-8 text-sm text-t-muted md:flex-row md:justify-between")}>
<p>{brand.name} {brand.suffix} · {brand.email}</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>
</div>
</footer>
<Drawer vehicle={selected} rate={rate} onClose={() => setSelected(null)} />
</TemplateShell>
)
}
function Nav({ brand, links }: { brand: NutzfahrzeugeData["brand"]; links: { href: string; label: string }[] }) {
const { scrolled, hidden } = useScrollNav()
return (
<motion.header
animate={{ y: hidden ? "-100%" : "0%" }}
transition={{ duration: 0.45, ease }}
className={cn("fixed inset-x-0 top-0 z-50 border-b transition-colors duration-500", scrolled ? "border-t-fg/10 bg-t-bg/90 backdrop-blur-xl" : "border-transparent")}
>
<nav className={cn(container, "flex h-20 items-center justify-between gap-6")}>
{brand.logo ? (
<a href="#top" aria-label={brand.name} className="relative block h-14 w-24">
<TImage src={brand.logo} alt={brand.name} sizes="96px" eager className="object-contain object-left" />
</a>
) : (
<a href="#top" className="flex items-center gap-3">
<span className="grid size-10 place-items-center bg-t-accent text-t-accent-fg"><TruckIcon className="size-5" /></span>
<span className="leading-none">
<span className={cn(display, "block text-xl")}>{brand.name}</span>
<span className="text-[11px] font-semibold uppercase tracking-[0.25em] text-t-muted">{brand.suffix}</span>
</span>
</a>
)}
<div className="hidden items-center gap-8 text-sm font-semibold uppercase tracking-wider lg:flex">
{links.map((link) => (
<a key={link.href} href={link.href} className="text-t-fg/70 transition-colors hover:text-t-accent">{link.label}</a>
))}
</div>
<a href={`tel:${brand.phone.replace(/\s/g, "")}`} className="flex items-center gap-2 bg-t-fg px-4 py-2.5 text-sm font-bold text-t-bg">
<PhoneIcon className="size-4" /> <span className="hidden sm:inline">{brand.phone}</span>
</a>
</nav>
</motion.header>
)
}
function Inventory({ vehicles, rate, types, type, maxPrice, onType, onReset, onSelect }: {
vehicles: NutzfahrzeugeData["vehicles"]
rate: number
types: string[]
type: string
maxPrice: number
onType: (type: string) => void
onReset: () => void
onSelect: (vehicle: Vehicle) => void
}) {
const [sort, setSort] = useState<keyof typeof SORTS>("price")
const visible = vehicles.items
.filter((v) => (type === ALL || v.type === type) && (!maxPrice || num(v.price) <= maxPrice))
.sort((a, b) =>
sort === "price" ? num(a.price) - num(b.price) : sort === "priceDesc" ? num(b.price) - num(a.price) : sort === "km" ? num(a.km) - num(b.km) : num(b.year) - num(a.year)
)
const intro = (
<div>
<Reveal><Eyebrow>{vehicles.eyebrow}</Eyebrow></Reveal>
<RevealWords text={vehicles.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,6vw,5.5rem)] leading-[0.92]")} />
{vehicles.text && (
<Reveal delay={0.1}><p className="mt-5 max-w-2xl text-lg leading-relaxed text-t-muted">{vehicles.text}</p></Reveal>
)}
</div>
)
// Ohne eigenen Bestand (z. B. alles auf mobile.de) zeigt die Sektion nur den Weg zum Bestand
if (vehicles.items.length === 0) {
return (
<section id="bestand" className={cn(container, "py-24 md:py-32")}>
{intro}
<Reveal delay={0.15}>
<StockLink vehicles={vehicles} large />
</Reveal>
</section>
)
}
return (
<section id="bestand" className={cn(container, "py-24 md:py-32")}>
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
{intro}
<label className="flex items-center gap-3 text-sm">
<span className="text-t-muted">Sortieren</span>
<select value={sort} onChange={(e) => setSort(e.target.value as keyof typeof SORTS)} className="border border-t-fg/15 bg-t-surface px-3 py-2.5 outline-none [&>option]:text-zinc-900">
{Object.entries(SORTS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</label>
</div>
<div className="mt-8 flex flex-wrap gap-2">
{types.map((option) => {
const count = option === ALL ? vehicles.items.length : vehicles.items.filter((v) => v.type === option).length
return (
<button
key={option}
type="button"
aria-pressed={type === option}
onClick={() => onType(option)}
className={cn("cursor-pointer border px-4 py-2.5 text-sm font-bold uppercase transition-colors", type === option ? "border-t-accent bg-t-accent text-t-accent-fg" : "border-t-fg/15 hover:border-t-fg")}
>
{option} <span className="opacity-60">({count})</span>
</button>
)
})}
</div>
<p className="mt-6 text-sm text-t-muted">
{visible.length} Fahrzeuge gefunden
{maxPrice > 0 && (
<>
{" "}bis {euro.format(maxPrice)} ·{" "}
<button type="button" onClick={onReset} className="cursor-pointer text-t-accent hover:underline">Preisfilter entfernen</button>
</>
)}
</p>
<motion.div layout className="mt-6 grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<AnimatePresence mode="popLayout">
{visible.map((vehicle) => (
<motion.article
layout
key={vehicle.title}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.4, ease }}
className="group flex flex-col border border-t-fg/10 bg-t-surface transition-colors hover:border-t-accent"
>
<button type="button" onClick={() => onSelect(vehicle)} className="relative aspect-[4/3] cursor-pointer overflow-hidden text-left">
<TImage src={vehicle.image} alt={vehicle.title} sizes="(min-width: 1280px) 25vw, (min-width: 640px) 50vw, 100vw" className="transition-transform duration-700 group-hover:scale-105" />
{vehicle.badge && <span className="absolute top-3 left-3 bg-t-accent px-2.5 py-1 text-xs font-bold text-t-accent-fg uppercase">{vehicle.badge}</span>}
<span className="absolute right-3 bottom-3 bg-t-bg/85 px-2.5 py-1 text-xs font-semibold backdrop-blur">{vehicle.condition}</span>
</button>
<div className="flex flex-1 flex-col p-5">
<p className="text-xs font-semibold uppercase tracking-wider text-t-accent">{vehicle.type}</p>
<h3 className="mt-1 text-lg leading-snug font-semibold">{vehicle.title}</h3>
<dl className="mt-4 grid grid-cols-2 gap-2 text-xs text-t-muted">
<div className="flex items-center gap-1.5"><CalendarIcon className="size-3.5" /> EZ {vehicle.year}</div>
<div className="flex items-center gap-1.5"><GaugeIcon className="size-3.5" /> {vehicle.km} km</div>
<div className="flex items-center gap-1.5"><TruckIcon className="size-3.5" /> {vehicle.power}</div>
<div className="flex items-center gap-1.5"><SettingsIcon className="size-3.5" /> {vehicle.gearbox}</div>
</dl>
<div className="mt-auto flex items-end justify-between gap-3 pt-5">
<div>
<p className={cn(display, "text-2xl")}>{vehicle.price} €</p>
<p className="text-xs text-t-muted">ab {euro.format(monthlyRate(num(vehicle.price) * 0.8, rate, 60))} / Monat</p>
</div>
<button type="button" onClick={() => onSelect(vehicle)} className="grid size-11 cursor-pointer place-items-center bg-t-accent text-t-accent-fg">
<ArrowRightIcon className="size-4" />
</button>
</div>
</div>
</motion.article>
))}
</AnimatePresence>
</motion.div>
{vehicles.link && <StockLink vehicles={vehicles} />}
</section>
)
}
function StockLink({ vehicles, large }: { vehicles: NutzfahrzeugeData["vehicles"]; large?: boolean }) {
let host = ""
try {
host = new URL(vehicles.link).hostname.replace(/^www\./, "").replace(/^home\./, "")
} catch {
host = ""
}
const external = host !== ""
return (
<a
href={external ? vehicles.link : "#standorte"}
target={external ? "_blank" : undefined}
rel={external ? "noreferrer" : undefined}
className={cn(
"group mt-10 flex items-center justify-between gap-6 border border-t-fg/10 bg-t-surface transition-colors hover:border-t-accent",
large ? "p-8 md:p-12" : "p-5 md:p-6"
)}
>
<span>
<span className={cn(display, "block", large ? "text-3xl md:text-5xl" : "text-xl md:text-2xl")}>{vehicles.linkLabel}</span>
<span className="mt-1 block text-t-muted">{external ? `Aktueller Bestand auf ${host}` : "Sprechen Sie uns an – wir finden Ihr Wunschfahrzeug."}</span>
</span>
<span className={cn("grid shrink-0 place-items-center bg-t-accent text-t-accent-fg", large ? "size-16" : "size-12")}>
<ArrowUpRightIcon className="size-6 transition-transform duration-500 group-hover:rotate-45" />
</span>
</a>
)
}
function RateCalculator({ price, rate, compact }: { price: number; rate: number; compact?: boolean }) {
const [down, setDown] = useState(20)
const [months, setMonths] = useState(48)
const amount = price * (1 - down / 100)
const monthly = monthlyRate(amount, rate, months)
return (
<div className={cn("grid gap-5", !compact && "md:grid-cols-2 md:gap-10")}>
<div className="grid gap-5">
<label className="block text-sm">
<span className="flex justify-between text-t-muted">Anzahlung <span className="font-semibold text-t-fg">{down} % · {euro.format(price * (down / 100))}</span></span>
<input type="range" min={0} max={50} step={5} value={down} onChange={(e) => setDown(Number(e.target.value))} className="mt-2 w-full cursor-pointer accent-[var(--t-accent)]" />
</label>
<div className="text-sm">
<p className="text-t-muted">Laufzeit</p>
<div className="mt-2 grid grid-cols-4 gap-1">
{[24, 36, 48, 60].map((option) => (
<button
key={option}
type="button"
aria-pressed={months === option}
onClick={() => setMonths(option)}
className={cn("cursor-pointer py-2 font-semibold transition-colors", months === option ? "bg-t-accent text-t-accent-fg" : "bg-t-fg/5 hover:bg-t-fg/10")}
>
{option}
</button>
))}
</div>
</div>
</div>
<div className="flex flex-col justify-center border-l-4 border-t-accent bg-t-fg/5 p-5">
<p className="text-sm text-t-muted">Ihre monatliche Rate</p>
<motion.p key={Math.round(monthly)} initial={{ opacity: 0.4, y: 6 }} animate={{ opacity: 1, y: 0 }} className="font-t-display text-5xl font-bold text-t-accent">
{euro.format(monthly)}
</motion.p>
<p className="mt-2 text-xs text-t-muted">
{months} Monate · {rate.toLocaleString("de-DE")} % Sollzins p. a. · Finanzierungsbetrag {euro.format(amount)}. Unverbindliches Rechenbeispiel.
</p>
</div>
</div>
)
}
function Financing({ data, rate }: { data: NutzfahrzeugeData["financing"]; rate: number }) {
const [price, setPrice] = useState(50000)
return (
<section data-order="financing" id="finanzierung" className="bg-t-surface py-24 md:py-32">
<div className={cn(container, "grid gap-12 lg:grid-cols-[1fr_1.4fr]")}>
<div>
<Reveal><Eyebrow>{data.eyebrow}</Eyebrow></Reveal>
<RevealWords text={data.title} emClassName={em} className={cn(display, "text-[clamp(2.5rem,5vw,4.5rem)] leading-[0.95]")} />
<Reveal delay={0.1}><p className="mt-6 max-w-md text-lg text-t-muted">{data.text}</p></Reveal>
</div>
<Reveal delay={0.1}>
<div className="grid gap-6 bg-t-bg p-6 md:p-8">
<label className="block text-sm">
<span className="flex justify-between text-t-muted">Fahrzeugpreis <span className={cn(display, "text-2xl text-t-fg")}>{euro.format(price)}</span></span>
<input type="range" min={10000} max={150000} step={1000} value={price} onChange={(e) => setPrice(Number(e.target.value))} className="mt-2 w-full cursor-pointer accent-[var(--t-accent)]" />
</label>
<RateCalculator price={price} rate={rate} />
</div>
</Reveal>
</div>
</section>
)
}
function Drawer({ vehicle, rate, onClose }: { vehicle: Vehicle | null; rate: number; onClose: () => void }) {
return (
<AnimatePresence>
{vehicle && (
<>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose} className="fixed inset-0 z-[80] bg-black/60 backdrop-blur-sm" />
<motion.aside
role="dialog"
aria-label={vehicle.title}
initial={{ x: "100%" }}
animate={{ x: "0%" }}
exit={{ x: "100%" }}
transition={{ duration: 0.45, ease }}
className="fixed inset-y-0 right-0 z-[90] flex w-full max-w-xl flex-col overflow-y-auto bg-t-bg text-t-fg shadow-2xl"
>
<div className="relative aspect-[16/10] shrink-0">
<TImage src={vehicle.image} alt={vehicle.title} sizes="576px" />
<button type="button" aria-label="Schließen" onClick={onClose} className="absolute top-4 right-4 grid size-10 cursor-pointer place-items-center bg-t-bg/90 text-t-fg">
<XIcon className="size-5" />
</button>
</div>
<div className="grid gap-6 p-6 md:p-8">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-t-accent">{vehicle.type} · {vehicle.condition}</p>
<h2 className={cn(display, "mt-2 text-3xl leading-tight")}>{vehicle.title}</h2>
<p className={cn(display, "mt-3 text-4xl text-t-accent")}>{vehicle.price} €</p>
</div>
<dl className="grid grid-cols-2 gap-px bg-t-fg/10 text-sm">
{[
["Erstzulassung", vehicle.year],
["Kilometerstand", `${vehicle.km} km`],
["Leistung", vehicle.power],
["Getriebe", vehicle.gearbox],
["Nutzlast / zGG", vehicle.payload],
["Zustand", vehicle.condition],
].map(([label, value]) => (
<div key={label} className="bg-t-bg p-3">
<dt className="text-xs text-t-muted">{label}</dt>
<dd className="font-semibold">{value}</dd>
</div>
))}
</dl>
<div>
<p className={cn(display, "mb-4 text-xl")}>Finanzierung berechnen</p>
<RateCalculator price={num(vehicle.price)} rate={rate} compact />
</div>
<DemoForm className="grid gap-3 sm:grid-cols-2" success={<p className="text-lg font-semibold">Danke! Wir melden uns heute noch für die Probefahrt.</p>}>
<p className={cn(display, "text-xl sm:col-span-2")}>Probefahrt anfragen</p>
<TField label="Name" name="name" required autoComplete="name" />
<TField label="Telefon" name="phone" type="tel" required autoComplete="tel" />
<button type="submit" className="cursor-pointer bg-t-accent py-4 font-bold text-t-accent-fg uppercase sm:col-span-2">Termin anfragen</button>
</DemoForm>
</div>
</motion.aside>
</>
)}
</AnimatePresence>
)
}