Files
gebos-landing/component-library/src/pages/configurator.tsx
T
Lars Nolden 7cfb7fd5ac
Deploy Static Site / deploy (push) Successful in 10m5s
udpates from session with Guido
2026-08-19 21:05:01 +02:00

742 lines
27 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, useSearchParams } from "react-router-dom"
import { Check, 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/solenos/checklist"
import { IconBadge } from "@/components/solenos/icon-tile"
import { Container, Section, SectionHeading } from "@/components/solenos/section"
import { PageBreadcrumb } from "@/components/solenos/page-breadcrumb"
import {
AssetRender,
GlassNumber,
renders,
type RenderAsset,
} from "@/components/solenos/site-assets"
import { cn } from "@/lib/utils"
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,
}
/**
* 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`,
* `abrechnung`, `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
billing?: 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")),
billing: presetFlag(params.get("abrechnung")),
rwm: presetFlag(params.get("rwm")),
install: presetFlag(params.get("installation")),
building,
}
}
/** the three configurator steps */
type Step = 1 | 2 | 3
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">
<GlassNumber n={step} className="size-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({
id,
checked,
onCheckedChange,
title,
description,
}: {
id: string
checked: boolean
onCheckedChange: (checked: boolean) => void
title: string
description: string
}) {
return (
<div
className={cn(
"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"
)}
>
<div className="flex items-start gap-3">
<Checkbox
id={id}
checked={checked}
onCheckedChange={(value) => onCheckedChange(value === true)}
aria-describedby={`${id}-description ${id}-state`}
className="mt-0.5 size-6"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-start justify-between gap-2">
<Label
htmlFor={id}
className="cursor-pointer text-sm font-bold leading-snug text-navy"
>
{title}
</Label>
<Badge
id={`${id}-state`}
variant={checked ? "default" : "muted"}
aria-live="polite"
className="shrink-0"
>
{checked ? "Enthalten · abwählbar" : "Hinzufügen"}
</Badge>
</div>
<p
id={`${id}-description`}
className="mt-1 text-sm leading-relaxed text-muted-foreground"
>
{description}
</p>
</div>
</div>
</div>
)
}
/**
* 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 Leistungsumfang
aufzunehmen.
</p>
)}
</Card>
)
}
export default function ConfiguratorPage() {
const [searchParams] = useSearchParams()
const preset = React.useMemo(() => readPreset(searchParams), [searchParams])
const [uvi, setUvi] = React.useState(preset.uvi ?? true)
const [billing, setBilling] = React.useState(preset.billing ?? 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)
setBilling(preset.billing ?? 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)
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="Leistungen auswählen, Gebäudedaten online erfassen und die nächsten Schritte transparent planen."
/>
</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
id="service-uvi"
checked={uvi}
onCheckedChange={setUvi}
title="Monatliche Verbrauchsinformation (UVI)"
description="Monatliche Verbrauchswerte und Vergleiche für jede Wohnung einschließlich der benötigten Messtechnik."
/>
<ServiceOption
id="service-billing"
checked={billing}
onCheckedChange={setBilling}
title="Heizkostenabrechnung"
description="Jährliche Heizkostenabrechnung einschließlich der gesetzlich vorgeschriebenen Pflichtangaben und Dokumente."
/>
<ServiceOption
id="service-smoke-alarms"
checked={rwm}
onCheckedChange={setRwm}
title="Rauchwarnmelder"
description="Rauchwarnmelder mit Ferninspektion, Geräteverwaltung sowie Prüf- und Statusdokumentation."
/>
</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 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Label
htmlFor="installation"
className="cursor-pointer text-sm font-bold text-navy"
>
Montage durch SolenOS
</Label>
<Badge variant={install ? "default" : "muted"}>
{install ? "SolenOS montiert" : "Selbstmontage"}
</Badge>
</div>
<p
id="installation-description"
className="mt-1 text-sm text-muted-foreground"
>
{install
? "SolenOS übernimmt Montage und Softwarekonfiguration. Der geprüfte Gesamtpreis folgt mit dem verbindlichen Angebot."
: "Sie, Ihr Hausmeister oder Ihr Fachbetrieb montieren. Eine Softwarekonfiguration vor Ort ist nicht erforderlich."}
</p>
</div>
<Switch
id="installation"
checked={install}
onCheckedChange={setInstall}
aria-describedby="installation-description"
className="mt-0.5"
/>
</div>
</StepCard>
<StepCard step={2} title="Gebäudedaten online erfassen">
<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="Persönlichen Link anfordern">
<div className="rounded-xl border border-brand-200 bg-brand-50/60 p-4">
<p className="text-sm font-bold text-navy">
So geht es nach der E-Mail weiter
</p>
<ol className="mt-3 list-decimal space-y-2 pl-5 text-sm leading-relaxed text-muted-foreground">
<li>
Wir speichern Ihre bisherige Auswahl und senden Ihnen einen
persönlichen Link.
</li>
<li>
Über den Link ergänzen Sie die Gebäudedetails und laden im
Portal Fotos vorhandener wasserführender Einbausituationen
hoch. Fotos ersetzen Maße und Typangaben, soweit sie eine
eindeutige Prüfung ermöglichen.
</li>
<li>
SolenOS prüft die Angaben und Fotos manuell, bestimmt die
endgültige Ausstattung und erstellt anschließend den genauen
Gesamtpreis beziehungsweise ein verbindliches Angebot.
</li>
</ol>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">
Beispiel für spätere Detailfragen: Kinderzimmer und regelmäßig
genutzte Gästezimmer zählen als Schlafräume; ein dauerhaftes
Büro nicht.
</p>
</div>
<form
onSubmit={(event) => event.preventDefault()}
className="mt-5 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={(event) => setEmail(event.target.value)}
aria-describedby="config-email-privacy"
required
/>
</div>
<p
id="config-email-privacy"
className="text-xs leading-relaxed text-muted-foreground"
>
Wir verwenden Ihre E-Mail-Adresse, um den persönlichen Link
zu versenden. Einzelheiten finden Sie in unseren{" "}
<Link
to="/wissen/datenschutz-sicherheit"
className="font-semibold text-primary underline underline-offset-2"
>
Hinweisen zu Datenschutz &amp; Sicherheit
</Link>
.
</p>
<Button type="submit" className="self-start">
Persönlichen Link anfordern
</Button>
</form>
</StepCard>
</div>
{/* Right column: sticky configuration summary */}
<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">
Ihre Konfiguration
</p>
<IconBadge variant="glass" size="lg">
<Check />
</IconBadge>
</div>
<div className="p-6 sm:p-7">
<p className="text-sm font-bold text-navy">
{buildings.length} Gebäude · {totalUnits}{" "}
{totalUnits === 1 ? "Wohnung" : "Wohnungen"}
</p>
<Separator className="my-5" />
<div aria-live="polite">
<p className="text-xs font-bold tracking-wide text-navy uppercase">
Gewählte Leistungen
</p>
<ul className="mt-3 space-y-3 text-sm">
<li
className={cn(
"flex items-start gap-2",
uvi ? "text-navy" : "text-muted-foreground"
)}
>
{uvi ? (
<Check className="mt-0.5 size-4 shrink-0 text-primary" />
) : (
<X className="mt-0.5 size-4 shrink-0" />
)}
<span>
Monatliche Verbrauchsinformation
{uvi ? " enthalten" : " nicht enthalten"}
</span>
</li>
<li
className={cn(
"flex items-start gap-2",
billing ? "text-navy" : "text-muted-foreground"
)}
>
{billing ? (
<Check className="mt-0.5 size-4 shrink-0 text-primary" />
) : (
<X className="mt-0.5 size-4 shrink-0" />
)}
<span>
Heizkostenabrechnung
{billing ? " enthalten" : " nicht enthalten"}
</span>
</li>
<li
className={cn(
"flex items-start gap-2",
rwm ? "text-navy" : "text-muted-foreground"
)}
>
{rwm ? (
<Check className="mt-0.5 size-4 shrink-0 text-primary" />
) : (
<X className="mt-0.5 size-4 shrink-0" />
)}
<span>
Rauchwarnmelder
{rwm ? " enthalten" : " nicht enthalten"}
</span>
</li>
<li className="flex items-start gap-2 text-navy">
<Check className="mt-0.5 size-4 shrink-0 text-primary" />
<span>
{install
? "Montage durch SolenOS"
: "Montage durch Sie oder Ihren Fachbetrieb"}
</span>
</li>
</ul>
</div>
<Separator className="my-5" />
<p className="text-sm font-bold text-navy">
Geprüfter Gesamtpreis nach Detailkonfiguration
</p>
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
Wir veröffentlichen keine ungeprüften Preiswerte. Nach der
manuellen Prüfung erhalten Sie ein verbindliches
Gesamtangebot mit der erforderlichen Gateway-Infrastruktur,
den gewählten Leistungen und der gewählten Montagevariante.
</p>
</div>
</Card>
</div>
</div>
</Section>
{/* Vollständiger Leistungsumfang des späteren Gesamtangebots */}
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading title="Was gehört in das Gesamtangebot?" />
<div className="mt-8 grid gap-6 lg:grid-cols-3">
<PackageCard
render={renders.gateway}
title="Monatliche Verbrauchsinformation (UVI)"
included={uvi}
items={[
"von SolenOS ermittelte notwendige Messtechnik",
"erforderliche Gateway- und Kommunikationsinfrastruktur",
"SolenOS Plattform, Datenerfassung und laufender digitaler Betrieb",
"monatliche Verbrauchswerte und Vergleiche für jede Wohnung",
]}
/>
<PackageCard
render={renders.glassMoney}
title="Heizkostenabrechnung"
included={billing}
items={[
"jährliche Heizkostenabrechnung im Online-Portal",
"Verteilung nach den Vorgaben der HeizkostenV",
"Berücksichtigung von Nutzerwechseln und Leerständen",
"Pflichtangaben nach § 6a Absatz 3 HeizkostenV",
]}
/>
<PackageCard
render={renders.smokeAlarm}
title="Rauchwarnmelder"
included={rwm}
items={[
"von SolenOS ermittelte Anzahl Rauchwarnmelder",
"Verwaltung und Ferninspektion der Geräte",
"Prüf- und Statusdokumentation",
install
? "Montage durch SolenOS im verbindlichen Gesamtangebot"
: "Montage durch Sie oder Ihren Fachbetrieb",
]}
/>
</div>
</Section>
</Container>
)
}