save
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
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 funktioniert’s" },
|
||||
/* "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
|
||||
}
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
|
||||