This commit is contained in:
Lars Nolden
2026-08-13 21:52:39 +02:00
parent 3dd74e53a0
commit 43c5b2e7ac
16 changed files with 603 additions and 342 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

@@ -1,18 +1,36 @@
import * as React from "react"
import { CircleCheck } from "lucide-react"
import { CircleCheck, CircleMinus } from "lucide-react"
import { cn } from "@/lib/utils"
/** "excluded" strikes the item out of the list: muted text, minus instead of check. */
type ChecklistTone = "included" | "excluded"
function ChecklistItem({
children,
className,
tone = "included",
}: {
children: React.ReactNode
className?: string
tone?: ChecklistTone
}) {
const excluded = tone === "excluded"
const Icon = excluded ? CircleMinus : CircleCheck
return (
<li className={cn("flex items-start gap-2.5 text-sm", className)}>
<CircleCheck className="mt-0.5 size-4 shrink-0 text-primary" />
<li
className={cn(
"flex items-start gap-2.5 text-sm",
excluded && "text-muted-foreground line-through decoration-muted-foreground/40",
className
)}
>
<Icon
className={cn(
"mt-0.5 size-4 shrink-0",
excluded ? "text-muted-foreground/70" : "text-primary"
)}
/>
<span>{children}</span>
</li>
)
@@ -21,14 +39,18 @@ function ChecklistItem({
function Checklist({
items,
className,
tone,
}: {
items: React.ReactNode[]
className?: string
tone?: ChecklistTone
}) {
return (
<ul className={cn("flex flex-col gap-2.5", className)}>
{items.map((item, i) => (
<ChecklistItem key={i}>{item}</ChecklistItem>
<ChecklistItem key={i} tone={tone}>
{item}
</ChecklistItem>
))}
</ul>
)
@@ -22,10 +22,18 @@ import cubesRoundWebp from "@/assets/gebos/cubes-round.webp"
import drillToolAvif from "@/assets/gebos/drill-tool.avif"
import drillToolWebp from "@/assets/gebos/drill-tool.webp"
import euroOrb from "@/assets/gebos/euro-orb.svg"
import floorHeatingAvif from "@/assets/gebos/floor-heating.avif"
import floorHeatingWebp from "@/assets/gebos/floor-heating.webp"
import fullHeroSectionAvif from "@/assets/gebos/full-hero-section.avif"
import fullHeroSectionWebp from "@/assets/gebos/full-hero-section.webp"
import gatewayAvif from "@/assets/gebos/gateway.avif"
import gatewayWebp from "@/assets/gebos/gateway.webp"
import glassBadge1Avif from "@/assets/gebos/glass-badge-1.avif"
import glassBadge1Webp from "@/assets/gebos/glass-badge-1.webp"
import glassBadge2Avif from "@/assets/gebos/glass-badge-2.avif"
import glassBadge2Webp from "@/assets/gebos/glass-badge-2.webp"
import glassBadge3Avif from "@/assets/gebos/glass-badge-3.avif"
import glassBadge3Webp from "@/assets/gebos/glass-badge-3.webp"
import glassCubes from "@/assets/gebos/glass-cubes.svg"
import glassCubesStackAvif from "@/assets/gebos/glass-cubes-stack.avif"
import glassCubesStackWebp from "@/assets/gebos/glass-cubes-stack.webp"
@@ -43,6 +51,8 @@ import laptopWebp from "@/assets/gebos/laptop-dashboard.webp"
import laptopPortfolioAvif from "@/assets/gebos/laptop-portfolio.avif"
import laptopPortfolioWebp from "@/assets/gebos/laptop-portfolio.webp"
import packageBox from "@/assets/gebos/package-box.svg"
import radiatorAvif from "@/assets/gebos/radiator.avif"
import radiatorWebp from "@/assets/gebos/radiator.webp"
import refreshLoopCloudAvif from "@/assets/gebos/refresh-loop-cloud.avif"
import refreshLoopCloudWebp from "@/assets/gebos/refresh-loop-cloud.webp"
import refreshLoopAvif from "@/assets/gebos/refresh-loop.avif"
@@ -201,6 +211,27 @@ const renders = {
alt: "",
matte: "alpha",
},
/** glossy numbered badge "1" process-step numeral, genuine alpha */
glassBadge1: {
avif: glassBadge1Avif,
webp: glassBadge1Webp,
alt: "",
matte: "alpha",
},
/** glossy numbered badge "2" process-step numeral, genuine alpha */
glassBadge2: {
avif: glassBadge2Avif,
webp: glassBadge2Webp,
alt: "",
matte: "alpha",
},
/** glossy numbered badge "3" process-step numeral, genuine alpha */
glassBadge3: {
avif: glassBadge3Avif,
webp: glassBadge3Webp,
alt: "",
matte: "alpha",
},
cubesRound: {
avif: cubesRoundAvif,
webp: cubesRoundWebp,
@@ -219,6 +250,18 @@ const renders = {
alt: "",
matte: "alpha",
},
radiator: {
avif: radiatorAvif,
webp: radiatorWebp,
alt: "Heizkörper",
matte: "alpha",
},
floorHeating: {
avif: floorHeatingAvif,
webp: floorHeatingWebp,
alt: "Fußbodenheizungsrohre",
matte: "alpha",
},
workerTablet: {
avif: workerTabletAvif,
webp: workerTabletWebp,
@@ -1,5 +1,6 @@
import * as React from "react"
import { ChevronDown, Menu } from "lucide-react"
import { Link, NavLink, useLocation } from "react-router-dom"
import { Link, NavLink, matchPath, useLocation } from "react-router-dom"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -21,44 +22,120 @@ const AUDIENCES = [
{ to: "/fuer-wen/installateure", label: "Installateure & Heizungsbauer" },
]
const NAV_LINKS = [
type NavItem = {
to: string
label: string
/** present ⇒ rendered as a dropdown; `to` stays the section root that marks it active */
menu?: typeof AUDIENCES
}
/** Source of truth for nav order desktop pill, dropdown and mobile sheet all read it. */
const NAV_ITEMS: NavItem[] = [
{ to: "/loesungen", label: "Lösungen" },
{ to: "/so-funktionierts", label: "So funktionierts" },
/* "Für wen?" rendered separately as dropdown */
{ to: "/fuer-wen", label: "Für wen?", menu: AUDIENCES },
{ to: "/konfigurator", label: "Konfigurator" },
{ to: "/unternehmen", label: "Unternehmen" },
{ to: "/kontakt", label: "Kontakt" },
]
/**
* Only colour changes between states: the highlight itself is one shared pill
* that slides, so a weight change here would reflow the row it has to land on.
*/
const navItemClass = (active: boolean) =>
cn(
"rounded-full px-3 py-1.5 text-sm font-medium transition-colors",
active
? "bg-accent font-semibold text-brand-800 ring-1 ring-brand-200"
: "text-foreground/70 hover:text-navy"
"relative z-10 rounded-full px-3 py-1.5 text-sm font-medium transition-colors duration-300",
active ? "text-brand-800" : "text-foreground/70 hover:text-navy"
)
function ForWhomMenu() {
const { pathname } = useLocation()
const active = pathname.startsWith("/fuer-wen")
/**
* Slide + width morph. The ease overshoots just enough to read as a spring
* overshoot scales with travel distance, so anything springier bleeds the pill
* out of the row and into the logo on a full-width jump.
*/
const pillClass = cn(
"pointer-events-none absolute top-0 left-0 rounded-full bg-accent ring-1 ring-brand-200",
"transition-[transform,width,height,opacity] duration-[440ms] ease-[cubic-bezier(0.34,1.16,0.42,1)]",
"motion-reduce:transition-none"
)
type Pill = {
left: number
top: number
width: number
height: number
/** no nav item matches the route (home, Klarpreis …) fade out, keep the geometry */
visible: boolean
}
const samePill = (a: Pill, b: Pill) =>
a.left === b.left &&
a.top === b.top &&
a.width === b.width &&
a.height === b.height &&
a.visible === b.visible
/**
* Measures the active item so a single highlight can travel between menus.
* Re-measures whenever the row resizes (viewport, font swap), and reads layout
* before paint so the pill never shows up a frame late at the old position.
*/
function useActivePill(activeKey: string | undefined) {
const navRef = React.useRef<HTMLElement | null>(null)
const [pill, setPill] = React.useState<Pill | null>(null)
React.useLayoutEffect(() => {
const nav = navRef.current
if (!nav) return
const measure = () => {
const item = activeKey
? nav.querySelector<HTMLElement>(`[data-nav="${activeKey}"]`)
: null
setPill((prev) => {
if (!item) return prev?.visible ? { ...prev, visible: false } : prev
const next: Pill = {
left: item.offsetLeft,
top: item.offsetTop,
width: item.offsetWidth,
height: item.offsetHeight,
visible: true,
}
return prev && samePill(prev, next) ? prev : next
})
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(nav)
return () => observer.disconnect()
}, [activeKey])
return { navRef, pill }
}
function AudienceMenu({ item, active }: { item: NavItem; active: boolean }) {
return (
<DropdownMenu>
<DropdownMenuTrigger
data-nav={item.to}
className={cn(
navItemClass(active),
"inline-flex cursor-pointer items-center gap-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40"
)}
>
Für wen?
{item.label}
<ChevronDown className="size-3.5 opacity-60" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem asChild>
<Link to="/fuer-wen">Übersicht</Link>
<Link to={item.to}>Übersicht</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{AUDIENCES.map((a) => (
{item.menu?.map((a) => (
<DropdownMenuItem key={a.to} asChild>
<Link to={a.to}>{a.label}</Link>
</DropdownMenuItem>
@@ -68,6 +145,45 @@ function ForWhomMenu() {
)
}
function DesktopNav() {
const { pathname } = useLocation()
/** mirrors NavLink's default (non-`end`) matching, but for the whole row at once */
const activeKey = NAV_ITEMS.find((item) =>
matchPath({ path: item.to, end: false }, pathname)
)?.to
const { navRef, pill } = useActivePill(activeKey)
return (
<nav ref={navRef} className="relative hidden items-center gap-1 lg:flex">
{pill && (
<span
aria-hidden
className={cn(pillClass, pill.visible ? "opacity-100" : "opacity-0")}
style={{
transform: `translate3d(${pill.left}px, ${pill.top}px, 0)`,
width: pill.width,
height: pill.height,
}}
/>
)}
{NAV_ITEMS.map((item) =>
item.menu ? (
<AudienceMenu key={item.to} item={item} active={item.to === activeKey} />
) : (
<NavLink
key={item.to}
to={item.to}
data-nav={item.to}
className={navItemClass(item.to === activeKey)}
>
{item.label}
</NavLink>
)
)}
</nav>
)
}
function MobileMenu() {
return (
<DropdownMenu>
@@ -78,27 +194,27 @@ function MobileMenu() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{NAV_LINKS.slice(0, 2).map((l) => (
<DropdownMenuItem key={l.to} asChild>
<Link to={l.to}>{l.label}</Link>
</DropdownMenuItem>
))}
{NAV_ITEMS.map((item) =>
item.menu ? (
<React.Fragment key={item.to}>
<DropdownMenuSeparator />
<DropdownMenuLabel>Für wen?</DropdownMenuLabel>
<DropdownMenuLabel>{item.label}</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link to="/fuer-wen">Übersicht</Link>
<Link to={item.to}>Übersicht</Link>
</DropdownMenuItem>
{AUDIENCES.map((a) => (
{item.menu.map((a) => (
<DropdownMenuItem key={a.to} asChild>
<Link to={a.to}>{a.label}</Link>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{NAV_LINKS.slice(2).map((l) => (
<DropdownMenuItem key={l.to} asChild>
<Link to={l.to}>{l.label}</Link>
</React.Fragment>
) : (
<DropdownMenuItem key={item.to} asChild>
<Link to={item.to}>{item.label}</Link>
</DropdownMenuItem>
))}
)
)}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/klarpreis">Klarpreis</Link>
@@ -120,19 +236,7 @@ function SiteHeader() {
<span className="sr-only">GebOS Startseite</span>
</Link>
<nav className="hidden items-center gap-1 lg:flex">
{NAV_LINKS.slice(0, 2).map((l) => (
<NavLink key={l.to} to={l.to} className={({ isActive }) => navItemClass(isActive)}>
{l.label}
</NavLink>
))}
<ForWhomMenu />
{NAV_LINKS.slice(2).map((l) => (
<NavLink key={l.to} to={l.to} className={({ isActive }) => navItemClass(isActive)}>
{l.label}
</NavLink>
))}
</nav>
<DesktopNav />
<div className="flex items-center gap-3">
<Button asChild size="sm" className="hidden sm:inline-flex">
@@ -12,7 +12,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-5 shrink-0 cursor-pointer rounded-md border border-input bg-card shadow-pill transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
"peer flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-[5px] border-2 border-navy/25 bg-card shadow-pill transition-colors outline-none hover:border-navy/45 focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:hover:border-primary",
className
)}
{...props}
@@ -21,7 +21,7 @@ function Checkbox({
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
<CheckIcon className="size-3.5 stroke-[3]" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
@@ -0,0 +1,48 @@
import * as React from "react"
/** Long enough to read as a count-up, short enough to keep typing responsive. */
const DURATION = 380
/** Cubic ease-out: quick start, gentle settle. */
const ease = (t: number) => 1 - (1 - t) ** 3
function prefersReducedMotion() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
}
/**
* Tweens towards `value` so a changing amount counts up or down instead of
* snapping. A change mid-flight continues from the frame on screen, and readers
* who ask for reduced motion get the plain value.
*/
export function useAnimatedNumber(value: number, duration = DURATION) {
const [display, setDisplay] = React.useState(value)
/** last value painted the starting point for an interrupting change */
const shown = React.useRef(value)
React.useEffect(() => {
if (value === shown.current) return
if (duration <= 0 || prefersReducedMotion()) {
shown.current = value
setDisplay(value)
return
}
const from = shown.current
const start = performance.now()
let frame = 0
const step = (now: number) => {
const t = Math.min(1, (now - start) / duration)
shown.current = t === 1 ? value : from + (value - from) * ease(t)
setDisplay(shown.current)
if (t < 1) frame = requestAnimationFrame(step)
}
frame = requestAnimationFrame(step)
return () => cancelAnimationFrame(frame)
}, [value, duration])
return display
}
+313 -269
View File
@@ -1,8 +1,6 @@
import * as React from "react"
import { Link } from "react-router-dom"
import { ArrowRight, Euro, Plus, X } from "lucide-react"
import { Ban, Check, CircleCheck, 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"
@@ -11,12 +9,18 @@ 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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
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"
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"
@@ -24,8 +28,9 @@ type Building = {
id: number
adresse: string
heat: Heat
wohnungen: number
zimmer: number
/** "" while the field is being edited the estimate counts it as 0. */
wohnungen: number | ""
zimmer: number | ""
}
const HEAT_OPTIONS: { value: Heat; label: string }[] = [
@@ -39,9 +44,15 @@ const INITIAL_BUILDINGS: 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
const RWM_ONE_TIME = 42
/** 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", {
@@ -50,22 +61,55 @@ function formatEuro(value: number) {
})
}
/**
* 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
}
/** 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: number
step: Step
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>
<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>
@@ -74,10 +118,152 @@ function StepCard({
)
}
/**
* 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({
assets,
title,
items,
included,
}: {
/** drawn side by side in the header, first one leading */
assets: 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">
<div className="flex shrink-0 items-center gap-1">
{assets.map((asset) => (
<AssetRender
key={asset.webp}
render={asset}
alt=""
className="flex size-20 items-center justify-center"
imgClassName={cn("w-20", !included && "opacity-40 grayscale")}
/>
))}
</div>
<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 [uvi, setUvi] = React.useState(true)
const [rwm, setRwm] = React.useState(true)
const [buildings, setBuildings] = React.useState<Building[]>(INITIAL_BUILDINGS)
const [install, setInstall] = React.useState(true)
const [email, setEmail] = React.useState("")
const nextId = React.useRef(INITIAL_BUILDINGS.length + 1)
@@ -96,18 +282,20 @@ export default function ConfiguratorPage() {
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)
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
/* the UVI package shows the heat distribution it has to measure */
const heatAsset = buildings.some((b) => b.heat === "fussbodenheizung")
? renders.floorHeating
: renders.radiator
return (
<Container>
@@ -122,55 +310,45 @@ export default function ConfiguratorPage() {
/>
<SectionHeading
title="Konfigurator"
lead="In 4 Schritten zur Preisindikation."
lead="In 3 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
<div className="flex flex-col gap-3">
<ServiceOption
checked={uvi}
onCheckedChange={(v) => setUvi(v === true)}
className="mt-0.5"
onCheckedChange={setUvi}
title="UVI"
description="Unterjährige Verbrauchsinformation inklusive Messtechnik"
/>
<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
<ServiceOption
checked={rwm}
onCheckedChange={(v) => setRwm(v === true)}
className="mt-0.5"
onCheckedChange={setRwm}
title="Rauchwarnmelder"
description="Rauchwarnmelder inklusive digitaler Dienstleistungen"
/>
<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>
<Separator className="my-6" />
<p className="text-sm font-bold text-navy">Installation</p>
<Tabs
className="mt-3"
value={install ? "mit" : "ohne"}
onValueChange={(v) => setInstall(v === "mit")}
>
<TabsList>
<TabsTrigger value="mit">Mit Installation</TabsTrigger>
<TabsTrigger value="ohne">Ohne Installation</TabsTrigger>
</TabsList>
</Tabs>
<p className="mt-3 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>
</StepCard>
<StepCard step={2} title="Gebäude angeben">
@@ -243,10 +421,12 @@ export default function ConfiguratorPage() {
value={b.wohnungen}
onChange={(e) =>
updateBuilding(b.id, {
wohnungen: Math.max(
0,
Math.round(Number(e.target.value) || 0)
),
wohnungen: parseCount(e.target.value),
})
}
onBlur={() =>
updateBuilding(b.id, {
wohnungen: settle(b.wohnungen, 1),
})
}
/>
@@ -260,7 +440,12 @@ export default function ConfiguratorPage() {
value={b.zimmer}
onChange={(e) =>
updateBuilding(b.id, {
zimmer: Math.max(0, Number(e.target.value) || 0),
zimmer: parseAmount(e.target.value),
})
}
onBlur={() =>
updateBuilding(b.id, {
zimmer: settle(b.zimmer, 1),
})
}
/>
@@ -274,46 +459,12 @@ export default function ConfiguratorPage() {
className="self-start"
onClick={addBuilding}
>
<Plus /> Gebäude hinzufügen
<Plus /> weiteres 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">
<StepCard step={3} title="Konfiguration speichern">
<form
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-3"
@@ -332,81 +483,69 @@ export default function ConfiguratorPage() {
Konfiguration speichern &amp; 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.
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 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 */}
{/* Right column: sticky price indication */}
<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} 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
<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>
<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>
<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 ? (
<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>
<>
{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>
<div className="mt-5 flex items-start gap-4">
<DonutIllustration className="w-16 shrink-0" />
<p className="text-xs leading-relaxed text-muted-foreground">
) : (
<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.
@@ -420,24 +559,13 @@ export default function ConfiguratorPage() {
{/* 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:"
title="Was ist in dem Preis enthalten?"
/>
<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"
<PackageCard
assets={[renders.gateway, heatAsset]}
title="UVI"
included={uvi}
items={[
"geschätzte benötigte Messtechnik",
"GebOS Plattform",
@@ -446,105 +574,21 @@ export default function ConfiguratorPage() {
"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"
<PackageCard
assets={[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",
]}
/>
</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>
)
}