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