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 } /** 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")), 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 = { 1: renders.glassBadge1, 2: renders.glassBadge2, 3: renders.glassBadge3, } 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({ checked, onCheckedChange, title, description, }: { checked: boolean onCheckedChange: (checked: boolean) => void title: string description: string }) { return ( ) } /** * 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 Preis aufzunehmen.

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

{label}

ca. {formatEuro(shown)} {"\u00A0"}€

{unit}

{note ? (

{note}

) : null} {allocatable ? : } {allocatable ? "Umlagefähig" : "Nicht umlagefähig"}
) } 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(() => [ { ...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) => { 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 (
{/* Left column: steps */}

{install ? "GebOS organisiert die Montage – Geräte- und Monatspreise enthalten die Installation." : "Sie selbst, Ihr Hausmeister oder Ihr Fachbetrieb montiert die Geräte."}

{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), }) } />
))}
e.preventDefault()} className="flex flex-col gap-3" >
setEmail(e.target.value)} />

Sie erhalten einen persönlichen Link zu Ihrer Konfiguration. Damit kannst du sofort weitermachen, später zurückkehren oder den Link intern weitergeben.

{/* Right column: sticky price indication */}

Geschätzter Preis

{uvi || rwm ? (
{uvi ? ( ) : null} {rwm ? ( <> {uvi ? : null} ) : null}
) : (

Wählen Sie in Schritt 1 mindestens eine Leistung, um eine Preisindikation zu erhalten.

)}

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

{/* Was ist in der Schätzung enthalten? */}
) }