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 * as React from "react"
import { CircleCheck } from "lucide-react" import { CircleCheck, CircleMinus } from "lucide-react"
import { cn } from "@/lib/utils" 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({ function ChecklistItem({
children, children,
className, className,
tone = "included",
}: { }: {
children: React.ReactNode children: React.ReactNode
className?: string className?: string
tone?: ChecklistTone
}) { }) {
const excluded = tone === "excluded"
const Icon = excluded ? CircleMinus : CircleCheck
return ( return (
<li className={cn("flex items-start gap-2.5 text-sm", className)}> <li
<CircleCheck className="mt-0.5 size-4 shrink-0 text-primary" /> 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> <span>{children}</span>
</li> </li>
) )
@@ -21,14 +39,18 @@ function ChecklistItem({
function Checklist({ function Checklist({
items, items,
className, className,
tone,
}: { }: {
items: React.ReactNode[] items: React.ReactNode[]
className?: string className?: string
tone?: ChecklistTone
}) { }) {
return ( return (
<ul className={cn("flex flex-col gap-2.5", className)}> <ul className={cn("flex flex-col gap-2.5", className)}>
{items.map((item, i) => ( {items.map((item, i) => (
<ChecklistItem key={i}>{item}</ChecklistItem> <ChecklistItem key={i} tone={tone}>
{item}
</ChecklistItem>
))} ))}
</ul> </ul>
) )
@@ -22,10 +22,18 @@ import cubesRoundWebp from "@/assets/gebos/cubes-round.webp"
import drillToolAvif from "@/assets/gebos/drill-tool.avif" import drillToolAvif from "@/assets/gebos/drill-tool.avif"
import drillToolWebp from "@/assets/gebos/drill-tool.webp" import drillToolWebp from "@/assets/gebos/drill-tool.webp"
import euroOrb from "@/assets/gebos/euro-orb.svg" 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 fullHeroSectionAvif from "@/assets/gebos/full-hero-section.avif"
import fullHeroSectionWebp from "@/assets/gebos/full-hero-section.webp" import fullHeroSectionWebp from "@/assets/gebos/full-hero-section.webp"
import gatewayAvif from "@/assets/gebos/gateway.avif" import gatewayAvif from "@/assets/gebos/gateway.avif"
import gatewayWebp from "@/assets/gebos/gateway.webp" 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 glassCubes from "@/assets/gebos/glass-cubes.svg"
import glassCubesStackAvif from "@/assets/gebos/glass-cubes-stack.avif" import glassCubesStackAvif from "@/assets/gebos/glass-cubes-stack.avif"
import glassCubesStackWebp from "@/assets/gebos/glass-cubes-stack.webp" 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 laptopPortfolioAvif from "@/assets/gebos/laptop-portfolio.avif"
import laptopPortfolioWebp from "@/assets/gebos/laptop-portfolio.webp" import laptopPortfolioWebp from "@/assets/gebos/laptop-portfolio.webp"
import packageBox from "@/assets/gebos/package-box.svg" 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 refreshLoopCloudAvif from "@/assets/gebos/refresh-loop-cloud.avif"
import refreshLoopCloudWebp from "@/assets/gebos/refresh-loop-cloud.webp" import refreshLoopCloudWebp from "@/assets/gebos/refresh-loop-cloud.webp"
import refreshLoopAvif from "@/assets/gebos/refresh-loop.avif" import refreshLoopAvif from "@/assets/gebos/refresh-loop.avif"
@@ -201,6 +211,27 @@ const renders = {
alt: "", alt: "",
matte: "alpha", 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: { cubesRound: {
avif: cubesRoundAvif, avif: cubesRoundAvif,
webp: cubesRoundWebp, webp: cubesRoundWebp,
@@ -219,6 +250,18 @@ const renders = {
alt: "", alt: "",
matte: "alpha", matte: "alpha",
}, },
radiator: {
avif: radiatorAvif,
webp: radiatorWebp,
alt: "Heizkörper",
matte: "alpha",
},
floorHeating: {
avif: floorHeatingAvif,
webp: floorHeatingWebp,
alt: "Fußbodenheizungsrohre",
matte: "alpha",
},
workerTablet: { workerTablet: {
avif: workerTabletAvif, avif: workerTabletAvif,
webp: workerTabletWebp, webp: workerTabletWebp,
@@ -1,5 +1,6 @@
import * as React from "react"
import { ChevronDown, Menu } from "lucide-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 { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -21,44 +22,120 @@ const AUDIENCES = [
{ to: "/fuer-wen/installateure", label: "Installateure & Heizungsbauer" }, { 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: "/loesungen", label: "Lösungen" },
{ to: "/so-funktionierts", label: "So funktionierts" }, { 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: "/konfigurator", label: "Konfigurator" },
{ to: "/unternehmen", label: "Unternehmen" }, { to: "/unternehmen", label: "Unternehmen" },
{ to: "/kontakt", label: "Kontakt" }, { 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) => const navItemClass = (active: boolean) =>
cn( cn(
"rounded-full px-3 py-1.5 text-sm font-medium transition-colors", "relative z-10 rounded-full px-3 py-1.5 text-sm font-medium transition-colors duration-300",
active active ? "text-brand-800" : "text-foreground/70 hover:text-navy"
? "bg-accent font-semibold text-brand-800 ring-1 ring-brand-200"
: "text-foreground/70 hover:text-navy"
) )
function ForWhomMenu() { /**
const { pathname } = useLocation() * Slide + width morph. The ease overshoots just enough to read as a spring
const active = pathname.startsWith("/fuer-wen") * 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 ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
data-nav={item.to}
className={cn( className={cn(
navItemClass(active), navItemClass(active),
"inline-flex cursor-pointer items-center gap-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40" "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" /> <ChevronDown className="size-3.5 opacity-60" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to="/fuer-wen">Übersicht</Link> <Link to={item.to}>Übersicht</Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{AUDIENCES.map((a) => ( {item.menu?.map((a) => (
<DropdownMenuItem key={a.to} asChild> <DropdownMenuItem key={a.to} asChild>
<Link to={a.to}>{a.label}</Link> <Link to={a.to}>{a.label}</Link>
</DropdownMenuItem> </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() { function MobileMenu() {
return ( return (
<DropdownMenu> <DropdownMenu>
@@ -78,27 +194,27 @@ function MobileMenu() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64"> <DropdownMenuContent align="end" className="w-64">
{NAV_LINKS.slice(0, 2).map((l) => ( {NAV_ITEMS.map((item) =>
<DropdownMenuItem key={l.to} asChild> item.menu ? (
<Link to={l.to}>{l.label}</Link> <React.Fragment key={item.to}>
</DropdownMenuItem> <DropdownMenuSeparator />
))} <DropdownMenuLabel>{item.label}</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuItem asChild>
<DropdownMenuLabel>Für wen?</DropdownMenuLabel> <Link to={item.to}>Übersicht</Link>
<DropdownMenuItem asChild> </DropdownMenuItem>
<Link to="/fuer-wen">Übersicht</Link> {item.menu.map((a) => (
</DropdownMenuItem> <DropdownMenuItem key={a.to} asChild>
{AUDIENCES.map((a) => ( <Link to={a.to}>{a.label}</Link>
<DropdownMenuItem key={a.to} asChild> </DropdownMenuItem>
<Link to={a.to}>{a.label}</Link> ))}
</DropdownMenuItem> <DropdownMenuSeparator />
))} </React.Fragment>
<DropdownMenuSeparator /> ) : (
{NAV_LINKS.slice(2).map((l) => ( <DropdownMenuItem key={item.to} asChild>
<DropdownMenuItem key={l.to} asChild> <Link to={item.to}>{item.label}</Link>
<Link to={l.to}>{l.label}</Link> </DropdownMenuItem>
</DropdownMenuItem> )
))} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to="/klarpreis">Klarpreis</Link> <Link to="/klarpreis">Klarpreis</Link>
@@ -120,19 +236,7 @@ function SiteHeader() {
<span className="sr-only">GebOS Startseite</span> <span className="sr-only">GebOS Startseite</span>
</Link> </Link>
<nav className="hidden items-center gap-1 lg:flex"> <DesktopNav />
{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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button asChild size="sm" className="hidden sm:inline-flex"> <Button asChild size="sm" className="hidden sm:inline-flex">
@@ -12,7 +12,7 @@ function Checkbox({
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
className={cn( 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 className
)} )}
{...props} {...props}
@@ -21,7 +21,7 @@ function Checkbox({
data-slot="checkbox-indicator" data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none" 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.Indicator>
</CheckboxPrimitive.Root> </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
}
+333 -289
View File
@@ -1,8 +1,6 @@
import * as React from "react" import * as React from "react"
import { Link } from "react-router-dom" import { Ban, Check, CircleCheck, Euro, Plus, X } from "lucide-react"
import { ArrowRight, Euro, Plus, X } from "lucide-react"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card" import { Card } from "@/components/ui/card"
@@ -11,12 +9,18 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Checklist } from "@/components/gebos/checklist" import { Checklist } from "@/components/gebos/checklist"
import { DonutIllustration } from "@/components/gebos/illustrations"
import { IconBadge } from "@/components/gebos/icon-tile" import { IconBadge } from "@/components/gebos/icon-tile"
import { Container, Section, SectionHeading } from "@/components/gebos/section" import { Container, Section, SectionHeading } from "@/components/gebos/section"
import { PageBreadcrumb } from "@/components/gebos/page-breadcrumb" 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" type Heat = "heizkoerper" | "fussbodenheizung" | "gemischt" | "unbekannt"
@@ -24,8 +28,9 @@ type Building = {
id: number id: number
adresse: string adresse: string
heat: Heat heat: Heat
wohnungen: number /** "" while the field is being edited the estimate counts it as 0. */
zimmer: number wohnungen: number | ""
zimmer: number | ""
} }
const HEAT_OPTIONS: { value: Heat; label: string }[] = [ const HEAT_OPTIONS: { value: Heat; label: string }[] = [
@@ -39,9 +44,15 @@ const INITIAL_BUILDINGS: Building[] = [
{ id: 1, adresse: "", heat: "heizkoerper", wohnungen: 12, zimmer: 3 }, { 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 UVI_MONTHLY = 10.5
const INSPECTION_MONTHLY = 2.2 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) { function formatEuro(value: number) {
return value.toLocaleString("de-DE", { 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({ function StepCard({
step, step,
title, title,
children, children,
}: { }: {
step: number step: Step
title: string title: string
children: React.ReactNode children: React.ReactNode
}) { }) {
return ( return (
<Card className="gap-0 p-6 sm:p-7"> <Card className="gap-0 p-6 sm:p-7">
<div className="flex items-center gap-3"> <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"> <AssetRender
{step} render={STEP_BADGES[step]}
</span> 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"> <h3 className="text-lg font-extrabold tracking-tight text-navy">
<span className="sr-only">Schritt {step}: </span>
{title} {title}
</h3> </h3>
</div> </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() { export default function ConfiguratorPage() {
const [uvi, setUvi] = React.useState(true) const [uvi, setUvi] = React.useState(true)
const [rwm, setRwm] = React.useState(true) const [rwm, setRwm] = React.useState(true)
const [buildings, setBuildings] = React.useState<Building[]>(INITIAL_BUILDINGS) const [buildings, setBuildings] = React.useState<Building[]>(INITIAL_BUILDINGS)
const [install, setInstall] = React.useState(true)
const [email, setEmail] = React.useState("") const [email, setEmail] = React.useState("")
const nextId = React.useRef(INITIAL_BUILDINGS.length + 1) 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)) 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 totalUnits = buildings.reduce((sum, b) => sum + (b.wohnungen || 0), 0)
const sensors = buildings.reduce( const installSurcharge = install ? INSTALL_MONTHLY_SURCHARGE : 0
(sum, b) => sum + Math.round((b.wohnungen || 0) * (b.zimmer || 0) * 0.92), const uviMonthly = UVI_MONTHLY + installSurcharge
0 const inspectionMonthly = INSPECTION_MONTHLY + installSurcharge
) const installNote = install
const smokeDetectors = buildings.reduce( ? `inklusive ${formatEuro(INSTALL_MONTHLY_SURCHARGE)}\u00A0€ Installationsanteil`
(sum, b) => sum + Math.round((b.wohnungen || 0) * ((b.zimmer || 0) + 0.7)), : undefined
0 const rwmRatePerUnit = install ? RWM_ONE_TIME_INSTALLED : RWM_ONE_TIME_SELF
) /* RWM hardware is billed once per Wohnung the estimate is the sum. */
const monthly = const rwmOneTimeTotal = rwm ? rwmRatePerUnit * totalUnits : 0
(uvi ? UVI_MONTHLY : 0) + (uvi || rwm ? INSPECTION_MONTHLY : 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 ( return (
<Container> <Container>
@@ -122,55 +310,45 @@ export default function ConfiguratorPage() {
/> />
<SectionHeading <SectionHeading
title="Konfigurator" 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>
<div className="mt-10 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_380px]"> <div className="mt-10 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_380px]">
{/* Left column: steps */} {/* Left column: steps */}
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<StepCard step={1} title="Leistungen wählen"> <StepCard step={1} title="Leistungen wählen">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-3">
<label className="flex cursor-pointer items-start gap-3"> <ServiceOption
<Checkbox checked={uvi}
checked={uvi} onCheckedChange={setUvi}
onCheckedChange={(v) => setUvi(v === true)} title="UVI"
className="mt-0.5" description="Unterjährige Verbrauchsinformation inklusive Messtechnik"
/> />
<span> <ServiceOption
<span className="block text-sm font-bold text-navy">UVI</span> checked={rwm}
<span className="block text-sm text-muted-foreground"> onCheckedChange={setRwm}
Unterjährige Verbrauchsinformation inklusive Messtechnik title="Rauchwarnmelder"
</span> description="Rauchwarnmelder inklusive digitaler Dienstleistungen"
</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> </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>
<StepCard step={2} title="Gebäude angeben"> <StepCard step={2} title="Gebäude angeben">
@@ -243,10 +421,12 @@ export default function ConfiguratorPage() {
value={b.wohnungen} value={b.wohnungen}
onChange={(e) => onChange={(e) =>
updateBuilding(b.id, { updateBuilding(b.id, {
wohnungen: Math.max( wohnungen: parseCount(e.target.value),
0, })
Math.round(Number(e.target.value) || 0) }
), onBlur={() =>
updateBuilding(b.id, {
wohnungen: settle(b.wohnungen, 1),
}) })
} }
/> />
@@ -260,7 +440,12 @@ export default function ConfiguratorPage() {
value={b.zimmer} value={b.zimmer}
onChange={(e) => onChange={(e) =>
updateBuilding(b.id, { 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" className="self-start"
onClick={addBuilding} onClick={addBuilding}
> >
<Plus /> Gebäude hinzufügen <Plus /> weiteres Gebäude hinzufügen
</Button> </Button>
</div> </div>
</StepCard> </StepCard>
<StepCard step={3} title="Preis sehen"> <StepCard step={3} title="Konfiguration speichern">
<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 <form
onSubmit={(e) => e.preventDefault()} onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-3" className="flex flex-col gap-3"
@@ -332,81 +483,69 @@ export default function ConfiguratorPage() {
Konfiguration speichern &amp; fortsetzen Konfiguration speichern &amp; fortsetzen
</Button> </Button>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Sie erhalten einen persönlichen Link (gebos.de/konfiguration/) Sie erhalten einen persönlichen Link
zu Ihrer Konfiguration sofort weitermachen, später zu Ihrer Konfiguration. Damit kannst du
zurückkehren oder den Link intern weitergeben. sofort weitermachen, später zurückkehren
oder den Link intern weitergeben.
</p> </p>
</form> </form>
</StepCard> </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> </div>
{/* Right column: sticky summary */} {/* Right column: sticky price indication */}
<div className="lg:sticky lg:top-24"> <div className="lg:sticky lg:top-24">
<Card className="gap-0 p-6 shadow-card-lg sm:p-7"> <Card className="gap-0 overflow-hidden p-0 shadow-card-lg">
<h3 className="text-base font-extrabold tracking-tight text-navy"> <div className="flex items-center justify-between gap-3 bg-brand-gradient p-6 text-white sm:p-7">
Ihre vorläufige Konfiguration <p className="text-xs font-bold tracking-[0.14em] uppercase">
</h3> Geschätzter Preis
<div className="mt-4 flex flex-col gap-1"> </p>
<div className="text-2xl font-extrabold tracking-tight text-navy"> <IconBadge variant="glass" size="lg">
{totalBuildings} Gebäude <Euro />
</div> </IconBadge>
<div className="text-2xl font-extrabold tracking-tight text-navy">
{totalUnits} {totalUnits === 1 ? "Wohnung" : "Wohnungen"}
</div>
</div> </div>
{uvi || rwm ? ( <div className="p-6 sm:p-7">
<> {uvi || rwm ? (
<Separator className="my-4" /> <div className="flex flex-col gap-5">
<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 ? ( {uvi ? (
<li className="font-semibold"> <PriceRow
ca. {sensors} Heizsensoren label="UVI-Komplettbetrieb"
</li> amount={uviMonthly}
unit="je Wohnung / Monat"
note={installNote}
allocatable
/>
) : null} ) : null}
{rwm ? ( {rwm ? (
<li className="font-semibold"> <>
ca. {smokeDetectors} Rauchwarnmelder {uvi ? <Separator /> : null}
</li> <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} ) : 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> </div>
<IconBadge variant="glass" size="lg"> ) : (
<Euro /> <p className="text-sm text-muted-foreground">
</IconBadge> Wählen Sie in Schritt 1 mindestens eine Leistung, um eine
</div> Preisindikation zu erhalten.
{rwm ? ( </p>
<div className="mt-3 border-t border-white/20 pt-2 text-xs text-white/80"> )}
+ {formatEuro(RWM_ONE_TIME)}{"\u00A0"} je Wohnung einmalig <p className="mt-6 text-xs leading-relaxed text-muted-foreground">
(RWM-Hardware) {install
</div> ? "Preise inklusive Montage durch GebOS. "
) : null} : "Preise ohne Montage Installation durch Sie oder Ihren Fachbetrieb. "}
</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. Die erste Berechnung ist eine unverbindliche Preisindikation.
Der genaue Preis ergibt sich aus der vollständigen Der genaue Preis ergibt sich aus der vollständigen
Objektkonfiguration. Objektkonfiguration.
@@ -420,131 +559,36 @@ export default function ConfiguratorPage() {
{/* Was ist in der Schätzung enthalten? */} {/* Was ist in der Schätzung enthalten? */}
<Section className="pt-0 sm:pt-0 lg:pt-0"> <Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading <SectionHeading
title="Was ist in der Schätzung enthalten?" title="Was ist in dem Preis enthalten?"
lead="Je nach gewähltem Paket beispielsweise:"
/> />
<div className="mt-8 grid gap-6 lg:grid-cols-2"> <div className="mt-8 grid gap-6 lg:grid-cols-2">
<Card <PackageCard
className={ assets={[renders.gateway, heatAsset]}
"gap-0 p-6 transition-opacity sm:p-8" + title="UVI"
(uvi ? "" : " opacity-50") included={uvi}
} items={[
> "geschätzte benötigte Messtechnik",
<div className="flex items-center gap-4"> "GebOS Plattform",
<img src={icons.uvi} alt="" className="size-14" /> "Datenerfassung",
<h3 className="text-xl font-extrabold tracking-tight text-navy"> "Unterjährige Verbrauchsinformation",
UVI "laufender digitaler Betrieb",
</h3> ]}
</div> />
<Checklist <PackageCard
className="mt-5" assets={[renders.smokeAlarm]}
items={[ title="Rauchwarnmelder"
"geschätzte benötigte Messtechnik", included={rwm}
"GebOS Plattform", items={[
"Datenerfassung", "geschätzte Anzahl Rauchwarnmelder",
"Unterjährige Verbrauchsinformation", "Verwaltung der Geräte",
"laufender digitaler Betrieb", "gebuchte digitale Dienstleistungen",
]} install
/> ? "Montage und Eigentumsübertragung"
</Card> : "Eigentumsübertragung Montage durch Sie",
<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> </div>
</Section> </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> </Container>
) )
} }