554 lines
22 KiB
TypeScript
554 lines
22 KiB
TypeScript
import * as React from "react"
|
||
import { Link } from "react-router-dom"
|
||
import { ArrowRight, Euro, Plus, X } from "lucide-react"
|
||
|
||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||
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 { Checklist } from "@/components/gebos/checklist"
|
||
import { DonutIllustration } from "@/components/gebos/illustrations"
|
||
import { IconBadge } from "@/components/gebos/icon-tile"
|
||
import { Container, Section, SectionHeading } from "@/components/gebos/section"
|
||
import { PageBreadcrumb } from "@/components/gebos/page-breadcrumb"
|
||
import { icons, vectors } from "@/components/gebos/site-assets"
|
||
|
||
type Heat = "heizkoerper" | "fussbodenheizung" | "gemischt" | "unbekannt"
|
||
|
||
type Building = {
|
||
id: number
|
||
adresse: string
|
||
heat: Heat
|
||
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_BUILDINGS: Building[] = [
|
||
{ id: 1, adresse: "", heat: "heizkoerper", wohnungen: 12, zimmer: 3 },
|
||
{ id: 2, adresse: "", heat: "heizkoerper", wohnungen: 8, zimmer: 3 },
|
||
{ id: 3, adresse: "", heat: "heizkoerper", wohnungen: 6, zimmer: 3 },
|
||
]
|
||
|
||
const UVI_MONTHLY = 10.5
|
||
const INSPECTION_MONTHLY = 2.2
|
||
const RWM_ONE_TIME = 42
|
||
|
||
function formatEuro(value: number) {
|
||
return value.toLocaleString("de-DE", {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2,
|
||
})
|
||
}
|
||
|
||
function StepCard({
|
||
step,
|
||
title,
|
||
children,
|
||
}: {
|
||
step: number
|
||
title: string
|
||
children: React.ReactNode
|
||
}) {
|
||
return (
|
||
<Card className="gap-0 p-6 sm:p-7">
|
||
<div className="flex items-center gap-3">
|
||
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-extrabold text-primary-foreground">
|
||
{step}
|
||
</span>
|
||
<h3 className="text-lg font-extrabold tracking-tight text-navy">
|
||
{title}
|
||
</h3>
|
||
</div>
|
||
<div className="mt-5">{children}</div>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
export default function ConfiguratorPage() {
|
||
const [uvi, setUvi] = React.useState(true)
|
||
const [rwm, setRwm] = React.useState(true)
|
||
const [buildings, setBuildings] = React.useState<Building[]>(INITIAL_BUILDINGS)
|
||
const [email, setEmail] = React.useState("")
|
||
const nextId = React.useRef(INITIAL_BUILDINGS.length + 1)
|
||
|
||
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 totalBuildings = buildings.length
|
||
const totalUnits = buildings.reduce((sum, b) => sum + (b.wohnungen || 0), 0)
|
||
const sensors = buildings.reduce(
|
||
(sum, b) => sum + Math.round((b.wohnungen || 0) * (b.zimmer || 0) * 0.92),
|
||
0
|
||
)
|
||
const smokeDetectors = buildings.reduce(
|
||
(sum, b) => sum + Math.round((b.wohnungen || 0) * ((b.zimmer || 0) + 0.7)),
|
||
0
|
||
)
|
||
const monthly =
|
||
(uvi ? UVI_MONTHLY : 0) + (uvi || rwm ? INSPECTION_MONTHLY : 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 4 Schritten zur Preisindikation."
|
||
/>
|
||
<div
|
||
aria-hidden="true"
|
||
className="pointer-events-none absolute -top-6 right-0 hidden items-end lg:flex"
|
||
>
|
||
<img src={vectors.euroOrb} alt="" className="w-40" />
|
||
<img
|
||
src={vectors.consumptionRing}
|
||
alt=""
|
||
className="-mb-3 -ml-6 w-44"
|
||
/>
|
||
</div>
|
||
</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-4">
|
||
<label className="flex cursor-pointer items-start gap-3">
|
||
<Checkbox
|
||
checked={uvi}
|
||
onCheckedChange={(v) => setUvi(v === true)}
|
||
className="mt-0.5"
|
||
/>
|
||
<span>
|
||
<span className="block text-sm font-bold text-navy">UVI</span>
|
||
<span className="block text-sm text-muted-foreground">
|
||
Unterjährige Verbrauchsinformation inklusive Messtechnik
|
||
</span>
|
||
</span>
|
||
</label>
|
||
<label className="flex cursor-pointer items-start gap-3">
|
||
<Checkbox
|
||
checked={rwm}
|
||
onCheckedChange={(v) => setRwm(v === true)}
|
||
className="mt-0.5"
|
||
/>
|
||
<span>
|
||
<span className="block text-sm font-bold text-navy">
|
||
Rauchwarnmelder
|
||
</span>
|
||
<span className="block text-sm text-muted-foreground">
|
||
Rauchwarnmelder inklusive digitaler Dienstleistungen
|
||
</span>
|
||
</span>
|
||
</label>
|
||
</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: Math.max(
|
||
0,
|
||
Math.round(Number(e.target.value) || 0)
|
||
),
|
||
})
|
||
}
|
||
/>
|
||
</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: Math.max(0, Number(e.target.value) || 0),
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
className="self-start"
|
||
onClick={addBuilding}
|
||
>
|
||
<Plus /> Gebäude hinzufügen
|
||
</Button>
|
||
</div>
|
||
</StepCard>
|
||
|
||
<StepCard step={3} title="Preis sehen">
|
||
<p className="text-sm text-muted-foreground">
|
||
Sofortige erste Preisindikation.
|
||
</p>
|
||
<Accordion type="single" collapsible className="mt-3">
|
||
<AccordionItem value="annahmen" className="border-b-0">
|
||
<AccordionTrigger className="text-sm font-bold text-navy">
|
||
Wie wurde dieser Preis berechnet?
|
||
</AccordionTrigger>
|
||
<AccordionContent className="text-sm text-muted-foreground">
|
||
<p>
|
||
Für diese Schätzung gehen wir aufgrund Ihrer Angaben von
|
||
ungefähr{" "}
|
||
<strong className="text-navy">
|
||
{sensors} Heizsensoren und {smokeDetectors}{" "}
|
||
Rauchwarnmeldern
|
||
</strong>{" "}
|
||
aus.
|
||
</p>
|
||
<p className="mt-2">
|
||
Bei Gebäuden mit durchschnittlich drei Zimmern verwenden
|
||
wir eine typische Geräteausstattung als
|
||
Berechnungsgrundlage.
|
||
</p>
|
||
<p className="mt-2">
|
||
Die endgültige Anzahl wird bei der detaillierten
|
||
Konfiguration der Wohnungen bestimmt und ersetzt diese
|
||
Schätzwerte.
|
||
</p>
|
||
</AccordionContent>
|
||
</AccordionItem>
|
||
</Accordion>
|
||
</StepCard>
|
||
|
||
<StepCard step={4} 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 (gebos.de/konfiguration/…)
|
||
zu Ihrer Konfiguration – sofort weitermachen, später
|
||
zurückkehren oder den Link intern weitergeben.
|
||
</p>
|
||
</form>
|
||
</StepCard>
|
||
|
||
<div className="flex flex-wrap gap-3">
|
||
<Button type="button">Jetzt Preis berechnen</Button>
|
||
<Button variant="outline" asChild>
|
||
<Link to="/so-funktionierts">So funktioniert der Konfigurator</Link>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right column: sticky summary */}
|
||
<div className="lg:sticky lg:top-24">
|
||
<Card className="gap-0 p-6 shadow-card-lg sm:p-7">
|
||
<h3 className="text-base font-extrabold tracking-tight text-navy">
|
||
Ihre vorläufige Konfiguration
|
||
</h3>
|
||
<div className="mt-4 flex flex-col gap-1">
|
||
<div className="text-2xl font-extrabold tracking-tight text-navy">
|
||
{totalBuildings}{" "}
|
||
{totalBuildings === 1 ? "Gebäude" : "Gebäude"}
|
||
</div>
|
||
<div className="text-2xl font-extrabold tracking-tight text-navy">
|
||
{totalUnits} {totalUnits === 1 ? "Wohnung" : "Wohnungen"}
|
||
</div>
|
||
</div>
|
||
{uvi || rwm ? (
|
||
<>
|
||
<Separator className="my-4" />
|
||
<p className="text-xs font-bold tracking-[0.14em] text-muted-foreground uppercase">
|
||
Voraussichtlich benötigt
|
||
</p>
|
||
<ul className="mt-2 flex flex-col gap-1.5 text-sm text-navy">
|
||
{uvi ? (
|
||
<li className="font-semibold">
|
||
ca. {sensors} Heizsensoren
|
||
</li>
|
||
) : null}
|
||
{rwm ? (
|
||
<li className="font-semibold">
|
||
ca. {smokeDetectors} Rauchwarnmelder
|
||
</li>
|
||
) : null}
|
||
</ul>
|
||
</>
|
||
) : null}
|
||
|
||
<div className="mt-5 rounded-xl bg-brand-gradient p-4 text-white shadow-band">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-2xl font-extrabold tracking-tight">
|
||
ca. {formatEuro(monthly)}{"\u00A0"}€
|
||
</div>
|
||
<div className="text-sm text-white/80">
|
||
je Wohnung / Monat
|
||
</div>
|
||
</div>
|
||
<IconBadge variant="glass" size="lg">
|
||
<Euro />
|
||
</IconBadge>
|
||
</div>
|
||
{rwm ? (
|
||
<div className="mt-3 border-t border-white/20 pt-2 text-xs text-white/80">
|
||
+ {formatEuro(RWM_ONE_TIME)}{"\u00A0"}€ je Wohnung einmalig
|
||
(RWM-Hardware)
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="mt-5 flex items-start gap-4">
|
||
<DonutIllustration className="w-16 shrink-0" />
|
||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||
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 der Schätzung enthalten?"
|
||
lead="Je nach gewähltem Paket beispielsweise:"
|
||
/>
|
||
<div className="mt-8 grid gap-6 lg:grid-cols-2">
|
||
<Card
|
||
className={
|
||
"gap-0 p-6 transition-opacity sm:p-8" +
|
||
(uvi ? "" : " opacity-50")
|
||
}
|
||
>
|
||
<div className="flex items-center gap-4">
|
||
<img src={icons.uvi} alt="" className="size-14" />
|
||
<h3 className="text-xl font-extrabold tracking-tight text-navy">
|
||
UVI
|
||
</h3>
|
||
</div>
|
||
<Checklist
|
||
className="mt-5"
|
||
items={[
|
||
"geschätzte benötigte Messtechnik",
|
||
"GebOS Plattform",
|
||
"Datenerfassung",
|
||
"Unterjährige Verbrauchsinformation",
|
||
"laufender digitaler Betrieb",
|
||
]}
|
||
/>
|
||
</Card>
|
||
<Card
|
||
className={
|
||
"gap-0 p-6 transition-opacity sm:p-8" +
|
||
(rwm ? "" : " opacity-50")
|
||
}
|
||
>
|
||
<div className="flex items-center gap-4">
|
||
<img src={icons.smokeAlarm} alt="" className="size-14" />
|
||
<h3 className="text-xl font-extrabold tracking-tight text-navy">
|
||
Rauchwarnmelder
|
||
</h3>
|
||
</div>
|
||
<Checklist
|
||
className="mt-5"
|
||
items={[
|
||
"geschätzte Anzahl Rauchwarnmelder",
|
||
"Verwaltung der Geräte",
|
||
"gebuchte digitale Dienstleistungen",
|
||
]}
|
||
/>
|
||
</Card>
|
||
</div>
|
||
</Section>
|
||
|
||
{/* Stufe 2 – So geht es weiter */}
|
||
<Section className="pt-0 pb-16 sm:pt-0 sm:pb-20 lg:pt-0 lg:pb-24">
|
||
<SectionHeading
|
||
eyebrow="So geht es weiter"
|
||
title="Erst schätzen. Dann genau konfigurieren."
|
||
lead="Der vollständige Konfigurator beginnt nicht wieder bei null. Die bereits erfassten Informationen werden übernommen – und die bisherigen Annahmen schrittweise durch echte Daten ersetzt."
|
||
/>
|
||
<div className="mt-8 flex flex-col gap-3">
|
||
<div className="flex flex-wrap items-center gap-2.5">
|
||
<Badge variant="muted" className="px-3.5 py-1.5 text-sm">
|
||
Geschätzt: 72 Heizsensoren
|
||
</Badge>
|
||
<ArrowRight className="size-4 text-primary" aria-hidden="true" />
|
||
<Badge className="px-3.5 py-1.5 text-sm">
|
||
Konfiguriert: 68 Heizsensoren
|
||
</Badge>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2.5">
|
||
<Badge variant="muted" className="px-3.5 py-1.5 text-sm">
|
||
Geschätzt: 96 Rauchwarnmelder
|
||
</Badge>
|
||
<ArrowRight className="size-4 text-primary" aria-hidden="true" />
|
||
<Badge className="px-3.5 py-1.5 text-sm">
|
||
Konfiguriert: 103 Rauchwarnmelder
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
|
||
<Card className="mt-10 max-w-2xl gap-0 p-6 sm:p-8">
|
||
<p className="text-xs font-bold tracking-[0.14em] text-primary uppercase">
|
||
Finales Ergebnis
|
||
</p>
|
||
<h3 className="mt-2 text-xl font-extrabold tracking-tight text-navy">
|
||
Ihre GebOS-Konfiguration
|
||
</h3>
|
||
<p className="mt-1 text-sm font-semibold text-navy">
|
||
3 Gebäude · 26 Wohnungen
|
||
</p>
|
||
<Separator className="my-4" />
|
||
<dl className="flex flex-col gap-3 text-sm">
|
||
<div>
|
||
<dt className="font-bold text-navy">UVI</dt>
|
||
<dd className="text-muted-foreground">
|
||
68 Heizsensoren / Messgeräte, weitere benötigte Messtechnik
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="font-bold text-navy">Rauchwarnmelder</dt>
|
||
<dd className="text-muted-foreground">103 Rauchwarnmelder</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="font-bold text-navy">Infrastruktur</dt>
|
||
<dd className="text-muted-foreground">
|
||
technisch ausgelegte Gateway- und Kommunikationsinfrastruktur
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
<Separator className="my-4" />
|
||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||
Ihr Preis ist dann keine grobe Hochrechnung auf Basis von
|
||
Durchschnittswerten mehr, sondern ein Preis auf Grundlage der
|
||
tatsächlichen Objektkonfiguration.
|
||
</p>
|
||
<div className="mt-5">
|
||
<Button type="button">Angebot anfordern</Button>
|
||
</div>
|
||
</Card>
|
||
|
||
<p className="mt-8 max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
||
Ihre Daten werden im gesamten Prozess weiterverwendet:
|
||
Preisschätzung → Konfiguration → Bestellung → Gerätevorbereitung →
|
||
Installation → laufender Betrieb.
|
||
</p>
|
||
</Section>
|
||
</Container>
|
||
)
|
||
}
|