Files
gebos-landing/v2/component-library/src/pages/configurator.tsx
T
2026-08-13 23:12:07 +02:00

690 lines
24 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 { useSearchParams } from "react-router-dom"
import { Ban, Check, CircleCheck, Euro, Plus, X } from "lucide-react"
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 { Switch } from "@/components/ui/switch"
import { Checklist } from "@/components/gebos/checklist"
import { IconBadge } from "@/components/gebos/icon-tile"
import { Container, Section, SectionHeading } from "@/components/gebos/section"
import { PageBreadcrumb } from "@/components/gebos/page-breadcrumb"
import {
AssetRender,
renders,
type RenderAsset,
} from "@/components/gebos/site-assets"
import { cn } from "@/lib/utils"
import { useAnimatedNumber } from "@/lib/use-animated-number"
type Heat = "heizkoerper" | "fussbodenheizung" | "gemischt" | "unbekannt"
type Building = {
id: number
adresse: string
heat: Heat
/** "" while the field is being edited the estimate counts it as 0. */
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_BUILDING: Building = {
id: 1,
adresse: "",
heat: "heizkoerper",
wohnungen: 12,
zimmer: 3,
}
/** Grundpreise je Wohnung / Monat Selbstmontage, wie in der Klarpreis-Liste. */
const UVI_MONTHLY = 10.5
const INSPECTION_MONTHLY = 2.2
/** Aufschlag je Monatsposition, wenn GebOS montiert Platzhalter bis zum finalen Preismodell. */
const INSTALL_MONTHLY_SURCHARGE = 2
/** RWM-Hardware je Wohnung einmalig, inklusive Montage wie in der Klarpreis-Liste. */
const RWM_ONE_TIME_INSTALLED = 42
/** Selbstmontage durch Kunde oder Fachbetrieb Platzhalter bis zum finalen Preismodell. */
const RWM_ONE_TIME_SELF = 30
function formatEuro(value: number) {
return value.toLocaleString("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
/**
* Keeps a partially edited number field empty instead of snapping it to 0, so
* Backspace clears the input like any other field.
*/
function parseAmount(raw: string): number | "" {
if (raw.trim() === "") return ""
const value = Number(raw)
return Number.isFinite(value) ? Math.max(0, value) : ""
}
function parseCount(raw: string): number | "" {
const value = parseAmount(raw)
return value === "" ? "" : Math.round(value)
}
/** Leaving the field settles an empty or too-small value at the minimum. */
function settle(value: number | "", min: number) {
return value === "" || value < min ? min : value
}
/**
* Deep-link preset. Pages elsewhere on the site link into the configurator with
* parts of the form already answered, e.g. `/konfigurator?installation=0` for
* the self-install route. Supported params: `installation`, `uvi`, `rwm`,
* `adresse`, `heizung`, `wohnungen`, `zimmer`. Missing or unreadable values
* keep the page defaults; numbers below the field minimum settle at it, just
* as they do when typed into the form.
*/
type Preset = {
uvi?: boolean
rwm?: boolean
install?: boolean
/** Seeds the single building the configurator starts with. */
building: Partial<Building>
}
/** A bare flag (`?installation`) counts as "on" so short links stay readable. */
function presetFlag(raw: string | null): boolean | undefined {
if (raw === null) return undefined
switch (raw.trim().toLowerCase()) {
case "":
case "1":
case "true":
case "ja":
return true
case "0":
case "false":
case "nein":
return false
default:
return undefined
}
}
function presetNumber(
raw: string | null,
parse: (raw: string) => number | ""
): number | undefined {
if (raw === null) return undefined
const value = parse(raw)
return value === "" ? undefined : settle(value, 1)
}
function presetHeat(raw: string | null): Heat | undefined {
return HEAT_OPTIONS.some((opt) => opt.value === raw)
? (raw as Heat)
: undefined
}
function readPreset(params: URLSearchParams): Preset {
const building: Partial<Building> = {}
const adresse = params.get("adresse")
if (adresse !== null) building.adresse = adresse
const heat = presetHeat(params.get("heizung"))
if (heat !== undefined) building.heat = heat
const wohnungen = presetNumber(params.get("wohnungen"), parseCount)
if (wohnungen !== undefined) building.wohnungen = wohnungen
const zimmer = presetNumber(params.get("zimmer"), parseAmount)
if (zimmer !== undefined) building.zimmer = zimmer
return {
uvi: presetFlag(params.get("uvi")),
rwm: presetFlag(params.get("rwm")),
install: presetFlag(params.get("installation")),
building,
}
}
/** glossy numerals for the three configurator steps */
type Step = 1 | 2 | 3
const STEP_BADGES: Record<Step, RenderAsset> = {
1: renders.glassBadge1,
2: renders.glassBadge2,
3: renders.glassBadge3,
}
function StepCard({
step,
title,
children,
}: {
step: Step
title: string
children: React.ReactNode
}) {
return (
<Card className="gap-0 p-6 sm:p-7">
<div className="flex items-center gap-3">
<AssetRender
render={STEP_BADGES[step]}
className="flex size-11 shrink-0 items-center justify-center"
imgClassName="w-11"
loading="eager"
/>
<h3 className="text-lg font-extrabold tracking-tight text-navy">
<span className="sr-only">Schritt {step}: </span>
{title}
</h3>
</div>
<div className="mt-5">{children}</div>
</Card>
)
}
/**
* Selectable service tile (step 1) — the active option carries a brand border.
*/
function ServiceOption({
checked,
onCheckedChange,
title,
description,
}: {
checked: boolean
onCheckedChange: (checked: boolean) => void
title: string
description: string
}) {
return (
<label
className={cn(
"flex cursor-pointer items-start gap-3 rounded-xl border p-4 transition-colors",
checked
? "border-primary bg-brand-50/60 shadow-pill"
: "border-border bg-card hover:border-brand-200 hover:bg-brand-50/40"
)}
>
<Checkbox
checked={checked}
onCheckedChange={(v) => onCheckedChange(v === true)}
className="mt-0.5"
/>
<span>
<span className="block text-sm font-bold text-navy">{title}</span>
<span className="block text-sm text-muted-foreground">{description}</span>
</span>
</label>
)
}
/**
* Leistungspaket in "Was ist in dem Preis enthalten?". A package the user did
* not pick in step 1 is priced out, so it is shown as struck-out and labelled
* "Nicht enthalten" instead of merely dimmed.
*/
function PackageCard({
render,
title,
items,
included,
}: {
render: RenderAsset
title: string
items: string[]
included: boolean
}) {
return (
<Card
className={cn(
"gap-0 p-6 transition-colors sm:p-8",
included ? "" : "border-dashed border-border bg-muted/40 shadow-none"
)}
>
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<AssetRender
render={render}
alt=""
className="flex size-20 shrink-0 items-center justify-center"
imgClassName={cn("w-20", !included && "opacity-40 grayscale")}
/>
<h3
className={cn(
"text-xl font-extrabold tracking-tight",
included ? "text-navy" : "text-muted-foreground"
)}
>
{title}
</h3>
<Badge
variant={included ? "default" : "muted"}
className="px-3 py-1.5 sm:ml-auto"
>
{included ? <Check aria-hidden="true" /> : <X aria-hidden="true" />}
{included ? "Enthalten" : "Nicht enthalten"}
</Badge>
</div>
<Checklist
className="mt-5"
tone={included ? "included" : "excluded"}
items={items}
/>
{included ? null : (
<p className="mt-5 text-sm text-muted-foreground">
Leistung in Schritt 1 auswählen, um sie in den Preis aufzunehmen.
</p>
)}
</Card>
)
}
/**
* One position of the price indication: figure, cadence and whether the
* position can be passed on to tenants (umlagefähig). Wording follows the
* Klarpreis price list.
*/
function PriceRow({
label,
amount,
unit,
note,
allocatable,
}: {
label: string
amount: number
unit: string
note?: string
allocatable: boolean
}) {
const shown = useAnimatedNumber(amount)
return (
<div>
<p className="text-sm font-bold text-navy">{label}</p>
<p className="mt-1 text-3xl font-extrabold tracking-tight text-navy tabular-nums">
ca. {formatEuro(shown)}
{"\u00A0"}
</p>
<p className="mt-0.5 text-sm text-muted-foreground">{unit}</p>
{note ? (
<p className="mt-0.5 text-xs text-muted-foreground">{note}</p>
) : null}
<Badge variant={allocatable ? "default" : "destructive"} className="mt-3">
{allocatable ? <CircleCheck /> : <Ban />}
{allocatable ? "Umlagefähig" : "Nicht umlagefähig"}
</Badge>
</div>
)
}
export default function ConfiguratorPage() {
const [searchParams] = useSearchParams()
const preset = React.useMemo(() => readPreset(searchParams), [searchParams])
const [uvi, setUvi] = React.useState(preset.uvi ?? true)
const [rwm, setRwm] = React.useState(preset.rwm ?? true)
const [buildings, setBuildings] = React.useState<Building[]>(() => [
{ ...INITIAL_BUILDING, ...preset.building },
])
const [install, setInstall] = React.useState(preset.install ?? true)
const [email, setEmail] = React.useState("")
const nextId = React.useRef(INITIAL_BUILDING.id + 1)
/*
* Following a second preset link while the page stays mounted re-seeds the
* form; the mount-time preset is already applied by the initialisers above.
*/
const appliedPreset = React.useRef(preset)
React.useEffect(() => {
if (appliedPreset.current === preset) return
appliedPreset.current = preset
setUvi(preset.uvi ?? true)
setRwm(preset.rwm ?? true)
setInstall(preset.install ?? true)
setBuildings([{ ...INITIAL_BUILDING, ...preset.building }])
nextId.current = INITIAL_BUILDING.id + 1
}, [preset])
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 totalUnits = buildings.reduce((sum, b) => sum + (b.wohnungen || 0), 0)
const installSurcharge = install ? INSTALL_MONTHLY_SURCHARGE : 0
const uviMonthly = UVI_MONTHLY + installSurcharge
const inspectionMonthly = INSPECTION_MONTHLY + installSurcharge
const installNote = install
? `inklusive ${formatEuro(INSTALL_MONTHLY_SURCHARGE)}\u00A0€ Installationsanteil`
: undefined
const rwmRatePerUnit = install ? RWM_ONE_TIME_INSTALLED : RWM_ONE_TIME_SELF
/* RWM hardware is billed once per Wohnung the estimate is the sum. */
const rwmOneTimeTotal = rwm ? rwmRatePerUnit * totalUnits : 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 3 Schritten zur Preisindikation."
/>
</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-3">
<ServiceOption
checked={uvi}
onCheckedChange={setUvi}
title="UVI"
description="Unterjährige Verbrauchsinformation inklusive Messtechnik"
/>
<ServiceOption
checked={rwm}
onCheckedChange={setRwm}
title="Rauchwarnmelder"
description="Rauchwarnmelder inklusive digitaler Dienstleistungen"
/>
</div>
<Separator className="my-6" />
<div
className={cn(
"flex items-start justify-between gap-4 rounded-xl border p-4 transition-colors",
install
? "border-primary bg-brand-50/60 shadow-pill"
: "border-border bg-card"
)}
>
<div className="min-w-0">
<Label
htmlFor="installation"
className="cursor-pointer text-sm font-bold text-navy"
>
Installation durch GebOS
</Label>
<p className="mt-1 text-sm text-muted-foreground">
{install
? "GebOS organisiert die Montage Geräte- und Monatspreise enthalten die Installation."
: "Sie selbst, Ihr Hausmeister oder Ihr Fachbetrieb montiert die Geräte."}
</p>
</div>
<Switch
id="installation"
checked={install}
onCheckedChange={setInstall}
className="mt-0.5"
/>
</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: parseCount(e.target.value),
})
}
onBlur={() =>
updateBuilding(b.id, {
wohnungen: settle(b.wohnungen, 1),
})
}
/>
</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: parseAmount(e.target.value),
})
}
onBlur={() =>
updateBuilding(b.id, {
zimmer: settle(b.zimmer, 1),
})
}
/>
</div>
</div>
</div>
))}
<Button
type="button"
variant="outline"
className="self-start"
onClick={addBuilding}
>
<Plus /> weiteres Gebäude hinzufügen
</Button>
</div>
</StepCard>
<StepCard step={3} 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
zu Ihrer Konfiguration. Damit kannst du
sofort weitermachen, später zurückkehren
oder den Link intern weitergeben.
</p>
</form>
</StepCard>
</div>
{/* Right column: sticky price indication */}
<div className="lg:sticky lg:top-24">
<Card className="gap-0 overflow-hidden p-0 shadow-card-lg">
<div className="flex items-center justify-between gap-3 bg-brand-gradient p-6 text-white sm:p-7">
<p className="text-xs font-bold tracking-[0.14em] uppercase">
Geschätzter Preis
</p>
<IconBadge variant="glass" size="lg">
<Euro />
</IconBadge>
</div>
<div className="p-6 sm:p-7">
{uvi || rwm ? (
<div className="flex flex-col gap-5">
{uvi ? (
<PriceRow
label="UVI-Komplettbetrieb"
amount={uviMonthly}
unit="je Wohnung / Monat"
note={installNote}
allocatable
/>
) : null}
{rwm ? (
<>
{uvi ? <Separator /> : null}
<PriceRow
label="Ferninspektion Rauchwarnmelder"
amount={inspectionMonthly}
unit="je Wohnung / Monat"
note={installNote}
allocatable
/>
<Separator />
<PriceRow
label="Rauchwarnmelder-Geräte"
amount={rwmOneTimeTotal}
unit="einmalig gesamt"
note={`${totalUnits} ${totalUnits === 1 ? "Wohnung" : "Wohnungen"} × ${formatEuro(rwmRatePerUnit)}\u00A0€ · ${install ? "inklusive Montage" : "Selbstmontage"}`}
allocatable={false}
/>
</>
) : null}
</div>
) : (
<p className="text-sm text-muted-foreground">
Wählen Sie in Schritt 1 mindestens eine Leistung, um eine
Preisindikation zu erhalten.
</p>
)}
<p className="mt-6 text-xs leading-relaxed text-muted-foreground">
{install
? "Preise inklusive Montage durch GebOS. "
: "Preise ohne Montage Installation durch Sie oder Ihren Fachbetrieb. "}
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 dem Preis enthalten?"
/>
<div className="mt-8 grid gap-6 lg:grid-cols-2">
<PackageCard
render={renders.gateway}
title="UVI"
included={uvi}
items={[
"geschätzte benötigte Messtechnik",
"GebOS Plattform",
"Datenerfassung",
"Unterjährige Verbrauchsinformation",
"laufender digitaler Betrieb",
]}
/>
<PackageCard
render={renders.smokeAlarm}
title="Rauchwarnmelder"
included={rwm}
items={[
"geschätzte Anzahl Rauchwarnmelder",
"Verwaltung der Geräte",
"gebuchte digitale Dienstleistungen",
install
? "Montage und Eigentumsübertragung"
: "Eigentumsübertragung Montage durch Sie",
]}
/>
</div>
</Section>
</Container>
)
}