742 lines
27 KiB
TypeScript
742 lines
27 KiB
TypeScript
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 & 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>
|
||
)
|
||
}
|