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 } /** 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 = {} 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 (

Schritt {step}: {title}

{children}
) } /** * 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 (
onCheckedChange(value === true)} aria-describedby={`${id}-description ${id}-state`} className="mt-0.5 size-6" />
{checked ? "Enthalten · abwählbar" : "Hinzufügen"}

{description}

) } /** * 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 (

{title}

{included ?
{included ? null : (

Leistung in Schritt 1 auswählen, um sie in den Leistungsumfang aufzunehmen.

)}
) } 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(() => [ { ...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) => { 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 (
{/* Left column: steps */}
{install ? "SolenOS montiert" : "Selbstmontage"}

{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."}

{buildings.map((b, i) => (
Gebäude {i + 1}
{buildings.length > 1 ? ( ) : null}
updateBuilding(b.id, { adresse: e.target.value }) } />
updateBuilding(b.id, { wohnungen: parseCount(e.target.value), }) } onBlur={() => updateBuilding(b.id, { wohnungen: settle(b.wohnungen, 1), }) } />
updateBuilding(b.id, { zimmer: parseAmount(e.target.value), }) } onBlur={() => updateBuilding(b.id, { zimmer: settle(b.zimmer, 1), }) } />
))}

So geht es nach der E-Mail weiter

  1. Wir speichern Ihre bisherige Auswahl und senden Ihnen einen persönlichen Link.
  2. Ü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.
  3. SolenOS prüft die Angaben und Fotos manuell, bestimmt die endgültige Ausstattung und erstellt anschließend den genauen Gesamtpreis beziehungsweise ein verbindliches Angebot.

Beispiel für spätere Detailfragen: Kinderzimmer und regelmäßig genutzte Gästezimmer zählen als Schlafräume; ein dauerhaftes Büro nicht.

event.preventDefault()} className="mt-5 flex flex-col gap-3" >
setEmail(event.target.value)} aria-describedby="config-email-privacy" required />

Wir verwenden Ihre E-Mail-Adresse, um den persönlichen Link zu versenden. Einzelheiten finden Sie in unseren{" "} Hinweisen zu Datenschutz & Sicherheit .

{/* Right column: sticky configuration summary */}

Ihre Konfiguration

{buildings.length} Gebäude · {totalUnits}{" "} {totalUnits === 1 ? "Wohnung" : "Wohnungen"}

Gewählte Leistungen

  • {uvi ? ( ) : ( )} Monatliche Verbrauchsinformation {uvi ? " – enthalten" : " – nicht enthalten"}
  • {billing ? ( ) : ( )} Heizkostenabrechnung {billing ? " – enthalten" : " – nicht enthalten"}
  • {rwm ? ( ) : ( )} Rauchwarnmelder {rwm ? " – enthalten" : " – nicht enthalten"}
  • {install ? "Montage durch SolenOS" : "Montage durch Sie oder Ihren Fachbetrieb"}

Geprüfter Gesamtpreis nach Detailkonfiguration

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.

{/* Vollständiger Leistungsumfang des späteren Gesamtangebots */}
) }