690 lines
24 KiB
TypeScript
690 lines
24 KiB
TypeScript
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<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")),
|
||
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<Step, RenderAsset> = {
|
||
1: renders.glassBadge1,
|
||
2: renders.glassBadge2,
|
||
3: renders.glassBadge3,
|
||
}
|
||
|
||
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">
|
||
<AssetRender
|
||
render={STEP_BADGES[step]}
|
||
className="flex size-11 shrink-0 items-center justify-center"
|
||
imgClassName="w-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({
|
||
checked,
|
||
onCheckedChange,
|
||
title,
|
||
description,
|
||
}: {
|
||
checked: boolean
|
||
onCheckedChange: (checked: boolean) => void
|
||
title: string
|
||
description: string
|
||
}) {
|
||
return (
|
||
<label
|
||
className={cn(
|
||
"flex cursor-pointer items-start gap-3 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"
|
||
)}
|
||
>
|
||
<Checkbox
|
||
checked={checked}
|
||
onCheckedChange={(v) => onCheckedChange(v === true)}
|
||
className="mt-0.5"
|
||
/>
|
||
<span>
|
||
<span className="block text-sm font-bold text-navy">{title}</span>
|
||
<span className="block text-sm text-muted-foreground">{description}</span>
|
||
</span>
|
||
</label>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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 Preis aufzunehmen.
|
||
</p>
|
||
)}
|
||
</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.
|
||
*/
|
||
function PriceRow({
|
||
label,
|
||
amount,
|
||
unit,
|
||
note,
|
||
allocatable,
|
||
}: {
|
||
label: string
|
||
amount: number
|
||
unit: string
|
||
note?: string
|
||
allocatable: boolean
|
||
}) {
|
||
const shown = useAnimatedNumber(amount)
|
||
|
||
return (
|
||
<div>
|
||
<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() {
|
||
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<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)
|
||
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)
|
||
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 (
|
||
<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="In 3 Schritten zur Preisindikation."
|
||
/>
|
||
</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
|
||
checked={uvi}
|
||
onCheckedChange={setUvi}
|
||
title="UVI"
|
||
description="Unterjährige Verbrauchsinformation inklusive Messtechnik"
|
||
/>
|
||
<ServiceOption
|
||
checked={rwm}
|
||
onCheckedChange={setRwm}
|
||
title="Rauchwarnmelder"
|
||
description="Rauchwarnmelder inklusive digitaler Dienstleistungen"
|
||
/>
|
||
</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">
|
||
<Label
|
||
htmlFor="installation"
|
||
className="cursor-pointer text-sm font-bold text-navy"
|
||
>
|
||
Installation durch GebOS
|
||
</Label>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
{install
|
||
? "GebOS organisiert die Montage – Geräte- und Monatspreise enthalten die Installation."
|
||
: "Sie selbst, Ihr Hausmeister oder Ihr Fachbetrieb montiert die Geräte."}
|
||
</p>
|
||
</div>
|
||
<Switch
|
||
id="installation"
|
||
checked={install}
|
||
onCheckedChange={setInstall}
|
||
className="mt-0.5"
|
||
/>
|
||
</div>
|
||
</StepCard>
|
||
|
||
<StepCard step={2} title="Gebäude angeben">
|
||
<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="Konfiguration speichern">
|
||
<form
|
||
onSubmit={(e) => e.preventDefault()}
|
||
className="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={(e) => setEmail(e.target.value)}
|
||
/>
|
||
</div>
|
||
<Button type="submit" className="self-start">
|
||
Konfiguration speichern & fortsetzen
|
||
</Button>
|
||
<p className="text-xs text-muted-foreground">
|
||
Sie erhalten einen persönlichen Link
|
||
zu Ihrer Konfiguration. Damit kannst du
|
||
sofort weitermachen, später zurückkehren
|
||
oder den Link intern weitergeben.
|
||
</p>
|
||
</form>
|
||
</StepCard>
|
||
</div>
|
||
|
||
{/* Right column: sticky price indication */}
|
||
<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">
|
||
Geschätzter Preis
|
||
</p>
|
||
<IconBadge variant="glass" size="lg">
|
||
<Euro />
|
||
</IconBadge>
|
||
</div>
|
||
<div className="p-6 sm:p-7">
|
||
{uvi || rwm ? (
|
||
<div className="flex flex-col gap-5">
|
||
{uvi ? (
|
||
<PriceRow
|
||
label="UVI-Komplettbetrieb"
|
||
amount={uviMonthly}
|
||
unit="je Wohnung / Monat"
|
||
note={installNote}
|
||
allocatable
|
||
/>
|
||
) : null}
|
||
{rwm ? (
|
||
<>
|
||
{uvi ? <Separator /> : null}
|
||
<PriceRow
|
||
label="Ferninspektion Rauchwarnmelder"
|
||
amount={inspectionMonthly}
|
||
unit="je Wohnung / Monat"
|
||
note={installNote}
|
||
allocatable
|
||
/>
|
||
<Separator />
|
||
<PriceRow
|
||
label="Rauchwarnmelder-Geräte"
|
||
amount={rwmOneTimeTotal}
|
||
unit="einmalig gesamt"
|
||
note={`${totalUnits} ${totalUnits === 1 ? "Wohnung" : "Wohnungen"} × ${formatEuro(rwmRatePerUnit)}\u00A0€ · ${install ? "inklusive Montage" : "Selbstmontage"}`}
|
||
allocatable={false}
|
||
/>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">
|
||
Wählen Sie in Schritt 1 mindestens eine Leistung, um eine
|
||
Preisindikation zu erhalten.
|
||
</p>
|
||
)}
|
||
<p className="mt-6 text-xs leading-relaxed text-muted-foreground">
|
||
{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.
|
||
</p>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
|
||
{/* Was ist in der Schätzung enthalten? */}
|
||
<Section className="pt-0 sm:pt-0 lg:pt-0">
|
||
<SectionHeading
|
||
title="Was ist in dem Preis enthalten?"
|
||
/>
|
||
<div className="mt-8 grid gap-6 lg:grid-cols-2">
|
||
<PackageCard
|
||
render={renders.gateway}
|
||
title="UVI"
|
||
included={uvi}
|
||
items={[
|
||
"geschätzte benötigte Messtechnik",
|
||
"GebOS Plattform",
|
||
"Datenerfassung",
|
||
"Unterjährige Verbrauchsinformation",
|
||
"laufender digitaler Betrieb",
|
||
]}
|
||
/>
|
||
<PackageCard
|
||
render={renders.smokeAlarm}
|
||
title="Rauchwarnmelder"
|
||
included={rwm}
|
||
items={[
|
||
"geschätzte Anzahl Rauchwarnmelder",
|
||
"Verwaltung der Geräte",
|
||
"gebuchte digitale Dienstleistungen",
|
||
install
|
||
? "Montage und Eigentumsübertragung"
|
||
: "Eigentumsübertragung – Montage durch Sie",
|
||
]}
|
||
/>
|
||
</div>
|
||
</Section>
|
||
</Container>
|
||
)
|
||
}
|