diff --git a/component-library/src/assets/gebos/floor-heating.avif b/component-library/src/assets/gebos/floor-heating.avif new file mode 100644 index 0000000..9cc0b15 Binary files /dev/null and b/component-library/src/assets/gebos/floor-heating.avif differ diff --git a/component-library/src/assets/gebos/floor-heating.webp b/component-library/src/assets/gebos/floor-heating.webp new file mode 100644 index 0000000..b956ec7 Binary files /dev/null and b/component-library/src/assets/gebos/floor-heating.webp differ diff --git a/component-library/src/assets/gebos/glass-badge-1.avif b/component-library/src/assets/gebos/glass-badge-1.avif new file mode 100644 index 0000000..cdda6fe Binary files /dev/null and b/component-library/src/assets/gebos/glass-badge-1.avif differ diff --git a/component-library/src/assets/gebos/glass-badge-1.webp b/component-library/src/assets/gebos/glass-badge-1.webp new file mode 100644 index 0000000..a1b6e30 Binary files /dev/null and b/component-library/src/assets/gebos/glass-badge-1.webp differ diff --git a/component-library/src/assets/gebos/glass-badge-2.avif b/component-library/src/assets/gebos/glass-badge-2.avif new file mode 100644 index 0000000..812482a Binary files /dev/null and b/component-library/src/assets/gebos/glass-badge-2.avif differ diff --git a/component-library/src/assets/gebos/glass-badge-2.webp b/component-library/src/assets/gebos/glass-badge-2.webp new file mode 100644 index 0000000..76ebaa4 Binary files /dev/null and b/component-library/src/assets/gebos/glass-badge-2.webp differ diff --git a/component-library/src/assets/gebos/glass-badge-3.avif b/component-library/src/assets/gebos/glass-badge-3.avif new file mode 100644 index 0000000..41963e9 Binary files /dev/null and b/component-library/src/assets/gebos/glass-badge-3.avif differ diff --git a/component-library/src/assets/gebos/glass-badge-3.webp b/component-library/src/assets/gebos/glass-badge-3.webp new file mode 100644 index 0000000..49a592c Binary files /dev/null and b/component-library/src/assets/gebos/glass-badge-3.webp differ diff --git a/component-library/src/assets/gebos/radiator.avif b/component-library/src/assets/gebos/radiator.avif new file mode 100644 index 0000000..d3d3c83 Binary files /dev/null and b/component-library/src/assets/gebos/radiator.avif differ diff --git a/component-library/src/assets/gebos/radiator.webp b/component-library/src/assets/gebos/radiator.webp new file mode 100644 index 0000000..54e3642 Binary files /dev/null and b/component-library/src/assets/gebos/radiator.webp differ diff --git a/component-library/src/components/gebos/checklist.tsx b/component-library/src/components/gebos/checklist.tsx index 5ed1bc1..acab8f3 100644 --- a/component-library/src/components/gebos/checklist.tsx +++ b/component-library/src/components/gebos/checklist.tsx @@ -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 ( -
  • - +
  • + {children}
  • ) @@ -21,14 +39,18 @@ function ChecklistItem({ function Checklist({ items, className, + tone, }: { items: React.ReactNode[] className?: string + tone?: ChecklistTone }) { return ( ) diff --git a/component-library/src/components/gebos/site-assets.tsx b/component-library/src/components/gebos/site-assets.tsx index b76561f..94a7aaf 100644 --- a/component-library/src/components/gebos/site-assets.tsx +++ b/component-library/src/components/gebos/site-assets.tsx @@ -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, diff --git a/component-library/src/components/gebos/site-header.tsx b/component-library/src/components/gebos/site-header.tsx index c127f43..98be7d0 100644 --- a/component-library/src/components/gebos/site-header.tsx +++ b/component-library/src/components/gebos/site-header.tsx @@ -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(null) + const [pill, setPill] = React.useState(null) + + React.useLayoutEffect(() => { + const nav = navRef.current + if (!nav) return + + const measure = () => { + const item = activeKey + ? nav.querySelector(`[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 ( - Für wen? + {item.label} - Übersicht + Übersicht - {AUDIENCES.map((a) => ( + {item.menu?.map((a) => ( {a.label} @@ -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 ( + + ) +} + function MobileMenu() { return ( @@ -78,27 +194,27 @@ function MobileMenu() { - {NAV_LINKS.slice(0, 2).map((l) => ( - - {l.label} - - ))} - - Für wen? - - Übersicht - - {AUDIENCES.map((a) => ( - - {a.label} - - ))} - - {NAV_LINKS.slice(2).map((l) => ( - - {l.label} - - ))} + {NAV_ITEMS.map((item) => + item.menu ? ( + + + {item.label} + + Übersicht + + {item.menu.map((a) => ( + + {a.label} + + ))} + + + ) : ( + + {item.label} + + ) + )} Klarpreis @@ -120,19 +236,7 @@ function SiteHeader() { GebOS Startseite - +
    {/* Left column: steps */}
    -
    - - +
    + +
    + +

    Installation

    + setInstall(v === "mit")} + > + + Mit Installation + Ohne Installation + + +

    + {install + ? "GebOS organisiert die Montage – Geräte- und Monatspreise enthalten die Installation." + : "Sie selbst, Ihr Hausmeister oder Ihr Fachbetrieb montiert die Geräte."} +

    @@ -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} > - Gebäude hinzufügen + weiteres Gebäude hinzufügen
    - -

    - Sofortige erste Preisindikation. -

    - - - - Wie wurde dieser Preis berechnet? - - -

    - Für diese Schätzung gehen wir aufgrund Ihrer Angaben von - ungefähr{" "} - - {sensors} Heizsensoren und {smokeDetectors}{" "} - Rauchwarnmeldern - {" "} - aus. -

    -

    - Bei Gebäuden mit durchschnittlich drei Zimmern verwenden - wir eine typische Geräteausstattung als - Berechnungsgrundlage. -

    -

    - Die endgültige Anzahl wird bei der detaillierten - Konfiguration der Wohnungen bestimmt und ersetzt diese - Schätzwerte. -

    -
    -
    -
    -
    - - +
    e.preventDefault()} className="flex flex-col gap-3" @@ -332,81 +483,69 @@ export default function ConfiguratorPage() { Konfiguration speichern & fortsetzen

    - 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.

    - -
    - - -
    - {/* Right column: sticky summary */} + {/* Right column: sticky price indication */}
    - -

    - Ihre vorläufige Konfiguration -

    -
    -
    - {totalBuildings} Gebäude -
    -
    - {totalUnits} {totalUnits === 1 ? "Wohnung" : "Wohnungen"} -
    + +
    +

    + Geschätzter Preis +

    + + +
    - {uvi || rwm ? ( - <> - -

    - Voraussichtlich benötigt -

    -
      +
      + {uvi || rwm ? ( +
      {uvi ? ( -
    • - ca. {sensors} Heizsensoren -
    • + ) : null} {rwm ? ( -
    • - ca. {smokeDetectors} Rauchwarnmelder -
    • + <> + {uvi ? : null} + + + + ) : null} -
    - - ) : null} - -
    -
    -
    -
    - ca. {formatEuro(monthly)}{"\u00A0"}€ -
    -
    - je Wohnung / Monat -
    - - - -
    - {rwm ? ( -
    - + {formatEuro(RWM_ONE_TIME)}{"\u00A0"}€ je Wohnung einmalig - (RWM-Hardware) -
    - ) : null} -
    - -
    - -

    + ) : ( +

    + Wählen Sie in Schritt 1 mindestens eine Leistung, um eine + Preisindikation zu erhalten. +

    + )} +

    + {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,131 +559,36 @@ export default function ConfiguratorPage() { {/* Was ist in der Schätzung enthalten? */}

    - -
    - -

    - UVI -

    -
    - -
    - -
    - -

    - Rauchwarnmelder -

    -
    - -
    + +
    - - {/* Stufe 2 – So geht es weiter */} -
    - -
    -
    - - Geschätzt: 72 Heizsensoren - -
    -
    - - Geschätzt: 96 Rauchwarnmelder - -
    -
    - - -

    - Finales Ergebnis -

    -

    - Ihre GebOS-Konfiguration -

    -

    - 3 Gebäude · 26 Wohnungen -

    - -
    -
    -
    UVI
    -
    - 68 Heizsensoren / Messgeräte, weitere benötigte Messtechnik -
    -
    -
    -
    Rauchwarnmelder
    -
    103 Rauchwarnmelder
    -
    -
    -
    Infrastruktur
    -
    - technisch ausgelegte Gateway- und Kommunikationsinfrastruktur -
    -
    -
    - -

    - Ihr Preis ist dann keine grobe Hochrechnung auf Basis von - Durchschnittswerten mehr, sondern ein Preis auf Grundlage der - tatsächlichen Objektkonfiguration. -

    -
    - -
    -
    - -

    - Ihre Daten werden im gesamten Prozess weiterverwendet: - Preisschätzung → Konfiguration → Bestellung → Gerätevorbereitung → - Installation → laufender Betrieb. -

    -
    ) }