Files
gebos-landing/v2/component-library/src/pages/configurator.tsx
T
2026-08-13 15:46:04 +02:00

554 lines
22 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.
import * as React from "react"
import { Link } from "react-router-dom"
import { ArrowRight, Euro, Plus, X } from "lucide-react"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { Checklist } from "@/components/gebos/checklist"
import { DonutIllustration } from "@/components/gebos/illustrations"
import { IconBadge } from "@/components/gebos/icon-tile"
import { Container, Section, SectionHeading } from "@/components/gebos/section"
import { PageBreadcrumb } from "@/components/gebos/page-breadcrumb"
import { icons, vectors } from "@/components/gebos/site-assets"
type Heat = "heizkoerper" | "fussbodenheizung" | "gemischt" | "unbekannt"
type Building = {
id: number
adresse: string
heat: Heat
wohnungen: number
zimmer: number
}
const HEAT_OPTIONS: { value: Heat; label: string }[] = [
{ value: "heizkoerper", label: "Heizkörper" },
{ value: "fussbodenheizung", label: "Fußbodenheizung" },
{ value: "gemischt", label: "Gemischt" },
{ value: "unbekannt", label: "Weiß ich nicht" },
]
const INITIAL_BUILDINGS: Building[] = [
{ id: 1, adresse: "", heat: "heizkoerper", wohnungen: 12, zimmer: 3 },
{ id: 2, adresse: "", heat: "heizkoerper", wohnungen: 8, zimmer: 3 },
{ id: 3, adresse: "", heat: "heizkoerper", wohnungen: 6, zimmer: 3 },
]
const UVI_MONTHLY = 10.5
const INSPECTION_MONTHLY = 2.2
const RWM_ONE_TIME = 42
function formatEuro(value: number) {
return value.toLocaleString("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
function StepCard({
step,
title,
children,
}: {
step: number
title: string
children: React.ReactNode
}) {
return (
<Card className="gap-0 p-6 sm:p-7">
<div className="flex items-center gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-extrabold text-primary-foreground">
{step}
</span>
<h3 className="text-lg font-extrabold tracking-tight text-navy">
{title}
</h3>
</div>
<div className="mt-5">{children}</div>
</Card>
)
}
export default function ConfiguratorPage() {
const [uvi, setUvi] = React.useState(true)
const [rwm, setRwm] = React.useState(true)
const [buildings, setBuildings] = React.useState<Building[]>(INITIAL_BUILDINGS)
const [email, setEmail] = React.useState("")
const nextId = React.useRef(INITIAL_BUILDINGS.length + 1)
const updateBuilding = (id: number, patch: Partial<Building>) => {
setBuildings((prev) => prev.map((b) => (b.id === id ? { ...b, ...patch } : b)))
}
const addBuilding = () => {
setBuildings((prev) => [
...prev,
{ id: nextId.current++, adresse: "", heat: "heizkoerper", wohnungen: 8, zimmer: 3 },
])
}
const removeBuilding = (id: number) => {
setBuildings((prev) => (prev.length > 1 ? prev.filter((b) => b.id !== id) : prev))
}
const totalBuildings = buildings.length
const totalUnits = buildings.reduce((sum, b) => sum + (b.wohnungen || 0), 0)
const sensors = buildings.reduce(
(sum, b) => sum + Math.round((b.wohnungen || 0) * (b.zimmer || 0) * 0.92),
0
)
const smokeDetectors = buildings.reduce(
(sum, b) => sum + Math.round((b.wohnungen || 0) * ((b.zimmer || 0) + 0.7)),
0
)
const monthly =
(uvi ? UVI_MONTHLY : 0) + (uvi || rwm ? INSPECTION_MONTHLY : 0)
return (
<Container>
<Section className="pt-6 sm:pt-7 lg:pt-8">
<div className="relative">
<PageBreadcrumb
className="mb-4"
items={[
{ label: "Startseite", to: "/" },
{ label: "Konfigurator" },
]}
/>
<SectionHeading
title="Konfigurator"
lead="In 4 Schritten zur Preisindikation."
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-6 right-0 hidden items-end lg:flex"
>
<img src={vectors.euroOrb} alt="" className="w-40" />
<img
src={vectors.consumptionRing}
alt=""
className="-mb-3 -ml-6 w-44"
/>
</div>
</div>
<div className="mt-10 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_380px]">
{/* Left column: steps */}
<div className="flex flex-col gap-6">
<StepCard step={1} title="Leistungen wählen">
<div className="flex flex-col gap-4">
<label className="flex cursor-pointer items-start gap-3">
<Checkbox
checked={uvi}
onCheckedChange={(v) => setUvi(v === true)}
className="mt-0.5"
/>
<span>
<span className="block text-sm font-bold text-navy">UVI</span>
<span className="block text-sm text-muted-foreground">
Unterjährige Verbrauchsinformation inklusive Messtechnik
</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-3">
<Checkbox
checked={rwm}
onCheckedChange={(v) => setRwm(v === true)}
className="mt-0.5"
/>
<span>
<span className="block text-sm font-bold text-navy">
Rauchwarnmelder
</span>
<span className="block text-sm text-muted-foreground">
Rauchwarnmelder inklusive digitaler Dienstleistungen
</span>
</span>
</label>
</div>
</StepCard>
<StepCard step={2} title="Gebäude angeben">
<div className="flex flex-col gap-5">
{buildings.map((b, i) => (
<div
key={b.id}
className="rounded-xl border border-border bg-brand-50/40 p-4 sm:p-5"
>
<div className="flex items-center justify-between">
<div className="text-sm font-bold text-navy">
Gebäude {i + 1}
</div>
{buildings.length > 1 ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-muted-foreground"
aria-label={`Gebäude ${i + 1} entfernen`}
onClick={() => removeBuilding(b.id)}
>
<X />
</Button>
) : null}
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor={`adresse-${b.id}`}>
Adresse{" "}
<span className="font-normal text-muted-foreground">
(optional)
</span>
</Label>
<Input
id={`adresse-${b.id}`}
placeholder="Venloer Straße 123, 50823 Köln"
value={b.adresse}
onChange={(e) =>
updateBuilding(b.id, { adresse: e.target.value })
}
/>
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor={`heat-${b.id}`}>Wärmeverteilung</Label>
<Select
value={b.heat}
onValueChange={(v) =>
updateBuilding(b.id, { heat: v as Heat })
}
>
<SelectTrigger id={`heat-${b.id}`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HEAT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`wohnungen-${b.id}`}>Wohnungen</Label>
<Input
id={`wohnungen-${b.id}`}
type="number"
min={1}
value={b.wohnungen}
onChange={(e) =>
updateBuilding(b.id, {
wohnungen: Math.max(
0,
Math.round(Number(e.target.value) || 0)
),
})
}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`zimmer-${b.id}`}>Ø Zimmer</Label>
<Input
id={`zimmer-${b.id}`}
type="number"
min={1}
value={b.zimmer}
onChange={(e) =>
updateBuilding(b.id, {
zimmer: Math.max(0, Number(e.target.value) || 0),
})
}
/>
</div>
</div>
</div>
))}
<Button
type="button"
variant="outline"
className="self-start"
onClick={addBuilding}
>
<Plus /> Gebäude hinzufügen
</Button>
</div>
</StepCard>
<StepCard step={3} title="Preis sehen">
<p className="text-sm text-muted-foreground">
Sofortige erste Preisindikation.
</p>
<Accordion type="single" collapsible className="mt-3">
<AccordionItem value="annahmen" className="border-b-0">
<AccordionTrigger className="text-sm font-bold text-navy">
Wie wurde dieser Preis berechnet?
</AccordionTrigger>
<AccordionContent className="text-sm text-muted-foreground">
<p>
Für diese Schätzung gehen wir aufgrund Ihrer Angaben von
ungefähr{" "}
<strong className="text-navy">
{sensors} Heizsensoren und {smokeDetectors}{" "}
Rauchwarnmeldern
</strong>{" "}
aus.
</p>
<p className="mt-2">
Bei Gebäuden mit durchschnittlich drei Zimmern verwenden
wir eine typische Geräteausstattung als
Berechnungsgrundlage.
</p>
<p className="mt-2">
Die endgültige Anzahl wird bei der detaillierten
Konfiguration der Wohnungen bestimmt und ersetzt diese
Schätzwerte.
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
</StepCard>
<StepCard step={4} title="Konfiguration speichern">
<form
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-3"
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="config-email">E-Mail-Adresse</Label>
<Input
id="config-email"
type="email"
placeholder="name@unternehmen.de"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<Button type="submit" className="self-start">
Konfiguration speichern &amp; fortsetzen
</Button>
<p className="text-xs text-muted-foreground">
Sie erhalten einen persönlichen Link (gebos.de/konfiguration/)
zu Ihrer Konfiguration sofort weitermachen, später
zurückkehren oder den Link intern weitergeben.
</p>
</form>
</StepCard>
<div className="flex flex-wrap gap-3">
<Button type="button">Jetzt Preis berechnen</Button>
<Button variant="outline" asChild>
<Link to="/so-funktionierts">So funktioniert der Konfigurator</Link>
</Button>
</div>
</div>
{/* Right column: sticky summary */}
<div className="lg:sticky lg:top-24">
<Card className="gap-0 p-6 shadow-card-lg sm:p-7">
<h3 className="text-base font-extrabold tracking-tight text-navy">
Ihre vorläufige Konfiguration
</h3>
<div className="mt-4 flex flex-col gap-1">
<div className="text-2xl font-extrabold tracking-tight text-navy">
{totalBuildings}{" "}
{totalBuildings === 1 ? "Gebäude" : "Gebäude"}
</div>
<div className="text-2xl font-extrabold tracking-tight text-navy">
{totalUnits} {totalUnits === 1 ? "Wohnung" : "Wohnungen"}
</div>
</div>
{uvi || rwm ? (
<>
<Separator className="my-4" />
<p className="text-xs font-bold tracking-[0.14em] text-muted-foreground uppercase">
Voraussichtlich benötigt
</p>
<ul className="mt-2 flex flex-col gap-1.5 text-sm text-navy">
{uvi ? (
<li className="font-semibold">
ca. {sensors} Heizsensoren
</li>
) : null}
{rwm ? (
<li className="font-semibold">
ca. {smokeDetectors} Rauchwarnmelder
</li>
) : null}
</ul>
</>
) : null}
<div className="mt-5 rounded-xl bg-brand-gradient p-4 text-white shadow-band">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-2xl font-extrabold tracking-tight">
ca. {formatEuro(monthly)}{"\u00A0"}
</div>
<div className="text-sm text-white/80">
je Wohnung / Monat
</div>
</div>
<IconBadge variant="glass" size="lg">
<Euro />
</IconBadge>
</div>
{rwm ? (
<div className="mt-3 border-t border-white/20 pt-2 text-xs text-white/80">
+ {formatEuro(RWM_ONE_TIME)}{"\u00A0"} je Wohnung einmalig
(RWM-Hardware)
</div>
) : null}
</div>
<div className="mt-5 flex items-start gap-4">
<DonutIllustration className="w-16 shrink-0" />
<p className="text-xs leading-relaxed text-muted-foreground">
Die erste Berechnung ist eine unverbindliche Preisindikation.
Der genaue Preis ergibt sich aus der vollständigen
Objektkonfiguration.
</p>
</div>
</Card>
</div>
</div>
</Section>
{/* Was ist in der Schätzung enthalten? */}
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
title="Was ist in der Schätzung enthalten?"
lead="Je nach gewähltem Paket beispielsweise:"
/>
<div className="mt-8 grid gap-6 lg:grid-cols-2">
<Card
className={
"gap-0 p-6 transition-opacity sm:p-8" +
(uvi ? "" : " opacity-50")
}
>
<div className="flex items-center gap-4">
<img src={icons.uvi} alt="" className="size-14" />
<h3 className="text-xl font-extrabold tracking-tight text-navy">
UVI
</h3>
</div>
<Checklist
className="mt-5"
items={[
"geschätzte benötigte Messtechnik",
"GebOS Plattform",
"Datenerfassung",
"Unterjährige Verbrauchsinformation",
"laufender digitaler Betrieb",
]}
/>
</Card>
<Card
className={
"gap-0 p-6 transition-opacity sm:p-8" +
(rwm ? "" : " opacity-50")
}
>
<div className="flex items-center gap-4">
<img src={icons.smokeAlarm} alt="" className="size-14" />
<h3 className="text-xl font-extrabold tracking-tight text-navy">
Rauchwarnmelder
</h3>
</div>
<Checklist
className="mt-5"
items={[
"geschätzte Anzahl Rauchwarnmelder",
"Verwaltung der Geräte",
"gebuchte digitale Dienstleistungen",
]}
/>
</Card>
</div>
</Section>
{/* Stufe 2 So geht es weiter */}
<Section className="pt-0 pb-16 sm:pt-0 sm:pb-20 lg:pt-0 lg:pb-24">
<SectionHeading
eyebrow="So geht es weiter"
title="Erst schätzen. Dann genau konfigurieren."
lead="Der vollständige Konfigurator beginnt nicht wieder bei null. Die bereits erfassten Informationen werden übernommen und die bisherigen Annahmen schrittweise durch echte Daten ersetzt."
/>
<div className="mt-8 flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2.5">
<Badge variant="muted" className="px-3.5 py-1.5 text-sm">
Geschätzt: 72 Heizsensoren
</Badge>
<ArrowRight className="size-4 text-primary" aria-hidden="true" />
<Badge className="px-3.5 py-1.5 text-sm">
Konfiguriert: 68 Heizsensoren
</Badge>
</div>
<div className="flex flex-wrap items-center gap-2.5">
<Badge variant="muted" className="px-3.5 py-1.5 text-sm">
Geschätzt: 96 Rauchwarnmelder
</Badge>
<ArrowRight className="size-4 text-primary" aria-hidden="true" />
<Badge className="px-3.5 py-1.5 text-sm">
Konfiguriert: 103 Rauchwarnmelder
</Badge>
</div>
</div>
<Card className="mt-10 max-w-2xl gap-0 p-6 sm:p-8">
<p className="text-xs font-bold tracking-[0.14em] text-primary uppercase">
Finales Ergebnis
</p>
<h3 className="mt-2 text-xl font-extrabold tracking-tight text-navy">
Ihre GebOS-Konfiguration
</h3>
<p className="mt-1 text-sm font-semibold text-navy">
3 Gebäude · 26 Wohnungen
</p>
<Separator className="my-4" />
<dl className="flex flex-col gap-3 text-sm">
<div>
<dt className="font-bold text-navy">UVI</dt>
<dd className="text-muted-foreground">
68 Heizsensoren / Messgeräte, weitere benötigte Messtechnik
</dd>
</div>
<div>
<dt className="font-bold text-navy">Rauchwarnmelder</dt>
<dd className="text-muted-foreground">103 Rauchwarnmelder</dd>
</div>
<div>
<dt className="font-bold text-navy">Infrastruktur</dt>
<dd className="text-muted-foreground">
technisch ausgelegte Gateway- und Kommunikationsinfrastruktur
</dd>
</div>
</dl>
<Separator className="my-4" />
<p className="text-sm leading-relaxed text-muted-foreground">
Ihr Preis ist dann keine grobe Hochrechnung auf Basis von
Durchschnittswerten mehr, sondern ein Preis auf Grundlage der
tatsächlichen Objektkonfiguration.
</p>
<div className="mt-5">
<Button type="button">Angebot anfordern</Button>
</div>
</Card>
<p className="mt-8 max-w-2xl text-sm leading-relaxed text-muted-foreground">
Ihre Daten werden im gesamten Prozess weiterverwendet:
Preisschätzung Konfiguration Bestellung Gerätevorbereitung
Installation laufender Betrieb.
</p>
</Section>
</Container>
)
}