new configurator
Deploy Static Site / deploy (push) Successful in 10m3s

This commit is contained in:
Lars Nolden
2026-08-19 22:00:56 +02:00
parent 7cfb7fd5ac
commit 5c5cbd8f3a
+186 -110
View File
@@ -1,11 +1,10 @@
import * as React from "react" import * as React from "react"
import { Link, useSearchParams } from "react-router-dom" import { Link, useSearchParams } from "react-router-dom"
import { Check, Plus, X } from "lucide-react" import { Ban, Check, CircleCheck, Euro, Plus, X } from "lucide-react"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card" import { Card } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -22,6 +21,7 @@ import {
type RenderAsset, type RenderAsset,
} from "@/components/solenos/site-assets" } from "@/components/solenos/site-assets"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useAnimatedNumber } from "@/lib/use-animated-number"
type Heat = "heizkoerper" | "fussbodenheizung" | "gemischt" | "unbekannt" type Heat = "heizkoerper" | "fussbodenheizung" | "gemischt" | "unbekannt"
@@ -49,6 +49,21 @@ const INITIAL_BUILDING: Building = {
zimmer: 3, zimmer: 3,
} }
/** Grundpreise je Wohnung / Monat wie in der Klarpreis-Liste. */
const UVI_MONTHLY = 10.5
const INSPECTION_MONTHLY = 2.2
/** RWM-Hardware je Wohnung einmalig ohne Montage, die eine eigene Position ist. */
const RWM_ONE_TIME = 30
/** Montage durch SolenOS: je neuem Melder und je Wohnung mit Messtechnik. */
const INSTALL_PER_RWM = 10
const INSTALL_PER_UNIT = 10
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 * Keeps a partially edited number field empty instead of snapping it to 0, so
@@ -167,7 +182,8 @@ function StepCard({
} }
/** /**
* Selectable service tile (step 1) — the active option carries a brand border. * Selectable service tile (step 1) — the whole tile is the toggle, the switch
* mirrors the state. The active option carries a brand border.
*/ */
function ServiceOption({ function ServiceOption({
id, id,
@@ -183,30 +199,22 @@ function ServiceOption({
description: string description: string
}) { }) {
return ( return (
<div <label
className={cn( className={cn(
"rounded-xl border p-4 transition-colors", "flex cursor-pointer items-start justify-between gap-4 rounded-xl border p-4 transition-colors",
checked checked
? "border-primary bg-brand-50/60 shadow-pill" ? "border-primary bg-brand-50/60 shadow-pill"
: "border-border bg-card hover:border-brand-200 hover:bg-brand-50/40" : "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="min-w-0 flex-1">
<div className="flex flex-wrap items-start justify-between gap-2"> <div className="flex flex-wrap items-center gap-2">
<Label <span
htmlFor={id} id={`${id}-title`}
className="cursor-pointer text-sm font-bold leading-snug text-navy" className="text-sm font-bold leading-snug text-navy"
> >
{title} {title}
</Label> </span>
<Badge <Badge
id={`${id}-state`} id={`${id}-state`}
variant={checked ? "default" : "muted"} variant={checked ? "default" : "muted"}
@@ -223,8 +231,15 @@ function ServiceOption({
{description} {description}
</p> </p>
</div> </div>
</div> <Switch
</div> id={id}
checked={checked}
onCheckedChange={onCheckedChange}
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-description ${id}-state`}
className="mt-0.5"
/>
</label>
) )
} }
@@ -281,14 +296,56 @@ function PackageCard({
/> />
{included ? null : ( {included ? null : (
<p className="mt-5 text-sm text-muted-foreground"> <p className="mt-5 text-sm text-muted-foreground">
Leistung in Schritt 1 auswählen, um sie in den Leistungsumfang Leistung in Schritt 1 auswählen, um sie in den Preis aufzunehmen.
aufzunehmen.
</p> </p>
)} )}
</Card> </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.
*/
type Position = {
label: string
amount: number
unit: string
note?: string
allocatable: boolean
/** A deselected position stays on the card as a faint zero. */
active: boolean
}
function PriceRow({
label,
amount,
unit,
note,
allocatable,
active,
}: Position) {
const shown = useAnimatedNumber(amount)
return (
<div className={cn("transition-opacity", !active && "opacity-45")}>
<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() { export default function ConfiguratorPage() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
@@ -336,6 +393,77 @@ export default function ConfiguratorPage() {
} }
const totalUnits = buildings.reduce((sum, b) => sum + (b.wohnungen || 0), 0) const totalUnits = buildings.reduce((sum, b) => sum + (b.wohnungen || 0), 0)
const unitWord = totalUnits === 1 ? "Wohnung" : "Wohnungen"
/*
* Melder je Wohnung: einer je Zimmer plus einer im Flur als Rettungsweg
* (DIN 14676). Die Hardware wird je Wohnung abgerechnet, die Montage je
* Gerät; die endgültige Anzahl ergibt sich aus der Detailkonfiguration.
*/
const rwmDevices = rwm
? Math.round(
buildings.reduce(
(sum, b) => sum + (b.wohnungen || 0) * ((b.zimmer || 0) + 1),
0
)
)
: 0
/* UVI und Abrechnung teilen sich die Messtechnik sie wird einmal montiert. */
const meteringUnits = uvi || billing ? totalUnits : 0
const installTotal =
rwmDevices * INSTALL_PER_RWM + meteringUnits * INSTALL_PER_UNIT
const installNotes: string[] = []
if (install && rwmDevices > 0) {
installNotes.push(
`${rwmDevices} Rauchwarnmelder × ${formatEuro(INSTALL_PER_RWM)}\u00A0€`
)
}
if (install && meteringUnits > 0) {
installNotes.push(
`Messtechnik in ${meteringUnits} ${unitWord} × ${formatEuro(INSTALL_PER_UNIT)}\u00A0€`
)
}
/*
* Every position stays on the card. A deselected one counts down to a faint
* zero, so a toggle reads as a change in the price instead of a row that
* vanishes.
*/
const positions: Position[] = [
{
label: "UVI-Komplettbetrieb",
amount: uvi ? UVI_MONTHLY : 0,
unit: "je Wohnung / Monat",
allocatable: true,
active: uvi,
},
{
label: "Ferninspektion Rauchwarnmelder",
amount: rwm ? INSPECTION_MONTHLY : 0,
unit: "je Wohnung / Monat",
allocatable: true,
active: rwm,
},
{
label: "Rauchwarnmelder-Geräte",
/* Hardware is billed once per Wohnung the estimate is the sum. */
amount: rwm ? RWM_ONE_TIME * totalUnits : 0,
unit: "einmalig gesamt",
note: rwm
? `${totalUnits} ${unitWord} × ${formatEuro(RWM_ONE_TIME)}\u00A0€`
: undefined,
allocatable: false,
active: rwm,
},
{
label: "Montage durch SolenOS",
amount: install ? installTotal : 0,
unit: "einmalig gesamt",
note: installNotes.length > 0 ? installNotes.join(" · ") : undefined,
allocatable: false,
active: install && installTotal > 0,
},
]
return ( return (
<Container> <Container>
@@ -350,7 +478,7 @@ export default function ConfiguratorPage() {
/> />
<SectionHeading <SectionHeading
title="Konfigurator" title="Konfigurator"
lead="Leistungen auswählen, Gebäudedaten online erfassen und die nächsten Schritte transparent planen." lead="Leistungen auswählen, Gebäudedaten erfassen und sofort eine Preisindikation erhalten."
/> />
</div> </div>
@@ -382,22 +510,22 @@ export default function ConfiguratorPage() {
/> />
</div> </div>
<Separator className="my-6" /> <Separator className="my-6" />
<div <label
className={cn( className={cn(
"flex items-start justify-between gap-4 rounded-xl border p-4 transition-colors", "flex cursor-pointer items-start justify-between gap-4 rounded-xl border p-4 transition-colors",
install install
? "border-primary bg-brand-50/60 shadow-pill" ? "border-primary bg-brand-50/60 shadow-pill"
: "border-border bg-card" : "border-border bg-card hover:border-brand-200 hover:bg-brand-50/40"
)} )}
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Label <span
htmlFor="installation" id="installation-title"
className="cursor-pointer text-sm font-bold text-navy" className="text-sm font-bold text-navy"
> >
Montage durch SolenOS Montage durch SolenOS
</Label> </span>
<Badge variant={install ? "default" : "muted"}> <Badge variant={install ? "default" : "muted"}>
{install ? "SolenOS montiert" : "Selbstmontage"} {install ? "SolenOS montiert" : "Selbstmontage"}
</Badge> </Badge>
@@ -407,7 +535,7 @@ export default function ConfiguratorPage() {
className="mt-1 text-sm text-muted-foreground" className="mt-1 text-sm text-muted-foreground"
> >
{install {install
? "SolenOS übernimmt Montage und Softwarekonfiguration. Der geprüfte Gesamtpreis folgt mit dem verbindlichen Angebot." ? "SolenOS übernimmt Montage und Softwarekonfiguration die Montage erscheint als eigene Position."
: "Sie, Ihr Hausmeister oder Ihr Fachbetrieb montieren. Eine Softwarekonfiguration vor Ort ist nicht erforderlich."} : "Sie, Ihr Hausmeister oder Ihr Fachbetrieb montieren. Eine Softwarekonfiguration vor Ort ist nicht erforderlich."}
</p> </p>
</div> </div>
@@ -415,10 +543,11 @@ export default function ConfiguratorPage() {
id="installation" id="installation"
checked={install} checked={install}
onCheckedChange={setInstall} onCheckedChange={setInstall}
aria-labelledby="installation-title"
aria-describedby="installation-description" aria-describedby="installation-description"
className="mt-0.5" className="mt-0.5"
/> />
</div> </label>
</StepCard> </StepCard>
<StepCard step={2} title="Gebäudedaten online erfassen"> <StepCard step={2} title="Gebäudedaten online erfassen">
@@ -599,105 +728,52 @@ export default function ConfiguratorPage() {
</StepCard> </StepCard>
</div> </div>
{/* Right column: sticky configuration summary */} {/* Right column: sticky price indication */}
<div className="lg:sticky lg:top-24"> <div className="lg:sticky lg:top-24">
<Card className="gap-0 overflow-hidden p-0 shadow-card-lg"> <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"> <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"> <p className="text-xs font-bold tracking-[0.14em] uppercase">
Ihre Konfiguration Geschätzter Preis
</p> </p>
<IconBadge variant="glass" size="lg"> <IconBadge variant="glass" size="lg">
<Check /> <Euro />
</IconBadge> </IconBadge>
</div> </div>
<div className="p-6 sm:p-7"> <div className="p-6 sm:p-7">
<p className="text-sm font-bold text-navy"> <p className="text-sm font-bold text-navy">
{buildings.length} Gebäude · {totalUnits}{" "} {buildings.length} Gebäude · {totalUnits} {unitWord}
{totalUnits === 1 ? "Wohnung" : "Wohnungen"}
</p> </p>
<Separator className="my-5" /> <Separator className="my-5" />
<div aria-live="polite"> <div className="flex flex-col gap-5" aria-live="polite">
<p className="text-xs font-bold tracking-wide text-navy uppercase"> {positions.map((position, index) => (
Gewählte Leistungen <React.Fragment key={position.label}>
</p> {index > 0 ? <Separator /> : null}
<ul className="mt-3 space-y-3 text-sm"> <PriceRow {...position} />
<li </React.Fragment>
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> </div>
<Separator className="my-5" /> <div className="mt-6 space-y-2 text-xs leading-relaxed text-muted-foreground">
<p className="text-sm font-bold text-navy"> {billing ? (
Geprüfter Gesamtpreis nach Detailkonfiguration <p>
Die Heizkostenabrechnung wird mit dem verbindlichen
Angebot bepreist.
</p> </p>
<p className="mt-2 text-xs leading-relaxed text-muted-foreground"> ) : null}
Wir veröffentlichen keine ungeprüften Preiswerte. Nach der <p>
manuellen Prüfung erhalten Sie ein verbindliches Die erste Berechnung ist eine unverbindliche
Gesamtangebot mit der erforderlichen Gateway-Infrastruktur, Preisindikation. Der genaue Preis ergibt sich aus der
den gewählten Leistungen und der gewählten Montagevariante. vollständigen Objektkonfiguration.
</p> </p>
</div> </div>
</div>
</Card> </Card>
</div> </div>
</div> </div>
</Section> </Section>
{/* Vollständiger Leistungsumfang des späteren Gesamtangebots */} {/* Was ist in dem Preis enthalten? */}
<Section className="pt-0 sm:pt-0 lg:pt-0"> <Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading title="Was gehört in das Gesamtangebot?" /> <SectionHeading title="Was ist in dem Preis enthalten?" />
<div className="mt-8 grid gap-6 lg:grid-cols-3"> <div className="mt-8 grid gap-6 lg:grid-cols-3">
<PackageCard <PackageCard
render={renders.gateway} render={renders.gateway}
@@ -730,7 +806,7 @@ export default function ConfiguratorPage() {
"Verwaltung und Ferninspektion der Geräte", "Verwaltung und Ferninspektion der Geräte",
"Prüf- und Statusdokumentation", "Prüf- und Statusdokumentation",
install install
? "Montage durch SolenOS im verbindlichen Gesamtangebot" ? "Montage durch SolenOS als eigene Position"
: "Montage durch Sie oder Ihren Fachbetrieb", : "Montage durch Sie oder Ihren Fachbetrieb",
]} ]}
/> />