save
This commit is contained in:
@@ -0,0 +1,553 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user