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 * 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 funktioniert’s" },
|
{ 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: "/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
|
||||||
|
}
|
||||||
@@ -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 & fortsetzen
|
Konfiguration speichern & 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||