This commit is contained in:
Lars Nolden
2026-08-13 15:46:04 +02:00
parent c734b85a8d
commit e881799ff1
179 changed files with 10290 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
import { useEffect } from "react"
import { BrowserRouter, Route, Routes, useLocation } from "react-router-dom"
import { SiteFooter } from "@/components/gebos/site-footer"
import { SiteHeader } from "@/components/gebos/site-header"
import HauseigentuemerPage from "@/pages/audiences/hauseigentuemer"
import HausverwaltungenPage from "@/pages/audiences/hausverwaltungen"
import InstallateurePage from "@/pages/audiences/installateure"
import MessdienstleisterPage from "@/pages/audiences/messdienstleister"
import ConfiguratorPage from "@/pages/configurator"
import ForWhomPage from "@/pages/for-whom"
import HomePage from "@/pages/home"
import HowItWorksPage from "@/pages/how-it-works"
import KlarpreisPage from "@/pages/klarpreis"
import { SimplePage } from "@/pages/placeholder"
import SolutionsPage from "@/pages/solutions"
function ScrollToTop() {
const { pathname } = useLocation()
useEffect(() => {
window.scrollTo(0, 0)
}, [pathname])
return null
}
export default function App() {
return (
<BrowserRouter>
<ScrollToTop />
<div className="flex min-h-screen flex-col">
<SiteHeader />
<main className="flex-1">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/so-funktionierts" element={<HowItWorksPage />} />
<Route path="/loesungen" element={<SolutionsPage />} />
<Route path="/fuer-wen" element={<ForWhomPage />} />
<Route
path="/fuer-wen/hauseigentuemer"
element={<HauseigentuemerPage />}
/>
<Route
path="/fuer-wen/hausverwaltungen"
element={<HausverwaltungenPage />}
/>
<Route
path="/fuer-wen/messdienstleister"
element={<MessdienstleisterPage />}
/>
<Route
path="/fuer-wen/installateure"
element={<InstallateurePage />}
/>
<Route path="/konfigurator" element={<ConfiguratorPage />} />
<Route path="/klarpreis" element={<KlarpreisPage />} />
<Route
path="/unternehmen"
element={
<SimplePage
breadcrumb={[
{ label: "Startseite", to: "/" },
{ label: "Unternehmen" },
]}
title="Unternehmen"
lead="GebOS verbindet Messtechnik, Datenerfassung und Software zu einem durchgängigen System für Wohngebäude."
/>
}
/>
<Route
path="/kontakt"
element={
<SimplePage
breadcrumb={[
{ label: "Startseite", to: "/" },
{ label: "Kontakt" },
]}
title="Kontakt"
lead="Sprechen Sie mit uns über Ihr Gebäude, Ihr Portfolio oder Ihre Plattform-Anforderungen."
/>
}
/>
<Route
path="*"
element={
<SimplePage
title="Seite nicht gefunden"
lead="Die angeforderte Seite existiert nicht."
/>
}
/>
</Routes>
</main>
<SiteFooter />
</div>
</BrowserRouter>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 520" role="img" aria-label="Teal approval check badge">
<defs><radialGradient id="o" cx="32%" cy="24%"><stop stop-color="#36e4d4"/><stop offset=".65" stop-color="#009b9a"/><stop offset="1" stop-color="#006175"/></radialGradient><filter id="sh" x="-40%" y="-40%" width="180%" height="190%"><feDropShadow dx="0" dy="22" stdDeviation="22" flood-color="#005a6c" flood-opacity=".24"/></filter></defs>
<ellipse cx="260" cy="423" rx="150" ry="30" fill="#007284" opacity=".15" filter="url(#sh)"/>
<circle cx="260" cy="238" r="162" fill="url(#o)" stroke="#9afff2" stroke-opacity=".55" stroke-width="5" filter="url(#sh)"/>
<circle cx="260" cy="238" r="126" fill="none" stroke="#fff" stroke-opacity=".16" stroke-width="4"/>
<path d="m172 238 56 58 122-132" fill="none" stroke="#fff" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 908 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 620" role="img" aria-label="Cloud platform and support headphones">
<defs><linearGradient id="c" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#fff"/><stop offset="1" stop-color="#d9e8eb"/></linearGradient><linearGradient id="t" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#37e1d2"/><stop offset="1" stop-color="#00798b"/></linearGradient><filter id="sh" x="-40%" y="-40%" width="190%" height="200%"><feDropShadow dx="0" dy="18" stdDeviation="18" flood-color="#075465" flood-opacity=".18"/></filter></defs>
<g filter="url(#sh)"><path d="M117 290c0-62 49-112 110-115 27-66 88-105 157-105 90 0 164 68 174 155 58 2 104 48 104 106 0 59-48 107-107 107H219c-57 0-102-46-102-103 0-16 3-31 10-45Z" fill="url(#c)" stroke="#e3eef0" stroke-width="5"/><path d="m356 331 66-66m-10 0h10v10" fill="none" stroke="url(#t)" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/></g>
<g transform="translate(451 304)" filter="url(#sh)" fill="none" stroke="url(#t)" stroke-width="28" stroke-linecap="round"><path d="M20 112v-25c0-79 59-137 132-137s132 58 132 137v25"/><path d="M20 108v76c0 25 20 45 45 45h24v-121H65c-25 0-45 20-45 45ZM284 108v76c0 25-20 45-45 45h-24v-121h24c25 0 45 20 45 45Z" fill="#e8fbf8" stroke-width="18"/><path d="M216 229c-8 32-33 45-73 45" stroke-width="14"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 560" role="img" aria-labelledby="title desc">
<title id="title">GebOS segmented consumption ring</title>
<desc id="desc">A substantial eight-part white, teal and translucent cyan ring in three-quarter perspective.</desc>
<defs>
<linearGradient id="whiteTop" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#ffffff"/><stop offset=".58" stop-color="#f1f5f6"/><stop offset="1" stop-color="#d9e2e5"/></linearGradient>
<linearGradient id="whiteSide" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#d8e1e4"/><stop offset="1" stop-color="#aebdc2"/></linearGradient>
<linearGradient id="aquaTop" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#78f3e6"/><stop offset=".52" stop-color="#23cfc4"/><stop offset="1" stop-color="#00a5a6"/></linearGradient>
<linearGradient id="aquaGlass" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#b9fff7" stop-opacity=".76"/><stop offset=".52" stop-color="#41dcd1" stop-opacity=".68"/><stop offset="1" stop-color="#059ca1" stop-opacity=".78"/></linearGradient>
<linearGradient id="tealTop" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#18c8c0"/><stop offset=".52" stop-color="#008f96"/><stop offset="1" stop-color="#006579"/></linearGradient>
<linearGradient id="deepTop" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#009198"/><stop offset=".5" stop-color="#006d7d"/><stop offset="1" stop-color="#00485e"/></linearGradient>
<linearGradient id="aquaSide" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#00a3a4"/><stop offset="1" stop-color="#006774"/></linearGradient>
<linearGradient id="tealSide" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#007681"/><stop offset="1" stop-color="#003d52"/></linearGradient>
<filter id="shadow" x="-35%" y="-110%" width="170%" height="320%"><feGaussianBlur stdDeviation="24"/></filter>
<filter id="lift" x="-25%" y="-60%" width="150%" height="230%"><feDropShadow dx="0" dy="16" stdDeviation="12" flood-color="#073f4f" flood-opacity=".2"/></filter>
</defs>
<ellipse cx="450" cy="430" rx="285" ry="55" fill="#0b4856" opacity=".16" filter="url(#shadow)"/>
<g transform="translate(450 250) scale(1 .58) translate(0 58) rotate(-18)" fill="none" stroke-width="132" stroke-linecap="butt">
<circle r="216" stroke="url(#whiteSide)" stroke-dasharray="168 1189" stroke-dashoffset="0"/>
<circle r="216" stroke="url(#whiteSide)" stroke-dasharray="168 1189" stroke-dashoffset="-170"/>
<circle r="216" stroke="url(#tealSide)" stroke-dasharray="168 1189" stroke-dashoffset="-340"/>
<circle r="216" stroke="url(#aquaSide)" stroke-dasharray="168 1189" stroke-dashoffset="-510"/>
<circle r="216" stroke="url(#aquaSide)" stroke-dasharray="168 1189" stroke-dashoffset="-680"/>
<circle r="216" stroke="url(#tealSide)" stroke-dasharray="168 1189" stroke-dashoffset="-850"/>
<circle r="216" stroke="url(#tealSide)" stroke-dasharray="168 1189" stroke-dashoffset="-1020"/>
<circle r="216" stroke="url(#whiteSide)" stroke-dasharray="168 1189" stroke-dashoffset="-1190"/>
</g>
<g transform="translate(450 250) scale(1 .58) rotate(-18)" fill="none" stroke-width="132" stroke-linecap="butt" filter="url(#lift)">
<circle r="216" stroke="url(#whiteTop)" stroke-dasharray="168 1189" stroke-dashoffset="0"/>
<circle r="216" stroke="url(#whiteTop)" stroke-dasharray="168 1189" stroke-dashoffset="-170"/>
<circle r="216" stroke="url(#deepTop)" stroke-dasharray="168 1189" stroke-dashoffset="-340"/>
<circle r="216" stroke="url(#aquaTop)" stroke-dasharray="168 1189" stroke-dashoffset="-510"/>
<circle r="216" stroke="url(#aquaGlass)" stroke-dasharray="168 1189" stroke-dashoffset="-680"/>
<circle r="216" stroke="url(#tealTop)" stroke-dasharray="168 1189" stroke-dashoffset="-850"/>
<circle r="216" stroke="url(#deepTop)" stroke-dasharray="168 1189" stroke-dashoffset="-1020"/>
<circle r="216" stroke="url(#whiteTop)" stroke-dasharray="168 1189" stroke-dashoffset="-1190"/>
</g>
<g transform="translate(450 250) scale(1 .58)" fill="none">
<ellipse rx="150" ry="150" stroke="#ffffff" stroke-opacity=".72" stroke-width="8"/>
<ellipse rx="283" ry="283" stroke="#dffffb" stroke-opacity=".34" stroke-width="6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 620 620" role="img" aria-label="Glass euro price orb">
<defs>
<radialGradient id="g" cx="30%" cy="20%"><stop stop-color="#dffffb" stop-opacity=".78"/><stop offset=".45" stop-color="#4cded4" stop-opacity=".46"/><stop offset="1" stop-color="#007487" stop-opacity=".7"/></radialGradient>
<linearGradient id="r" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#8ffff2"/><stop offset=".5" stop-color="#00b4ad"/><stop offset="1" stop-color="#006276"/></linearGradient>
<filter id="gl" x="-50%" y="-50%" width="200%" height="220%"><feGaussianBlur stdDeviation="18" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
<filter id="sh" x="-50%" y="-50%" width="200%" height="220%"><feDropShadow dx="0" dy="24" stdDeviation="20" flood-color="#005b6b" flood-opacity=".28"/></filter>
</defs>
<ellipse cx="310" cy="500" rx="215" ry="66" fill="none" stroke="url(#r)" stroke-width="18" opacity=".8" filter="url(#gl)"/>
<ellipse cx="310" cy="500" rx="145" ry="38" fill="#35e1d3" opacity=".26" filter="url(#gl)"/>
<circle cx="310" cy="283" r="190" fill="url(#g)" stroke="#b9fff8" stroke-opacity=".78" stroke-width="5" filter="url(#sh)"/>
<circle cx="270" cy="216" r="80" fill="#fff" opacity=".15"/>
<text x="310" y="360" text-anchor="middle" font-family="Arial,sans-serif" font-weight="700" font-size="220" fill="#fff"></text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 600" role="img" aria-label="Translucent teal data cubes">
<defs>
<linearGradient id="f" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#34e5d5" stop-opacity=".78"/><stop offset="1" stop-color="#008c99" stop-opacity=".62"/></linearGradient>
<linearGradient id="s" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#008b96" stop-opacity=".72"/><stop offset="1" stop-color="#005267" stop-opacity=".82"/></linearGradient>
<linearGradient id="t" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#b9fff7" stop-opacity=".82"/><stop offset="1" stop-color="#30ded1" stop-opacity=".6"/></linearGradient>
<filter id="sh" x="-40%" y="-40%" width="190%" height="200%"><feDropShadow dx="0" dy="18" stdDeviation="17" flood-color="#006b77" flood-opacity=".18"/></filter>
</defs>
<ellipse cx="360" cy="522" rx="270" ry="36" fill="#006678" opacity=".12" filter="url(#sh)"/>
<g filter="url(#sh)" stroke="#8afff2" stroke-opacity=".45" stroke-width="3">
<g transform="translate(75 235)"><path d="m0 60 90 26v104L0 164Z" fill="url(#f)"/><path d="m90 86 52-38v104l-52 38Z" fill="url(#s)"/><path d="M0 60 52 22l90 26-52 38Z" fill="url(#t)"/></g>
<g transform="translate(220 105)"><path d="m0 60 90 26v104L0 164Z" fill="url(#f)"/><path d="m90 86 52-38v104l-52 38Z" fill="url(#s)"/><path d="M0 60 52 22l90 26-52 38Z" fill="url(#t)"/></g>
<g transform="translate(220 315)"><path d="m0 60 90 26v104L0 164Z" fill="url(#f)"/><path d="m90 86 52-38v104l-52 38Z" fill="url(#s)"/><path d="M0 60 52 22l90 26-52 38Z" fill="url(#t)"/></g>
<g transform="translate(365 210)"><path d="m0 60 90 26v104L0 164Z" fill="url(#f)"/><path d="m90 86 52-38v104l-52 38Z" fill="url(#s)"/><path d="M0 60 52 22l90 26-52 38Z" fill="url(#t)"/></g>
<g transform="translate(510 315)"><path d="m0 60 90 26v104L0 164Z" fill="url(#f)"/><path d="m90 86 52-38v104l-52 38Z" fill="url(#s)"/><path d="M0 60 52 22l90 26-52 38Z" fill="url(#t)"/></g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><circle cx="48" cy="48" r="42" fill="#f2f8f9"/><path d="M30 23h28l10 10v38H30Z" fill="none" stroke="#008e96" stroke-width="4" stroke-linejoin="round"/><path d="M58 23v12h12M39 44h19M39 53h13" fill="none" stroke="#008e96" stroke-width="4" stroke-linecap="round"/><circle cx="61" cy="62" r="12" fill="#fff" stroke="#008e96" stroke-width="4"/><path d="m56 62 4 4 7-9" fill="none" stroke="#008e96" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 528 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><circle cx="48" cy="48" r="42" fill="#f2f8f9"/><rect x="22" y="26" width="52" height="38" rx="6" fill="none" stroke="#008e96" stroke-width="4"/><path d="M38 72h20M48 64v8M31 52l8-9 7 6 10-13 9 8" fill="none" stroke="#008e96" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 357 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><circle cx="48" cy="48" r="42" fill="#f2f8f9"/><rect x="25" y="24" width="34" height="49" rx="8" fill="none" stroke="#008e96" stroke-width="4"/><circle cx="42" cy="61" r="4" fill="#008e96"/><path d="M65 35c8 7 8 19 0 26M72 28c13 12 13 28 0 40" fill="none" stroke="#008e96" stroke-width="4" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 381 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><circle cx="48" cy="48" r="42" fill="#f2f8f9"/><path d="M25 46c0-15 10-25 23-25s23 10 23 25c0 17-11 29-23 29S25 63 25 46Z" fill="none" stroke="#008e96" stroke-width="4"/><circle cx="48" cy="48" r="8" fill="none" stroke="#008e96" stroke-width="4"/><path d="M34 32 29 27M62 32l5-5M48 26v-7" stroke="#008e96" stroke-width="4" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 414 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><circle cx="48" cy="48" r="42" fill="#f2f8f9"/><path d="M31 33 48 23l17 10v28L48 71 31 61Z" fill="none" stroke="#008e96" stroke-width="4" stroke-linejoin="round"/><path d="M42 53V39h12v14M39 58h18" fill="none" stroke="#008e96" stroke-width="4" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 335 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 620 560" role="img" aria-label="Preconfigured device package">
<defs><linearGradient id="f" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#fff"/><stop offset="1" stop-color="#dce8ea"/></linearGradient><linearGradient id="s" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#c8dadd"/><stop offset="1" stop-color="#9db4ba"/></linearGradient><linearGradient id="t" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#f7ffff"/><stop offset="1" stop-color="#dff4f2"/></linearGradient><filter id="sh" x="-40%" y="-40%" width="190%" height="200%"><feDropShadow dx="0" dy="20" stdDeviation="18" flood-color="#064d5b" flood-opacity=".18"/></filter></defs>
<ellipse cx="310" cy="492" rx="225" ry="35" fill="#006576" opacity=".12" filter="url(#sh)"/>
<g filter="url(#sh)" stroke="#b9cbcf" stroke-width="4" stroke-linejoin="round"><path d="m104 188 234 67v221l-234-68Z" fill="url(#f)"/><path d="m338 255 178-129v221L338 476Z" fill="url(#s)"/><path d="M104 188 282 59l234 67-178 129Z" fill="url(#t)"/><path d="m241 99 233 67-55 40-234-67Z" fill="#20cfc3" fill-opacity=".7" stroke="none"/></g>
<circle cx="221" cy="323" r="56" fill="#009b9a"/><path d="m192 323 20 21 40-46" fill="none" stroke="#fff" stroke-width="15" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 640" role="img" aria-labelledby="title desc">
<title id="title">GebOS translucent data bars</title>
<desc id="desc">Five translucent teal isometric bars of increasing height.</desc>
<defs>
<linearGradient id="front" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#19e1d2" stop-opacity=".86"/><stop offset="1" stop-color="#008797" stop-opacity=".72"/></linearGradient>
<linearGradient id="side" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#00788b" stop-opacity=".74"/><stop offset="1" stop-color="#004c68" stop-opacity=".84"/></linearGradient>
<linearGradient id="top" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#9efff2" stop-opacity=".88"/><stop offset="1" stop-color="#2ce0d4" stop-opacity=".72"/></linearGradient>
<filter id="shadow" x="-30%" y="-30%" width="170%" height="180%"><feDropShadow dx="0" dy="18" stdDeviation="17" flood-color="#006875" flood-opacity=".18"/></filter>
</defs>
<ellipse cx="385" cy="572" rx="310" ry="38" fill="#007789" opacity=".13" filter="url(#shadow)"/>
<g filter="url(#shadow)" stroke="#75fff1" stroke-opacity=".34" stroke-width="2">
<g><path d="M75 425 159 449 159 551 75 527Z" fill="url(#front)"/><path d="m159 449 43-31v102l-43 31Z" fill="url(#side)"/><path d="m75 425 43-31 84 24-43 31Z" fill="url(#top)"/></g>
<g><path d="m195 352 84 24v175l-84-24Z" fill="url(#front)"/><path d="m279 376 43-31v175l-43 31Z" fill="url(#side)"/><path d="m195 352 43-31 84 24-43 31Z" fill="url(#top)"/></g>
<g><path d="m315 270 84 24v257l-84-24Z" fill="url(#front)"/><path d="m399 294 43-31v257l-43 31Z" fill="url(#side)"/><path d="m315 270 43-31 84 24-43 31Z" fill="url(#top)"/></g>
<g><path d="m435 176 84 24v351l-84-24Z" fill="url(#front)"/><path d="m519 200 43-31v351l-43 31Z" fill="url(#side)"/><path d="m435 176 43-31 84 24-43 31Z" fill="url(#top)"/></g>
<g><path d="m555 83 84 24v444l-84-24Z" fill="url(#front)"/><path d="m639 107 43-31v444l-43 31Z" fill="url(#side)"/><path d="m555 83 43-31 84 24-43 31Z" fill="url(#top)"/></g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 620" role="img" aria-labelledby="title desc">
<title id="title">GebOS UVI sensor badge</title>
<desc id="desc">A white sensor with a teal consumption display and wireless symbol.</desc>
<defs>
<linearGradient id="case" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#fff"/><stop offset="1" stop-color="#dce7e9"/></linearGradient>
<linearGradient id="screen" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#20d9c9"/><stop offset="1" stop-color="#007b8b"/></linearGradient>
<radialGradient id="orb"><stop stop-color="#22dacd"/><stop offset=".78" stop-color="#008796"/><stop offset="1" stop-color="#005568"/></radialGradient>
<filter id="s" x="-40%" y="-40%" width="180%" height="200%"><feDropShadow dx="0" dy="22" stdDeviation="20" flood-color="#064b58" flood-opacity=".2"/></filter>
</defs>
<ellipse cx="245" cy="548" rx="160" ry="30" fill="#005c6f" opacity=".13" filter="url(#s)"/>
<g filter="url(#s)">
<rect x="98" y="98" width="240" height="400" rx="54" fill="url(#case)" stroke="#d7e4e7" stroke-width="4"/>
<rect x="135" y="170" width="166" height="192" rx="34" fill="url(#screen)"/>
<path d="M180 273c16-31 31 31 47 0s31 31 47 0" fill="none" stroke="#fff" stroke-width="15" stroke-linecap="round"/>
<circle cx="218" cy="423" r="12" fill="#00b5aa"/>
</g>
<g transform="translate(320 320)" filter="url(#s)">
<circle r="108" fill="url(#orb)" stroke="#77fff2" stroke-opacity=".55" stroke-width="3"/>
<circle r="72" fill="none" stroke="#fff" stroke-opacity=".17" stroke-width="3"/>
<path d="M-15 35V-35M8-52c34 25 34 79 0 104M37-75c58 43 58 107 0 150M-38-24c-14 14-14 34 0 48" fill="none" stroke="#fff" stroke-width="14" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 520" role="img" aria-label="Wireless operation badge">
<defs><radialGradient id="o" cx="30%" cy="20%"><stop stop-color="#37e7d7"/><stop offset=".65" stop-color="#009796"/><stop offset="1" stop-color="#005d72"/></radialGradient><filter id="sh" x="-40%" y="-40%" width="180%" height="190%"><feDropShadow dx="0" dy="22" stdDeviation="22" flood-color="#005a6c" flood-opacity=".24"/></filter></defs>
<ellipse cx="260" cy="423" rx="150" ry="30" fill="#007284" opacity=".15" filter="url(#sh)"/><circle cx="260" cy="238" r="162" fill="url(#o)" stroke="#9afff2" stroke-opacity=".55" stroke-width="5" filter="url(#sh)"/>
<circle cx="260" cy="277" r="18" fill="#fff"/><path d="M211 231c27-27 71-27 98 0M178 198c46-46 118-46 164 0M147 166c63-63 163-63 226 0" fill="none" stroke="#fff" stroke-width="20" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 883 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

@@ -0,0 +1,37 @@
import * as React from "react"
import { CircleCheck } from "lucide-react"
import { cn } from "@/lib/utils"
function ChecklistItem({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return (
<li className={cn("flex items-start gap-2.5 text-sm", className)}>
<CircleCheck className="mt-0.5 size-4 shrink-0 text-primary" />
<span>{children}</span>
</li>
)
}
function Checklist({
items,
className,
}: {
items: React.ReactNode[]
className?: string
}) {
return (
<ul className={cn("flex flex-col gap-2.5", className)}>
{items.map((item, i) => (
<ChecklistItem key={i}>{item}</ChecklistItem>
))}
</ul>
)
}
export { Checklist, ChecklistItem }
@@ -0,0 +1,50 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { IconBadge } from "@/components/gebos/icon-tile"
/**
* Teal gradient call-to-action band:
* glassy icon disc · bold white title + muted subline · actions right.
*/
function CtaBanner({
icon,
title,
description,
actions,
className,
}: {
icon: React.ReactNode
title: React.ReactNode
description?: React.ReactNode
actions: React.ReactNode
className?: string
}) {
return (
<div
className={cn(
"flex flex-col gap-5 rounded-2xl bg-brand-gradient p-6 shadow-band sm:flex-row sm:items-center sm:gap-6 sm:p-7",
className
)}
>
<IconBadge variant="glass" size="lg">
{icon}
</IconBadge>
<div className="min-w-0 flex-1">
<h3 className="text-lg font-bold tracking-tight text-white sm:text-xl">
{title}
</h3>
{description ? (
<p className="mt-1 text-sm leading-relaxed text-white/75">
{description}
</p>
) : null}
</div>
<div className="flex shrink-0 flex-wrap items-center gap-3">
{actions}
</div>
</div>
)
}
export { CtaBanner }
@@ -0,0 +1,63 @@
import { cn } from "@/lib/utils"
import buildingAvif from "@/assets/gebos/building.avif"
import buildingWebp from "@/assets/gebos/building.webp"
import consumptionRing from "@/assets/gebos/consumption-ring.svg"
import smokeAlarmAvif from "@/assets/gebos/smoke-alarm.avif"
import smokeAlarmWebp from "@/assets/gebos/smoke-alarm.webp"
import tealBars from "@/assets/gebos/teal-bars.svg"
import uviBadge from "@/assets/gebos/uvi-badge.svg"
/**
* Homepage hero composition from the GebOS visual asset kit:
* building render (white matte → mix-blend-multiply), smoke alarm,
* translucent data bars, UVI badge and consumption ring.
* Layout mirrors the kit's demo.html so proportions can be tuned
* without rerendering.
*/
function HeroArt({ className }: { className?: string }) {
return (
<div
className={cn(
"relative mx-auto aspect-[1.28] w-full max-w-[680px] lg:mr-0 lg:ml-auto",
className
)}
aria-label="GebOS Produktvisualisierung"
role="img"
>
<img
src={tealBars}
alt=""
className="absolute top-[12%] -right-[5%] z-0 w-[46%]"
/>
<picture>
<source srcSet={buildingAvif} type="image/avif" />
<img
src={buildingWebp}
alt="Modernes Mehrfamilienhaus"
className="absolute top-[2%] left-[2%] z-1 h-[90%] w-[96%] object-contain mix-blend-multiply"
/>
</picture>
<picture>
<source srcSet={smokeAlarmAvif} type="image/avif" />
<img
src={smokeAlarmWebp}
alt="Rauchwarnmelder"
className="absolute top-[4%] -left-[2%] z-3 w-[22%] rounded-full mix-blend-multiply"
/>
</picture>
<img
src={uviBadge}
alt=""
className="absolute bottom-[7%] left-[2%] z-4 w-[15%]"
/>
<img
src={consumptionRing}
alt=""
className="absolute -bottom-[2%] -right-[2%] z-5 w-[46%]"
/>
</div>
)
}
export { HeroArt }
@@ -0,0 +1,84 @@
import * as React from "react"
import { CircleCheck, Wifi } from "lucide-react"
import { cn } from "@/lib/utils"
import { IconBadge } from "@/components/gebos/icon-tile"
import {
BuildingIllustration,
DonutIllustration,
} from "@/components/gebos/illustrations"
/**
* Floating status chip ("Inspektion OK · 06.05.2024") used on hero visuals.
*/
function StatusChip({
title,
sub,
className,
}: {
title: React.ReactNode
sub?: React.ReactNode
className?: string
}) {
return (
<div
className={cn(
"flex items-center gap-2.5 rounded-xl border border-border/60 bg-card/95 px-3.5 py-2.5 shadow-card-lg backdrop-blur",
className
)}
>
<IconBadge variant="solid" size="sm">
<CircleCheck />
</IconBadge>
<div className="leading-tight">
<div className="text-xs font-bold text-navy">{title}</div>
{sub ? (
<div className="text-[10px] text-muted-foreground">{sub}</div>
) : null}
</div>
</div>
)
}
/**
* Reusable hero composition standing in for the 3D building renders:
* glow stage, building, floating status card, signal chip and donut.
* `media` swaps the centre illustration.
*/
function HeroVisual({
media,
chipTitle = "Inspektion OK",
chipSub = "Letzte Prüfung 06.05.2024",
className,
}: {
media?: React.ReactNode
chipTitle?: React.ReactNode
chipSub?: React.ReactNode
className?: string
}) {
return (
<div className={cn("relative mx-auto w-full max-w-md", className)}>
{/* stage */}
<div className="absolute inset-x-6 top-8 bottom-0 rounded-[2.5rem] bg-gradient-to-br from-white via-brand-50 to-brand-100 shadow-card-lg" />
<div className="relative px-10 pt-4 pb-8">
{media ?? <BuildingIllustration className="mx-auto w-56" />}
</div>
{/* floating elements */}
<StatusChip
title={chipTitle}
sub={chipSub}
className="absolute top-16 -left-2 sm:left-0"
/>
<IconBadge
variant="solid"
size="lg"
className="absolute bottom-20 -left-1 shadow-band sm:left-4"
>
<Wifi />
</IconBadge>
<DonutIllustration className="absolute -right-2 bottom-4 w-24 drop-shadow-lg sm:right-0" />
</div>
)
}
export { HeroVisual, StatusChip }
@@ -0,0 +1,97 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const iconBadgeVariants = cva(
"flex shrink-0 items-center justify-center [&_svg]:shrink-0",
{
variants: {
variant: {
/* soft teal disc */
soft: "bg-accent text-brand-700",
/* white disc with hairline + teal line icon (feature rows) */
outline: "border border-brand-100 bg-card text-primary shadow-pill",
solid: "bg-primary text-primary-foreground",
navy: "bg-navy text-white",
/* translucent white on gradient bands */
glass: "bg-white/15 text-white ring-1 ring-white/25",
},
size: {
sm: "size-9 [&_svg]:size-4",
md: "size-11 [&_svg]:size-5",
lg: "size-14 [&_svg]:size-6",
xl: "size-16 [&_svg]:size-7",
},
shape: {
circle: "rounded-full",
squircle: "rounded-xl",
},
},
defaultVariants: {
variant: "soft",
size: "md",
shape: "circle",
},
}
)
function IconBadge({
className,
variant,
size,
shape,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof iconBadgeVariants>) {
return (
<div
className={cn(iconBadgeVariants({ variant, size, shape }), className)}
{...props}
/>
)
}
/**
* Icon-over-label tile used in the feature rows underneath heroes
* (UVI · Heizkostenabrechnung · Rauchwarnmelder · …).
*/
function FeatureTile({
icon,
media,
title,
sub,
align = "center",
className,
}: {
icon?: React.ReactNode
/** self-contained visual (e.g. asset-kit icon) rendered without IconBadge */
media?: React.ReactNode
title: React.ReactNode
sub?: React.ReactNode
align?: "center" | "start"
className?: string
}) {
return (
<div
className={cn(
"flex flex-col gap-2.5",
align === "center" ? "items-center text-center" : "items-start",
className
)}
>
{media ?? (
<IconBadge variant="outline" shape="squircle" size="lg">
{icon}
</IconBadge>
)}
<div className="text-sm font-bold text-navy">{title}</div>
{sub ? (
<p className="-mt-1.5 text-xs leading-snug text-muted-foreground">
{sub}
</p>
) : null}
</div>
)
}
export { IconBadge, iconBadgeVariants, FeatureTile }
@@ -0,0 +1,154 @@
import * as React from "react"
/**
* Lightweight SVG stand-ins for the 3D renders in the designs.
* All of them draw exclusively from the chart/brand tokens.
*/
/** Smoke detector: white disc, vent ring, teal status LED. */
function SmokeDetectorIllustration({ className }: { className?: string }) {
const vents = Array.from({ length: 12 }, (_, i) => {
const a = (i / 12) * Math.PI * 2
return (
<circle
key={i}
cx={60 + Math.cos(a) * 30}
cy={60 + Math.sin(a) * 30}
r="3.2"
fill="var(--muted)"
/>
)
})
return (
<svg viewBox="0 0 120 120" aria-hidden="true" className={className}>
<defs>
<radialGradient id="sd-body" cx="0.35" cy="0.3" r="0.9">
<stop offset="0" stopColor="#ffffff" />
<stop offset="0.75" stopColor="#f2f6f7" />
<stop offset="1" stopColor="#dde7e9" />
</radialGradient>
</defs>
<circle cx="60" cy="64" r="52" fill="rgb(19 41 60 / 0.08)" />
<circle cx="60" cy="60" r="52" fill="url(#sd-body)" />
<circle
cx="60"
cy="60"
r="41"
fill="none"
stroke="rgb(19 41 60 / 0.08)"
strokeWidth="1.5"
/>
{vents}
<circle cx="60" cy="60" r="12" fill="#eef3f4" />
<circle cx="60" cy="60" r="4" fill="var(--brand-500)" />
</svg>
)
}
/** Cluster of translucent isometric sensor cubes (UVI). */
function SensorCubesIllustration({ className }: { className?: string }) {
const cube = (x: number, y: number, s: number, o: number) => (
<g transform={`translate(${x} ${y}) scale(${s})`} opacity={o}>
<path d="M0 10 20 0l20 10-20 10Z" fill="var(--brand-300)" />
<path d="M0 10v22l20 10V20Z" fill="var(--brand-500)" />
<path d="M40 10v22L20 42V20Z" fill="var(--brand-600)" />
</g>
)
return (
<svg viewBox="0 0 120 120" aria-hidden="true" className={className}>
<g opacity="0.9">
{cube(12, 12, 1.1, 0.55)}
{cube(58, 30, 1.0, 0.8)}
{cube(26, 56, 1.3, 1)}
</g>
</svg>
)
}
/** Donut chart in brand tones (portfolio / reporting motif). */
function DonutIllustration({ className }: { className?: string }) {
/* three segments: 55% teal, 25% light, 20% pale */
const C = 2 * Math.PI * 40
return (
<svg viewBox="0 0 120 120" aria-hidden="true" className={className}>
<g transform="rotate(-90 60 60)">
<circle
cx="60"
cy="60"
r="40"
fill="none"
stroke="var(--chart-3)"
strokeWidth="22"
/>
<circle
cx="60"
cy="60"
r="40"
fill="none"
stroke="var(--chart-2)"
strokeWidth="22"
strokeDasharray={`${C * 0.25} ${C}`}
strokeDashoffset={-C * 0.55}
/>
<circle
cx="60"
cy="60"
r="40"
fill="none"
stroke="var(--chart-1)"
strokeWidth="22"
strokeDasharray={`${C * 0.55} ${C}`}
/>
</g>
</svg>
)
}
/** Stylised apartment building: white slabs, window grid, teal accents. */
function BuildingIllustration({ className }: { className?: string }) {
const windows: React.ReactNode[] = []
for (let row = 0; row < 5; row++) {
for (let col = 0; col < 4; col++) {
windows.push(
<rect
key={`${row}-${col}`}
x={34 + col * 22}
y={36 + row * 26}
width="14"
height="16"
rx="2"
fill={row === 1 && col === 2 ? "var(--brand-300)" : "#dbe6ea"}
/>
)
}
}
return (
<svg viewBox="0 0 160 200" aria-hidden="true" className={className}>
<defs>
<linearGradient id="bld-face" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#ffffff" />
<stop offset="1" stopColor="#e9f0f2" />
</linearGradient>
</defs>
<ellipse cx="80" cy="188" rx="72" ry="10" fill="rgb(19 41 60 / 0.08)" />
<rect x="24" y="22" width="112" height="166" rx="8" fill="url(#bld-face)" />
<rect x="24" y="22" width="112" height="10" rx="5" fill="#f7fafb" />
{windows}
{/* balcony bands */}
<rect x="28" y="60" width="104" height="3" rx="1.5" fill="#cfdde1" />
<rect x="28" y="112" width="104" height="3" rx="1.5" fill="#cfdde1" />
{/* entrance */}
<rect x="70" y="160" width="20" height="28" rx="3" fill="var(--navy)" opacity="0.85" />
</svg>
)
}
export {
SmokeDetectorIllustration,
SensorCubesIllustration,
DonutIllustration,
BuildingIllustration,
}
@@ -0,0 +1,98 @@
import mark3dAvif from "@/assets/gebos/brand-mark-3d-96.avif"
import mark3dWebp from "@/assets/gebos/brand-mark-3d-96.webp"
import { cn } from "@/lib/utils"
/**
* GebOS brand mark: isometric cube inside a hexagon, teal gradient stroke,
* followed by the two-tone wordmark ("Geb" navy / "OS" teal).
*/
function LogoMark({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 48 48"
fill="none"
aria-hidden="true"
className={cn("size-8", className)}
>
<defs>
<linearGradient id="gebos-mark" x1="8" y1="6" x2="42" y2="42">
<stop offset="0" stopColor="var(--brand-400)" />
<stop offset="0.55" stopColor="var(--brand-600)" />
<stop offset="1" stopColor="var(--navy)" />
</linearGradient>
</defs>
{/* top face */}
<path
d="M24 5.5 40.5 15 24 24.5 7.5 15Z"
fill="var(--brand-100)"
opacity="0.7"
/>
{/* hexagon silhouette */}
<path
d="M24 4.5 41 14.3v19.4L24 43.5 7 33.7V14.3Z"
stroke="url(#gebos-mark)"
strokeWidth="3.4"
strokeLinejoin="round"
/>
{/* inner cube edges */}
<path
d="M7.6 14.7 24 24.2m0 0 16.4-9.5M24 24.2v18.6"
stroke="url(#gebos-mark)"
strokeWidth="3.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
/** 3D glass brand mark (rendered asset, genuine alpha). */
function LogoMark3d({ className }: { className?: string }) {
return (
<picture>
<source srcSet={mark3dAvif} type="image/avif" />
<img
src={mark3dWebp}
alt=""
width={96}
height={96}
className={cn("size-8 object-contain", className)}
/>
</picture>
)
}
function Logo({
className,
markClassName,
inverse = false,
mark = "line",
}: {
className?: string
markClassName?: string
inverse?: boolean
/** "line" = flat SVG mark, "3d" = glass render */
mark?: "line" | "3d"
}) {
return (
<span className={cn("inline-flex items-center gap-2", className)}>
{mark === "3d" ? (
<LogoMark3d className={markClassName} />
) : (
<LogoMark className={markClassName} />
)}
<span
className={cn(
"text-[1.35rem] leading-none font-extrabold tracking-tight",
inverse ? "text-white" : "text-navy"
)}
>
Geb
<span className={inverse ? "text-brand-300" : "text-primary"}>OS</span>
</span>
</span>
)
}
export { Logo, LogoMark, LogoMark3d }
@@ -0,0 +1,55 @@
import { Fragment } from "react"
import { Link } from "react-router-dom"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
export interface Crumb {
label: string
/** omit on the last crumb it renders as the current page */
to?: string
}
/**
* Site-wide breadcrumb trail: parent chain → current page.
* Top-level pages start at "Startseite", audience pages at "Für wen?".
*/
function PageBreadcrumb({
items,
className,
}: {
items: Crumb[]
className?: string
}) {
return (
<Breadcrumb>
<BreadcrumbList className={className}>
{items.map((item, i) => {
const last = i === items.length - 1
return (
<Fragment key={item.label}>
<BreadcrumbItem>
{last || !item.to ? (
<BreadcrumbPage>{item.label}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link to={item.to}>{item.label}</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{last ? null : <BreadcrumbSeparator />}
</Fragment>
)
})}
</BreadcrumbList>
</Breadcrumb>
)
}
export { PageBreadcrumb }
@@ -0,0 +1,62 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Container } from "@/components/gebos/section"
/**
* Split hero: copy left (breadcrumb/eyebrow, H1, lead, CTAs, extra),
* visual right, soft radial glow behind.
*/
function PageHero({
breadcrumb,
title,
lead,
actions,
extra,
visual,
className,
}: {
breadcrumb?: React.ReactNode
title: React.ReactNode
lead?: React.ReactNode
actions?: React.ReactNode
/** slot below the actions (e.g. hint pill) */
extra?: React.ReactNode
visual?: React.ReactNode
className?: string
}) {
return (
<div className={cn("bg-hero-glow", className)}>
{breadcrumb ? (
<Container className="pt-6 lg:pt-8">{breadcrumb}</Container>
) : null}
<Container
className={cn(
"grid items-center gap-10 pb-14 sm:pb-16 lg:grid-cols-[1.05fr_0.95fr] lg:gap-6 lg:pb-20",
/* breadcrumb already provides the top offset */
breadcrumb ? "pt-5" : "pt-14 sm:pt-16 lg:pt-20"
)}
>
<div className={cn("max-w-xl", breadcrumb && "lg:self-start")}>
<h1 className="text-4xl font-extrabold tracking-tight text-balance text-navy sm:text-5xl">
{title}
</h1>
{lead ? (
<p className="mt-4 max-w-lg text-base leading-relaxed text-pretty text-muted-foreground sm:text-lg">
{lead}
</p>
) : null}
{actions ? (
<div className="mt-7 flex flex-wrap items-center gap-3">
{actions}
</div>
) : null}
{extra ? <div className="mt-6">{extra}</div> : null}
</div>
{visual ? <div className="relative w-full">{visual}</div> : null}
</Container>
</div>
)
}
export { PageHero }
@@ -0,0 +1,65 @@
import * as React from "react"
import { Ban, CircleCheck } from "lucide-react"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Card } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
/**
* Klarpreis pricing card:
* title · cadence pill · large price + unit · allocability pill ·
* divider · description. Optional media floats top-right.
*/
function PriceCard({
title,
cadence,
price,
unit,
allocation,
description,
media,
className,
}: {
title: React.ReactNode
/** "Einmalig" | "Monatlich" pill under the title */
cadence: React.ReactNode
price: React.ReactNode
unit: React.ReactNode
/** umlagefähig? tone drives soft-teal vs. soft-red pill */
allocation: { label: React.ReactNode; tone: "positive" | "negative" }
description: React.ReactNode
media?: React.ReactNode
className?: string
}) {
return (
<Card className={cn("relative gap-0 p-6 sm:p-7", className)}>
{media ? (
<div className="pointer-events-none absolute top-6 right-6 w-24 sm:w-28">
{media}
</div>
) : null}
<h3 className="pr-24 text-xl font-bold tracking-tight text-navy">
{title}
</h3>
<Badge className="mt-2.5">{cadence}</Badge>
<div className="mt-5 text-[2.6rem] leading-none font-extrabold tracking-tight text-navy">
{price}
</div>
<div className="mt-1.5 text-sm text-muted-foreground">{unit}</div>
<Badge
variant={allocation.tone === "positive" ? "default" : "destructive"}
className="mt-4"
>
{allocation.tone === "positive" ? <CircleCheck /> : <Ban />}
{allocation.label}
</Badge>
<Separator className="mt-5 mb-4" />
<p className="text-sm leading-relaxed text-muted-foreground">
{description}
</p>
</Card>
)
}
export { PriceCard }
@@ -0,0 +1,97 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { IconBadge } from "@/components/gebos/icon-tile"
export interface ProcessStep {
icon?: React.ReactNode
/** self-contained visual (asset render/vector) shown instead of IconBadge */
media?: React.ReactNode
title: React.ReactNode
description?: React.ReactNode
}
/**
* Numbered process row ("So funktioniert GebOS", steps 15):
* numbered chips joined by a dotted teal line, device icon, title, copy.
*/
function ProcessSteps({
steps,
className,
}: {
steps: ProcessStep[]
className?: string
}) {
return (
<ol
className={cn(
"relative grid gap-x-6 gap-y-10 sm:grid-cols-2",
steps.length >= 5 ? "lg:grid-cols-5" : "lg:grid-cols-4",
className
)}
>
<div
aria-hidden="true"
className="absolute inset-x-[10%] top-3.5 hidden border-t-2 border-dotted-brand lg:block"
/>
{steps.map((step, i) => (
<li
key={i}
className="relative flex flex-col items-center gap-3 text-center"
>
<span className="z-10 flex size-7 items-center justify-center rounded-full bg-card text-xs font-bold text-primary shadow-pill ring-2 ring-brand-300">
{i + 1}
</span>
{step.media ?? (
<IconBadge variant="outline" shape="squircle" size="xl">
{step.icon}
</IconBadge>
)}
<h3 className="text-sm font-bold text-navy">{step.title}</h3>
{step.description ? (
<p className="-mt-1 max-w-[16rem] text-xs leading-relaxed text-muted-foreground">
{step.description}
</p>
) : null}
</li>
))}
</ol>
)
}
/**
* Vertical variant used by the Konfigurator overview (steps 14).
*/
function NumberedList({
steps,
className,
}: {
steps: { title: React.ReactNode; description?: React.ReactNode }[]
className?: string
}) {
return (
<ol className={cn("relative flex flex-col gap-6", className)}>
<div
aria-hidden="true"
className="absolute top-2 bottom-2 left-3.5 border-l-2 border-dotted-brand"
/>
{steps.map((step, i) => (
<li key={i} className="relative flex items-start gap-4">
<span className="z-10 flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground shadow-pill">
{i + 1}
</span>
<div className="pt-0.5">
<h3 className="text-sm font-bold text-navy">{step.title}</h3>
{step.description ? (
<p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
{step.description}
</p>
) : null}
</div>
</li>
))}
</ol>
)
}
export { ProcessSteps, NumberedList }
@@ -0,0 +1,64 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Container({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8", className)}
{...props}
/>
)
}
function Section({ className, ...props }: React.ComponentProps<"section">) {
return (
<section
className={cn("scroll-mt-20 py-14 sm:py-18 lg:py-20", className)}
{...props}
/>
)
}
/**
* Standard section intro: optional teal eyebrow, navy title, muted lead.
*/
function SectionHeading({
eyebrow,
title,
lead,
align = "left",
className,
}: {
eyebrow?: React.ReactNode
title: React.ReactNode
lead?: React.ReactNode
align?: "left" | "center"
className?: string
}) {
return (
<div
className={cn(
"max-w-2xl",
align === "center" && "mx-auto text-center",
className
)}
>
{eyebrow ? (
<p className="mb-2 text-xs font-bold tracking-[0.14em] text-primary uppercase">
{eyebrow}
</p>
) : null}
<h2 className="text-3xl font-extrabold tracking-tight text-balance text-navy sm:text-4xl">
{title}
</h2>
{lead ? (
<p className="mt-3 text-base leading-relaxed text-pretty text-muted-foreground sm:text-lg">
{lead}
</p>
) : null}
</div>
)
}
export { Container, Section, SectionHeading }
@@ -0,0 +1,253 @@
import { cn } from "@/lib/utils"
import barChartAvif from "@/assets/gebos/bar-chart.avif"
import barChartWebp from "@/assets/gebos/bar-chart.webp"
import brandCubeAvif from "@/assets/gebos/brand-cube-render.avif"
import brandCubeWebp from "@/assets/gebos/brand-cube-render.webp"
import brandMark3dAvif from "@/assets/gebos/brand-mark-3d.avif"
import brandMark3dWebp from "@/assets/gebos/brand-mark-3d.webp"
import brandMarkFlatAvif from "@/assets/gebos/brand-mark-flat.avif"
import brandMarkFlatWebp from "@/assets/gebos/brand-mark-flat.webp"
import buildingAlphaAvif from "@/assets/gebos/building-alpha.avif"
import buildingAlphaWebp from "@/assets/gebos/building-alpha.webp"
import buildingIsoAvif from "@/assets/gebos/building-iso.avif"
import buildingIsoWebp from "@/assets/gebos/building-iso.webp"
import buildingAvif from "@/assets/gebos/building.avif"
import buildingWebp from "@/assets/gebos/building.webp"
import checkOrb from "@/assets/gebos/check-orb.svg"
import cloudSupport from "@/assets/gebos/cloud-support.svg"
import consumptionRing from "@/assets/gebos/consumption-ring.svg"
import cubesRoundAvif from "@/assets/gebos/cubes-round.avif"
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 gatewayAvif from "@/assets/gebos/gateway.avif"
import gatewayWebp from "@/assets/gebos/gateway.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"
import heatMeterAvif from "@/assets/gebos/heat-meter.avif"
import heatMeterWebp from "@/assets/gebos/heat-meter.webp"
import iconBilling from "@/assets/gebos/icon-billing.svg"
import iconPortal from "@/assets/gebos/icon-portal.svg"
import iconRadio from "@/assets/gebos/icon-radio.svg"
import iconSmokeAlarm from "@/assets/gebos/icon-smoke-alarm.svg"
import iconUvi from "@/assets/gebos/icon-uvi.svg"
import laptopAvif from "@/assets/gebos/laptop-dashboard.avif"
import laptopWebp from "@/assets/gebos/laptop-dashboard.webp"
import packageBox from "@/assets/gebos/package-box.svg"
import shippingBoxClosedAvif from "@/assets/gebos/shipping-box-closed.avif"
import shippingBoxClosedWebp from "@/assets/gebos/shipping-box-closed.webp"
import shippingBoxMetersAvif from "@/assets/gebos/shipping-box-meters.avif"
import shippingBoxMetersWebp from "@/assets/gebos/shipping-box-meters.webp"
import smokeAlarmAvif from "@/assets/gebos/smoke-alarm.avif"
import smokeAlarmWebp from "@/assets/gebos/smoke-alarm.webp"
import tealBars from "@/assets/gebos/teal-bars.svg"
import uviBadge from "@/assets/gebos/uvi-badge.svg"
import wirelessOrb from "@/assets/gebos/wireless-orb.svg"
import wordmarkDarkAvif from "@/assets/gebos/wordmark-cubes-dark.avif"
import wordmarkDarkWebp from "@/assets/gebos/wordmark-cubes-dark.webp"
import wordmarkGlowAvif from "@/assets/gebos/wordmark-cubes-glow.avif"
import wordmarkGlowWebp from "@/assets/gebos/wordmark-cubes-glow.webp"
import workerTabletAvif from "@/assets/gebos/worker-tablet.avif"
import workerTabletWebp from "@/assets/gebos/worker-tablet.webp"
export interface RenderAsset {
avif: string
webp: string
alt: string
/**
* "white" pure-white matte, needs multiply blending to sit on light surfaces.
* "alpha" genuine transparency, composited as-is.
*/
matte: "white" | "alpha"
}
/**
* Photographic / 3D renders. Always draw through <AssetRender>, which picks
* the right compositing mode for the asset's matte.
*/
const renders = {
/* --- site kit: white-matte masters --------------------------------- */
building: {
avif: buildingAvif,
webp: buildingWebp,
alt: "Modernes Mehrfamilienhaus",
matte: "white",
},
smokeAlarm: {
avif: smokeAlarmAvif,
webp: smokeAlarmWebp,
alt: "Rauchwarnmelder",
matte: "white",
},
gateway: {
avif: gatewayAvif,
webp: gatewayWebp,
alt: "GebOS Gateway",
matte: "white",
},
heatMeter: {
avif: heatMeterAvif,
webp: heatMeterWebp,
alt: "Wärmemengenzähler",
matte: "white",
},
laptopDashboard: {
avif: laptopAvif,
webp: laptopWebp,
alt: "GebOS Portal auf dem Laptop",
matte: "white",
},
brandCube: {
avif: brandCubeAvif,
webp: brandCubeWebp,
alt: "",
matte: "white",
},
shippingBoxMeters: {
avif: shippingBoxMetersAvif,
webp: shippingBoxMetersWebp,
alt: "Objektbezogen vorbereitete Hardware",
matte: "white",
},
/* --- transparent renders (free placement, any background) ---------- */
/** same subject as `building`, but with genuine alpha safe on any surface */
buildingAlpha: {
avif: buildingAlphaAvif,
webp: buildingAlphaWebp,
alt: "Modernes Mehrfamilienhaus",
matte: "alpha",
},
/** isometric corner view of the same building type, genuine alpha */
buildingIso: {
avif: buildingIsoAvif,
webp: buildingIsoWebp,
alt: "Modernes Mehrfamilienhaus",
matte: "alpha",
},
brandMark3d: {
avif: brandMark3dAvif,
webp: brandMark3dWebp,
alt: "GebOS Markenzeichen",
matte: "alpha",
},
brandMarkFlat: {
avif: brandMarkFlatAvif,
webp: brandMarkFlatWebp,
alt: "GebOS Markenzeichen",
matte: "alpha",
},
glassCubesStack: {
avif: glassCubesStackAvif,
webp: glassCubesStackWebp,
alt: "",
matte: "alpha",
},
cubesRound: {
avif: cubesRoundAvif,
webp: cubesRoundWebp,
alt: "",
matte: "alpha",
},
barChart: {
avif: barChartAvif,
webp: barChartWebp,
alt: "",
matte: "alpha",
},
drillTool: {
avif: drillToolAvif,
webp: drillToolWebp,
alt: "",
matte: "alpha",
},
workerTablet: {
avif: workerTabletAvif,
webp: workerTabletWebp,
alt: "Monteur mit Tablet",
matte: "alpha",
},
shippingBoxClosed: {
avif: shippingBoxClosedAvif,
webp: shippingBoxClosedWebp,
alt: "Vorkonfigurierte Lieferung",
matte: "alpha",
},
/** baked light glow use on white/near-white surfaces */
wordmarkGlow: {
avif: wordmarkGlowAvif,
webp: wordmarkGlowWebp,
alt: "GebOS",
matte: "alpha",
},
/** baked dark glow use on navy/dark surfaces */
wordmarkDark: {
avif: wordmarkDarkAvif,
webp: wordmarkDarkWebp,
alt: "GebOS",
matte: "alpha",
},
} satisfies Record<string, RenderAsset>
/** Transparent brand vectors (decorative; render as plain <img alt="">). */
const vectors = {
tealBars,
uviBadge,
consumptionRing,
glassCubes,
checkOrb,
euroOrb,
cloudSupport,
packageBox,
wirelessOrb,
}
/** Feature icons with baked-in pale disc (use in FeatureTile `media`). */
const icons = {
uvi: iconUvi,
billing: iconBilling,
smokeAlarm: iconSmokeAlarm,
radio: iconRadio,
portal: iconPortal,
}
/**
* Renders a kit asset as AVIF→WebP <picture>. White-matte masters get
* mix-blend-multiply so their matte disappears on light surfaces;
* transparent assets composite normally.
*/
function AssetRender({
render,
alt,
className,
imgClassName,
loading = "lazy",
}: {
render: RenderAsset
/** override the registry alt (e.g. decorative usage) */
alt?: string
className?: string
imgClassName?: string
loading?: "lazy" | "eager"
}) {
return (
<picture className={className}>
<source srcSet={render.avif} type="image/avif" />
<img
src={render.webp}
alt={alt ?? render.alt}
loading={loading}
className={cn(
"object-contain",
render.matte === "white" && "mix-blend-multiply",
imgClassName
)}
/>
</picture>
)
}
export { AssetRender, icons, renders, vectors }
@@ -0,0 +1,76 @@
import { Link } from "react-router-dom"
import { Logo } from "@/components/gebos/logo"
import { Container } from "@/components/gebos/section"
const COLUMNS: { title: string; links: { to: string; label: string }[] }[] = [
{
title: "Produkt",
links: [
{ to: "/loesungen", label: "Lösungen" },
{ to: "/so-funktionierts", label: "So funktionierts" },
{ to: "/konfigurator", label: "Konfigurator" },
{ to: "/klarpreis", label: "GebOS Klarpreis" },
],
},
{
title: "Für wen?",
links: [
{ to: "/fuer-wen", label: "Übersicht" },
{ to: "/fuer-wen/hauseigentuemer", label: "Hauseigentümer" },
{ to: "/fuer-wen/hausverwaltungen", label: "Hausverwaltungen" },
{ to: "/fuer-wen/messdienstleister", label: "Messdienstleister" },
{ to: "/fuer-wen/installateure", label: "Installateure & Heizungsbauer" },
],
},
{
title: "Unternehmen",
links: [
{ to: "/unternehmen", label: "Über GebOS" },
{ to: "/kontakt", label: "Kontakt" },
],
},
]
function SiteFooter() {
return (
<footer className="mt-20 bg-navy text-white">
<Container className="grid gap-10 py-14 sm:grid-cols-2 lg:grid-cols-[1.2fr_1fr_1fr_1fr]">
<div>
<Logo inverse />
<p className="mt-4 max-w-xs text-sm leading-relaxed text-white/60">
Messdienstleistungen einfach gemacht von der vorkonfigurierten
Hardware bis zum laufenden Betrieb.
</p>
</div>
{COLUMNS.map((col) => (
<nav key={col.title} aria-label={col.title}>
<h3 className="text-xs font-bold tracking-[0.14em] text-brand-300 uppercase">
{col.title}
</h3>
<ul className="mt-4 flex flex-col gap-2.5">
{col.links.map((l) => (
<li key={l.to}>
<Link
to={l.to}
className="text-sm text-white/75 transition-colors hover:text-white"
>
{l.label}
</Link>
</li>
))}
</ul>
</nav>
))}
</Container>
<div className="border-t border-white/10">
<Container className="flex flex-wrap items-center justify-between gap-3 py-5 text-xs text-white/50">
<span>© 2026 GebOS. Alle Rechte vorbehalten.</span>
<span>Impressum · Datenschutz · AGB</span>
</Container>
</div>
</footer>
)
}
export { SiteFooter }
@@ -0,0 +1,148 @@
import { ChevronDown, Menu } from "lucide-react"
import { Link, NavLink, useLocation } from "react-router-dom"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Logo } from "@/components/gebos/logo"
import { Container } from "@/components/gebos/section"
const AUDIENCES = [
{ to: "/fuer-wen/hauseigentuemer", label: "Hauseigentümer" },
{ to: "/fuer-wen/hausverwaltungen", label: "Hausverwaltungen" },
{ to: "/fuer-wen/messdienstleister", label: "Messdienstleister" },
{ to: "/fuer-wen/installateure", label: "Installateure & Heizungsbauer" },
]
const NAV_LINKS = [
{ to: "/loesungen", label: "Lösungen" },
{ to: "/so-funktionierts", label: "So funktionierts" },
/* "Für wen?" rendered separately as dropdown */
{ to: "/konfigurator", label: "Konfigurator" },
{ to: "/unternehmen", label: "Unternehmen" },
{ to: "/kontakt", label: "Kontakt" },
]
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"
)
function ForWhomMenu() {
const { pathname } = useLocation()
const active = pathname.startsWith("/fuer-wen")
return (
<DropdownMenu>
<DropdownMenuTrigger
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?
<ChevronDown className="size-3.5 opacity-60" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem asChild>
<Link to="/fuer-wen">Übersicht</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{AUDIENCES.map((a) => (
<DropdownMenuItem key={a.to} asChild>
<Link to={a.to}>{a.label}</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
function MobileMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="lg:hidden">
<Menu />
<span className="sr-only">Menü öffnen</span>
</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>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>Für wen?</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link to="/fuer-wen">Übersicht</Link>
</DropdownMenuItem>
{AUDIENCES.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>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/klarpreis">Klarpreis</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
function SiteHeader() {
return (
<header className="sticky top-0 z-40 border-b border-border/60 bg-background/85 backdrop-blur">
<Container className="flex h-16 items-center justify-between gap-4">
<Link
to="/"
className="rounded-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40"
>
<Logo mark="3d" markClassName="size-9" />
<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>
<div className="flex items-center gap-3">
<Button asChild size="sm" className="hidden sm:inline-flex">
<Link to="/konfigurator">Preis berechnen</Link>
</Button>
<MobileMenu />
</div>
</Container>
</header>
)
}
export { SiteHeader }
@@ -0,0 +1,66 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b border-border/70 last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-semibold transition-all outline-none hover:text-primary focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pt-0 pb-4 text-muted-foreground", className)}>
{children}
</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1.5 rounded-full border px-3 py-1 text-xs font-semibold whitespace-nowrap transition-colors [&>svg]:pointer-events-none [&>svg]:size-3.5",
{
variants: {
variant: {
/* soft teal pill "Monatlich", "Grundsätzlich umlagefähig" */
default: "border-transparent bg-accent text-accent-foreground",
/* solid teal pill */
solid: "border-transparent bg-primary text-primary-foreground",
/* soft red pill "Nicht umlagefähig" */
destructive: "border-transparent bg-destructive/10 text-destructive",
outline: "border-border bg-card text-foreground shadow-pill",
muted: "border-transparent bg-muted text-muted-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
@@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-xs font-semibold tracking-wide break-words text-muted-foreground sm:gap-2",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-primary", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-semibold text-primary", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">Mehr</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg text-sm font-semibold whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-sm hover:bg-brand-700",
outline:
"border border-border bg-card text-foreground shadow-pill hover:border-brand-200 hover:bg-brand-50",
secondary: "bg-secondary text-secondary-foreground hover:bg-brand-100",
ghost: "text-foreground hover:bg-secondary",
link: "text-primary underline-offset-4 hover:underline",
/* white button sitting on the teal gradient band */
inverse: "bg-white text-brand-800 shadow-sm hover:bg-brand-50",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
},
size: {
default: "h-10 px-5 py-2",
sm: "h-9 gap-1.5 px-4",
lg: "h-11 px-6 text-[15px]",
icon: "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,89 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-2xl border border-border/70 bg-card py-6 text-card-foreground shadow-card",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn("flex flex-col gap-1.5 px-6", className)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-lg leading-snug font-bold tracking-tight",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm leading-relaxed text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("self-start justify-self-end", className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,30 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<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",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 8,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[10rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xl border border-border/70 bg-popover p-1.5 text-popover-foreground shadow-card-lg data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-pointer items-center gap-2 rounded-lg py-2 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-pointer items-center gap-2 rounded-lg py-2 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-xs font-medium text-muted-foreground data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-xl border border-border/70 bg-popover p-1.5 text-popover-foreground shadow-card-lg data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"flex h-10 w-full min-w-0 rounded-lg border border-input bg-card px-3.5 py-2 text-sm shadow-pill transition-colors outline-none selection:bg-brand-200 selection:text-navy file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
className
)}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-semibold select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,186 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit cursor-pointer items-center justify-between gap-2 rounded-lg border border-input bg-card px-3.5 py-2 text-sm font-medium whitespace-nowrap shadow-pill transition-colors outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=sm]:h-9 data-[placeholder]:text-muted-foreground *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xl border border-border/70 bg-popover text-popover-foreground shadow-card-lg data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1.5",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn(
"px-2 py-1.5 text-xs font-medium text-muted-foreground",
className
)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-2 pr-8 pl-3 text-sm font-medium outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4 text-primary" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -0,0 +1,26 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b border-border/70 transition-colors hover:bg-brand-50/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-11 px-3 text-left align-middle text-sm font-bold whitespace-nowrap text-navy [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"px-3 py-3 align-middle text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
@@ -0,0 +1,67 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-6", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn("flex flex-wrap items-center gap-3", className)}
{...props}
/>
)
}
/**
* Audience-switcher style triggers: white cards that fill teal when active
* (see "Für wen ist GebOS?" Hauseigentümer / Hausverwaltungen / …).
*/
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-border/80 bg-card px-4 py-2.5 text-sm font-semibold whitespace-nowrap text-foreground shadow-pill transition-all outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"hover:border-brand-200 hover:bg-brand-50",
"data-[state=active]:border-brand-700 data-[state=active]:bg-brand-700 data-[state=active]:text-white data-[state=active]:shadow-band",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import "@fontsource-variable/inter"
import "@/styles/globals.css"
import App from "@/App"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
)
@@ -0,0 +1,115 @@
import * as React from "react"
import { Container, Section, SectionHeading } from "@/components/gebos/section"
import { PageBreadcrumb } from "@/components/gebos/page-breadcrumb"
import { PageHero } from "@/components/gebos/page-hero"
import { FeatureTile } from "@/components/gebos/icon-tile"
type AudienceFeature = {
icon?: React.ReactNode
/** self-contained visual (asset-kit icon/vector) rendered without IconBadge */
media?: React.ReactNode
title: React.ReactNode
sub?: React.ReactNode
}
function AudienceBreadcrumb({ current }: { current: string }) {
return (
<PageBreadcrumb
items={[{ label: "Für wen?", to: "/fuer-wen" }, { label: current }]}
/>
)
}
/**
* Shared audience page template (tiles 58):
* split PageHero with breadcrumb, feature tile row, closing slot
* (teal CtaBanner or slim repeat-CTA section).
*/
function AudiencePage({
breadcrumb,
title,
tagline,
lead,
actions,
visual,
features,
children,
closing,
}: {
breadcrumb: string
title: React.ReactNode
/** short bold subline under the H1 (e.g. "Bestellen. Montieren. Fertig.") */
tagline?: React.ReactNode
lead?: React.ReactNode
actions?: React.ReactNode
visual?: React.ReactNode
features: AudienceFeature[]
/** additional full-width spec sections between feature row and closing */
children?: React.ReactNode
closing?: React.ReactNode
}) {
return (
<>
<PageHero
breadcrumb={<AudienceBreadcrumb current={breadcrumb} />}
title={title}
lead={
<>
{tagline ? (
<span className="mb-2 block text-lg font-bold text-navy sm:text-xl">
{tagline}
</span>
) : null}
{lead}
</>
}
actions={actions}
visual={visual}
/>
<Container>
<Section className="pt-2 sm:pt-4 lg:pt-6">
<div className="grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-4">
{features.map((feature, i) => (
<FeatureTile
key={i}
icon={feature.icon}
media={feature.media}
title={feature.title}
sub={feature.sub}
/>
))}
</div>
</Section>
{children}
{closing}
</Container>
</>
)
}
/**
* Slim closing section repeating the hero CTAs
* (tiles 7 and 8 close without a gradient band).
*/
function AudienceClosing({
title,
lead,
actions,
}: {
title: React.ReactNode
lead?: React.ReactNode
actions: React.ReactNode
}) {
return (
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading align="center" title={title} lead={lead} />
<div className="mt-7 flex flex-wrap items-center justify-center gap-3">
{actions}
</div>
</Section>
)
}
export { AudiencePage, AudienceClosing }
export type { AudienceFeature }
@@ -0,0 +1,291 @@
import { Link } from "react-router-dom"
import { Euro, Handshake, ReceiptText, ShieldCheck, Wrench } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Section, SectionHeading } from "@/components/gebos/section"
import { CtaBanner } from "@/components/gebos/cta-banner"
import { NumberedList } from "@/components/gebos/process-steps"
import { AssetRender, renders } from "@/components/gebos/site-assets"
import { AudiencePage } from "@/pages/audiences/audience-page"
/**
* Page-local hero composition (05-homeowners kit):
* building render on the right, smoke alarm top-left,
* detached check orb as accent between them.
*/
function HomeownerArt() {
return (
<div className="relative mx-auto aspect-[1.28] w-full max-w-[560px]">
<AssetRender
render={renders.buildingIso}
className="absolute top-[4%] right-0 w-[74%] z-50"
imgClassName="w-full"
loading="eager"
/>
<AssetRender
render={renders.smokeAlarm}
className="absolute top-[8%] left-[2%] w-[26%]"
imgClassName="w-full"
/>
<AssetRender
render={renders.barChart}
alt=""
className="absolute top-[52%] left-[10%] w-[16%] scale-200"
/>
<AssetRender
render={renders.barChart}
alt=""
className="absolute top-[40%] right-[0%] w-[16%] scale-200 z-30"
/>
<AssetRender
render={renders.cubesRound}
alt=""
className="absolute top-[50%] right-[30%] w-[16%] scale-200 z-30"
/>
</div>
)
}
const SELF_INSTALL_STEPS = [
{
title: "Gebäude angeben",
description:
"Sie teilen uns die grundlegenden Informationen zum Gebäude und den Wohnungen mit.",
},
{
title: "Passende Ausstattung zusammenstellen",
description:
"Die benötigten Zähler, Kommunikationskomponenten und gegebenenfalls Rauchwarnmelder werden zusammengestellt.",
},
{
title: "Vorkonfiguriert erhalten",
description:
"Die Geräte werden bereits für Ihr Gebäude vorbereitet geliefert.",
},
{
title: "Montieren",
description:
"Sie selbst, Ihr Hausmeister oder Ihr Fachbetrieb installiert die Geräte.",
},
{
title: "GebOS übernimmt",
description:
"Nach der Inbetriebnahme laufen Datenerfassung und die gebuchten Dienstleistungen über GebOS.",
},
]
const WHY_GEBOS = [
{
title: "Kosten kontrollieren",
text: "Entscheiden Sie selbst, welche Arbeiten Sie übernehmen und welche Sie abgeben möchten.",
},
{
title: "Keine unnötige technische Komplexität",
text: "Die Hardware wird für das jeweilige Gebäude vorbereitet.",
},
{
title: "Bestehenden Handwerker nutzen",
text: "Sie benötigen keinen speziellen GebOS-Installateur.",
},
{
title: "Alles an einem Ort",
text: "UVI, Abrechnung, Geräte und weitere Gebäudedienstleistungen werden zentral verwaltet.",
},
{
title: "Weniger laufender Aufwand",
text: "Nach der Einrichtung werden Verbrauchsdaten automatisch erfasst und verarbeitet.",
},
]
const VARIANT_ROWS = [
["Planung", "gemeinsam mit GebOS", "GebOS"],
["Hardware", "GebOS", "GebOS"],
["Installation", "Kunde / eigener Fachbetrieb", "organisiert durch GebOS"],
["Softwareeinrichtung", "vorbereitet durch GebOS", "GebOS"],
["Laufender Betrieb", "GebOS", "GebOS"],
["Kosten", "möglichst niedrig", "höhere Planungssicherheit und Komfort"],
]
export default function HauseigentuemerPage() {
return (
<AudiencePage
breadcrumb="Hauseigentümer"
title={
<span className="block text-[2.25rem] text-pretty sm:text-[2.75rem]">
Messdienstleistungen für Ihr Mehrfamilienhaus. Einfach organisiert.
</span>
}
lead="UVI, Heizkostenabrechnung und Rauchwarnmelder mit vorkonfigurierter Technik selbst installieren oder als Komplettlösung beauftragen."
actions={
<Button asChild size="lg">
<Link to="/konfigurator">Preis für mein Gebäude berechnen</Link>
</Button>
}
visual={<HomeownerArt />}
features={[
{
icon: <Wrench />,
title: "Selbst installieren",
sub: "Kosten sparen, flexibel bleiben",
},
{
icon: <ShieldCheck />,
title: "Komplettlösung",
sub: "Wir kümmern uns um alles",
},
{
icon: <ReceiptText />,
title: "Transparente Preise",
sub: "Keine versteckten Kosten",
},
{
icon: <Handshake />,
title: "Bestehende Partner",
sub: "Ihre Handwerker bleiben",
},
]}
closing={
<Section className="pt-0 sm:pt-0 lg:pt-0">
<CtaBanner
icon={<Euro />}
title="Welche Lösung passt zu Ihrem Gebäude?"
description="Mit wenigen Angaben zur ersten Preisindikation oder direkt die Komplettlösung anfragen."
actions={
<>
<Button asChild variant="inverse" size="lg">
<Link to="/konfigurator">
Preis für mein Gebäude berechnen
</Link>
</Button>
<Button asChild variant="inverse" size="lg">
<Link to="/kontakt">Komplettlösung anfragen</Link>
</Button>
</>
}
/>
</Section>
}
>
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
eyebrow="Ihre Entscheidung"
title="Zwei Wege zu GebOS"
/>
<div className="mt-10 grid gap-6 md:grid-cols-2">
<Card className="relative gap-0 overflow-hidden p-7">
<AssetRender
render={renders.drillTool}
className="pointer-events-none absolute -top-2 -right-3 w-28 opacity-90"
/>
<h3 className="pr-24 text-lg font-extrabold tracking-tight text-navy">
Selbst installieren
</h3>
<p className="mt-3 text-sm leading-relaxed text-pretty text-muted-foreground">
Sie möchten Kosten niedrig halten und können die Installation
selbst oder über einen vertrauten Heizungsbauer, Hausmeister
oder anderen Fachbetrieb organisieren? GebOS liefert die
benötigte Technik passend zu Ihrem Gebäude vorkonfiguriert.
</p>
<p className="mt-3 text-sm font-bold leading-relaxed text-navy">
Sie organisieren die Montage. GebOS übernimmt den digitalen
Betrieb.
</p>
<div className="mt-6">
<Button asChild size="sm">
<Link to="/konfigurator">Preis berechnen</Link>
</Button>
</div>
</Card>
<Card className="relative gap-0 overflow-hidden p-7">
<AssetRender
render={renders.shippingBoxMeters}
className="pointer-events-none absolute -top-1 -right-4 w-32"
/>
<h3 className="pr-28 text-lg font-extrabold tracking-tight text-navy">
Komplett installieren lassen
</h3>
<p className="mt-3 text-sm leading-relaxed text-pretty text-muted-foreground">
Sie möchten sich möglichst wenig mit Planung, Beschaffung und
Installation beschäftigen? GebOS organisiert die komplette
Umsetzung für Sie. Von der Ermittlung der benötigten Geräte
über die Installation bis zum laufenden Betrieb erhalten Sie
eine durchgängige Lösung.
</p>
<div className="mt-6">
<Button asChild size="sm" variant="outline">
<Link to="/kontakt">Komplettlösung anfragen</Link>
</Button>
</div>
</Card>
</div>
</Section>
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
eyebrow="Schritt für Schritt"
title="So funktioniert die Selbstinstallation"
/>
<NumberedList steps={SELF_INSTALL_STEPS} className="mt-10 max-w-2xl" />
</Section>
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
eyebrow="Ihre Vorteile"
title="Warum GebOS für Hauseigentümer?"
/>
<div className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{WHY_GEBOS.map((item) => (
<Card key={item.title} className="gap-0 p-6">
<h3 className="text-sm font-bold text-navy">{item.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
{item.text}
</p>
</Card>
))}
</div>
</Section>
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
eyebrow="Im Vergleich"
title="Welche Variante passt zu mir?"
/>
<Card className="mt-10 gap-0 overflow-hidden p-2 sm:p-4">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[30%]" />
<TableHead>Selbst installieren</TableHead>
<TableHead>Komplettlösung</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{VARIANT_ROWS.map(([label, self, full]) => (
<TableRow key={label}>
<TableCell className="font-bold text-navy">
{label}
</TableCell>
<TableCell className="text-muted-foreground">
{self}
</TableCell>
<TableCell className="text-muted-foreground">
{full}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</Section>
</AudiencePage>
)
}
@@ -0,0 +1,348 @@
import { Link } from "react-router-dom"
import {
Ban,
Euro,
LayoutDashboard,
Package,
Timer,
TrendingUp,
Unlock,
X,
} from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { Section, SectionHeading } from "@/components/gebos/section"
import { CtaBanner } from "@/components/gebos/cta-banner"
import { IconBadge } from "@/components/gebos/icon-tile"
import { NumberedList } from "@/components/gebos/process-steps"
import {
AssetRender,
renders,
vectors,
} from "@/components/gebos/site-assets"
import { AudiencePage } from "@/pages/audiences/audience-page"
const deviceExamples = [
"Wärmemengenzähler",
"Warmwasserzähler",
"Kaltwasserzähler",
"Kommunikationsinfrastruktur",
"Rauchwarnmelder",
]
const rolloutSteps = [
{
title: "Portfolio und Objekte erfassen",
description:
"Bei der Auftragserstellung werden die betreffenden Gebäude, Wohnungen und die vorhandene beziehungsweise benötigte Ausstattung erfasst.",
},
{
title: "Gerätebedarf bestimmen",
description: (
<>
Für jedes Objekt wird festgelegt, welche Geräte benötigt werden.
Beispielsweise:
<span className="mt-2 flex flex-wrap gap-1.5">
{deviceExamples.map((device) => (
<Badge key={device} variant="muted">
{device}
</Badge>
))}
</span>
</>
),
},
{
title: "GebOS bereitet den Rollout vor",
description:
"Gebäude und Wohnungen werden im System angelegt und die Hardware wird objektbezogen vorbereitet.",
},
{
title: "Installationsfertig liefern",
description:
"Die Geräte werden passend zum jeweiligen Gebäude beziehungsweise zur jeweiligen Nutzungseinheit ausgeliefert.",
},
{
title: "Bestehende Monteure installieren",
description:
"Eigene Mitarbeiter, Hausmeister oder bestehende Installationspartner können die Montage übernehmen. Eine umfangreiche Softwarekonfiguration vor Ort ist nicht erforderlich.",
},
{
title: "Bewohner einladen",
description:
"Nach der Einrichtung müssen im Wesentlichen noch die relevanten Bewohnerdaten ergänzt beziehungsweise Einladungen zum Mieterportal versendet werden.",
},
]
const noEffortItems = [
{ icon: <Ban />, title: "Keine aufwendige Gerätekonfiguration." },
{ icon: <X />, title: "Keine manuelle Anlage jedes einzelnen Gerätes." },
{
icon: <Unlock />,
title: "Keine spezielle GebOS-Zertifizierung für den Installateur.",
},
]
const portfolioStats = [
{ value: "48", label: "Gebäude" },
{ value: "384", label: "Wohnungen" },
{ value: "1.746", label: "Geräte" },
]
const statusInfos = [
"Auftrag angelegt",
"Hardware vorbereitet",
"Versendet",
"Installation begonnen",
"Installation vollständig",
"Geräte aktiv",
"Bewohner eingeladen",
"Handlungsbedarf",
]
const whyGebos = [
{
title: "Schneller Rollout",
sub: "Standardisierte Abläufe ermöglichen die parallele Umsetzung vieler Objekte.",
},
{
title: "Weniger Administration",
sub: "Gebäudedaten werden möglichst nur einmal erfasst und anschließend weiterverwendet.",
},
{
title: "Vorkonfigurierte Hardware",
sub: "Geräte werden bereits passend zu den jeweiligen Objekten vorbereitet.",
},
{
title: "Bestehende Installationspartner",
sub: "Eigene Hausmeister, technische Mitarbeiter oder externe Fachbetriebe können weiter eingesetzt werden.",
},
{
title: "Zentrale Portfolioübersicht",
sub: "Gebäude, Wohnungen und Geräte lassen sich gemeinsam betrachten.",
},
{
title: "Ein Ansprechpartner",
sub: "Technik und laufende digitale Dienstleistungen greifen ineinander.",
},
]
/** Portfolio/rollout hero per asset kit 06: building, teal bars, consumption ring. */
function PortfolioHeroArt() {
return (
<div className="relative aspect-[1.28] w-full">
<img
src={vectors.tealBars}
alt=""
className="absolute top-[22%] right-0 w-[44%]"
/>
<AssetRender
render={renders.building}
className="absolute inset-x-[8%] top-[4%]"
imgClassName="w-full"
loading="eager"
/>
<img
src={vectors.consumptionRing}
alt=""
className="absolute bottom-[3%] left-[2%] w-[28%]"
/>
</div>
)
}
export default function HausverwaltungenPage() {
return (
<AudiencePage
breadcrumb="Hausverwaltungen"
title="Für Hausverwaltungen"
tagline="Skalieren Sie Ihre Bestände einfach."
lead="Rollout über viele Gebäude mit minimalem Aufwand von der Planung bis zum laufenden Betrieb."
actions={
<>
<Button asChild size="lg">
<Link to="/konfigurator">Portfolio kalkulieren</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link to="/kontakt">Rollout besprechen</Link>
</Button>
</>
}
visual={<PortfolioHeroArt />}
features={[
{
icon: <Package />,
title: "Vorkonfigurierte Hardware",
},
{
icon: <Timer />,
title: "Wenig Aufwand vor Ort",
},
{
icon: <LayoutDashboard />,
title: "Zentrale Übersicht",
},
{
icon: <TrendingUp />,
title: "Skalierbar für wachsende Portfolios",
},
]}
closing={
<Section className="pt-0 sm:pt-0 lg:pt-0">
<CtaBanner
icon={<Euro />}
title="Wie sieht Ihr Portfolio aus?"
description="Erhalten Sie zunächst mit wenigen Angaben eine Preisindikation für mehrere Gebäude oder sprechen Sie mit uns über vorhandene Messtechnik und den geplanten Rollout."
actions={
<>
<Button asChild variant="inverse" size="lg">
<Link to="/konfigurator">Portfolio kalkulieren</Link>
</Button>
<Button asChild variant="inverse" size="lg">
<Link to="/kontakt">Rollout besprechen</Link>
</Button>
</>
}
/>
</Section>
}
>
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
align="center"
eyebrow="Skalierung"
title="Weniger Projektaufwand pro Gebäude"
lead="Bei einem einzelnen Gebäude sind manuelle Arbeitsschritte überschaubar. Bei dutzenden Gebäuden werden sie zum Problem. GebOS reduziert genau diese wiederkehrenden Aufgaben."
/>
<Card className="mx-auto mt-8 max-w-xl border-l-4 border-l-primary">
<CardContent className="py-6 text-center">
<p className="text-lg font-bold text-balance text-navy">
Objekte einmal erfassen. Geräte vorbereitet erhalten. Montieren.
Betreiben.
</p>
</CardContent>
</Card>
</Section>
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
eyebrow="Ablauf"
title="Bestellung und Installation greifen ineinander"
/>
<NumberedList className="mt-10 max-w-2xl" steps={rolloutSteps} />
</Section>
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
align="center"
title="Der Monteur montiert. GebOS kümmert sich um den Rest."
lead="Die Installation soll nicht davon abhängen, dass vor Ort ein speziell geschulter GebOS-Techniker verfügbar ist."
/>
<div className="mt-10 grid gap-6 sm:grid-cols-3">
{noEffortItems.map((item, i) => (
<Card key={i}>
<CardContent className="flex flex-col items-center gap-4 py-6 text-center">
<IconBadge variant="soft" shape="squircle" size="lg">
{item.icon}
</IconBadge>
<p className="text-sm font-bold text-balance text-navy">
{item.title}
</p>
</CardContent>
</Card>
))}
</div>
<p className="mt-8 text-center text-base text-muted-foreground">
Damit können bestehende Installationsstrukturen weiter genutzt
werden.
</p>
</Section>
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
align="center"
eyebrow="Übersicht"
title="Rollout zentral überblicken"
lead="Bei größeren Portfolios reicht es nicht, Geräte lediglich zu installieren. Die Verwaltung muss erkennen können, wo der Rollout steht und wo Handlungsbedarf besteht."
/>
<Card className="mx-auto mt-10 max-w-3xl shadow-card-lg">
<CardContent className="py-6">
<div className="flex flex-wrap items-start justify-between gap-6">
<div className="min-w-0 flex-1">
<p className="text-xs font-bold tracking-[0.14em] text-primary uppercase">
Beispiel
</p>
<h3 className="mt-1 text-xl font-extrabold tracking-tight text-navy">
Portfolio
</h3>
<div className="mt-5 flex flex-wrap gap-x-10 gap-y-4">
{portfolioStats.map((stat) => (
<div key={stat.label}>
<div className="text-3xl font-extrabold tracking-tight text-navy">
{stat.value}
</div>
<div className="text-xs font-medium text-muted-foreground">
{stat.label}
</div>
</div>
))}
</div>
<div className="mt-6 flex flex-wrap gap-2">
<Badge>43 Gebäude aktiv</Badge>
<Badge variant="muted">3 in Installation</Badge>
<Badge variant="destructive">2 mit Handlungsbedarf</Badge>
</div>
</div>
<img
src={vectors.consumptionRing}
alt=""
className="w-36 shrink-0 max-sm:hidden"
/>
</div>
<Separator className="my-6" />
<p className="text-xs font-bold text-muted-foreground">
Mögliche Statusinformationen
</p>
<div className="mt-2.5 flex flex-wrap gap-1.5">
{statusInfos.map((status) => (
<Badge key={status} variant="outline">
{status}
</Badge>
))}
</div>
</CardContent>
</Card>
</Section>
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
align="center"
title="Warum GebOS für Hausverwaltungen?"
/>
<div className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{whyGebos.map((item) => (
<Card key={item.title}>
<CardContent className="py-5">
<h3 className="text-sm font-bold text-navy">{item.title}</h3>
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">
{item.sub}
</p>
</CardContent>
</Card>
))}
</div>
</Section>
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
align="center"
title="Vom kleinen Bestand bis zum wachsenden Portfolio"
lead="GebOS soll nicht verlangen, dass eine Hausverwaltung zunächst eine eigene Messdienst-Infrastruktur aufbaut. Neue Gebäude können schrittweise ergänzt und nach demselben standardisierten Prozess ausgerollt werden."
/>
</Section>
</AudiencePage>
)
}
@@ -0,0 +1,327 @@
import { Link } from "react-router-dom"
import {
CircleCheck,
GraduationCap,
Package,
PackageX,
Unlock,
Users,
Wrench,
} from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Checklist } from "@/components/gebos/checklist"
import { IconBadge } from "@/components/gebos/icon-tile"
import { ProcessSteps } from "@/components/gebos/process-steps"
import { Section, SectionHeading } from "@/components/gebos/section"
import { AssetRender, renders, vectors } from "@/components/gebos/site-assets"
import { AudiencePage, AudienceClosing } from "@/pages/audiences/audience-page"
const KUNDEN_BEDARFE = [
"UVI",
"Heizkostenabrechnung",
"fernauslesbare Messtechnik",
"Rauchwarnmelder",
]
const KEINE_HUERDEN = [
{
icon: <Unlock />,
title: "Keine verpflichtende Partnerschaft",
},
{
icon: <PackageX />,
title: "Keine Mindestabnahme",
},
{
icon: <GraduationCap />,
title: "Keine verpflichtende GebOS-Vertriebsschulung",
},
{
icon: <Wrench />,
title: "Keine komplizierte Softwareeinrichtung auf der Baustelle",
},
]
const GEBOS_BEREITET_VOR = [
"Gebäudestruktur",
"Wohnungen beziehungsweise Nutzungseinheiten",
"Gerätezuordnung",
"benötigte Hardware",
"Kommunikationsinfrastruktur",
"Softwarestruktur",
]
const SIE_UEBERNEHMEN = [
"fachgerechte Montage",
"gegebenenfalls Austausch vorhandener Geräte",
"Einbau der vorgesehenen Messtechnik",
"Abschluss der Installation",
]
export default function InstallateurePage() {
return (
<AudiencePage
breadcrumb="Installateure & Heizungsbauer"
title={
<>
Für Installateure &amp;
<br />
Heizungsbauer
</>
}
tagline="Bestellen. Montieren. Fertig."
lead="GebOS für Ihre Kunden installieren ohne Partnerprogramm, verpflichtende Zertifizierung oder aufwendige Softwarekonfiguration."
actions={
<Button asChild size="lg">
<Link to="/konfigurator">Gebäude für meinen Kunden konfigurieren</Link>
</Button>
}
visual={
<div className="relative aspect-[1.28] w-full">
<img
src={vectors.checkOrb}
alt=""
className="absolute top-[6%] right-[8%] w-[16%]"
/>
<AssetRender
render={renders.heatMeter}
className="absolute top-[30%] left-[2%] w-[38%]"
imgClassName="w-full"
/>
<AssetRender
render={renders.gateway}
className="absolute top-[24%] right-[16%] w-[46%]"
imgClassName="w-full"
/>
<img
src={vectors.glassCubes}
alt=""
className="absolute right-[1%] bottom-[10%] w-[26%]"
/>
</div>
}
features={[
{
icon: <Unlock />,
title: "Keine Partnerpflichten",
sub: "Keine Mindestmengen",
},
{
icon: <Package />,
title: "Vorkonfigurierte Hardware",
},
{
icon: <Wrench />,
title: "Einfache Installation",
sub: "Keine Softwarekonfiguration",
},
{
icon: <Users />,
title: "Ihre Kunden",
sub: "Bleiben Ihre Kunden",
},
]}
closing={
<AudienceClosing
title="GebOS bei einem Kunden einsetzen?"
lead="Stellen Sie mit wenigen Angaben zunächst die ungefähre Ausstattung und den Preis für das Gebäude zusammen."
actions={
<Button asChild size="lg">
<Link to="/konfigurator">
Gebäude für meinen Kunden konfigurieren
</Link>
</Button>
}
/>
}
>
{/* 1. Ihr Kunde braucht eine Messdienstlösung? */}
<Section className="pt-4 sm:pt-6 lg:pt-8">
<SectionHeading
eyebrow="Ausgangslage"
title="Ihr Kunde braucht eine Messdienstlösung?"
lead="Sie betreuen ein Mehrfamilienhaus und Ihr Kunde benötigt beispielsweise:"
/>
<div className="mt-6 flex flex-wrap gap-2.5">
{KUNDEN_BEDARFE.map((bedarf) => (
<Badge
key={bedarf}
variant="outline"
className="px-4 py-1.5 text-sm font-semibold text-navy"
>
<CircleCheck className="text-primary" />
{bedarf}
</Badge>
))}
</div>
<p className="mt-6 max-w-2xl text-base leading-relaxed text-muted-foreground">
Dann können Sie GebOS einfach für das betreffende Gebäude einsetzen.{" "}
<strong className="font-semibold text-navy">
Sie müssen dafür nicht zunächst Teil eines Partnerprogramms werden.
</strong>
</p>
</Section>
{/* 2. So einfach soll die Installation sein */}
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
eyebrow="Ablauf"
title="So einfach soll die Installation sein"
align="center"
/>
<ProcessSteps
className="mt-12"
steps={[
{
media: (
<AssetRender
render={renders.building}
className="flex h-16 items-center justify-center"
imgClassName="h-16 w-auto"
/>
),
title: "Gebäude erfassen",
description:
"Die benötigte Ausstattung wird für das konkrete Objekt zusammengestellt.",
},
{
media: (
<img
src={vectors.packageBox}
alt=""
className="h-16 w-16 object-contain"
/>
),
title: "Geräte bestellen",
description:
"GebOS liefert die zum Gebäude passenden Geräte.",
},
{
media: (
<AssetRender
render={renders.gateway}
className="flex h-16 items-center justify-center"
imgClassName="h-16 w-auto"
/>
),
title: "Vorkonfiguriert erhalten",
description:
"Die Hardware wird bereits objektbezogen vorbereitet.",
},
{
media: (
<AssetRender
render={renders.heatMeter}
className="flex h-16 items-center justify-center"
imgClassName="h-16 w-auto"
/>
),
title: "Montieren",
description: "Sie übernehmen die fachgerechte Installation.",
},
{
media: (
<img
src={vectors.checkOrb}
alt=""
className="h-16 w-16 object-contain"
/>
),
title: "Fertig",
description:
"Die digitale Einrichtung und der laufende Betrieb werden von GebOS übernommen.",
},
]}
/>
</Section>
{/* 3. Kein klassisches Partnerprogramm erforderlich */}
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
eyebrow="Ohne Einstiegshürden"
title="Kein klassisches Partnerprogramm erforderlich"
lead="Für die normale Installation von GebOS sollen keine unnötigen Einstiegshürden entstehen."
align="center"
/>
<div className="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{KEINE_HUERDEN.map((item, i) => (
<Card key={i} className="items-start gap-4 p-6">
<IconBadge variant="soft" shape="squircle">
{item.icon}
</IconBadge>
<p className="text-sm font-bold leading-snug text-navy">
{item.title}
</p>
</Card>
))}
</div>
<p className="mx-auto mt-8 max-w-2xl text-center text-base leading-relaxed text-muted-foreground">
<strong className="font-semibold text-navy">
Der Fachbetrieb bleibt der Fachbetrieb des Kunden.
</strong>{" "}
GebOS stellt das Produkt und die digitale Infrastruktur bereit.
</p>
</Section>
{/* 4. Sie können bei Ihren bestehenden Kunden bleiben */}
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
title="Sie können bei Ihren bestehenden Kunden bleiben"
lead={
<>
GebOS ist besonders interessant, wenn ein Kunde Sie ohnehin mit
Arbeiten an Heizungs-, Wasser- oder Gebäudetechnik beauftragt und
zusätzlich eine Lösung für Messdienstleistungen benötigt. Sie
können die Installation übernehmen, ohne selbst einen
vollständigen Messdienst aufbauen zu müssen.
</>
}
/>
</Section>
{/* 5. Was GebOS vorbereitet / Was Sie übernehmen */}
<Section className="pt-0 sm:pt-0 lg:pt-0">
<div className="grid gap-6 lg:grid-cols-2">
<Card className="gap-0 p-6 sm:p-8">
<h3 className="text-xl font-extrabold tracking-tight text-navy">
Was GebOS vorbereitet
</h3>
<p className="mt-1 text-sm text-muted-foreground">Je nach Auftrag:</p>
<Checklist className="mt-5" items={GEBOS_BEREITET_VOR} />
<p className="mt-5 text-sm leading-relaxed text-muted-foreground">
Damit soll vor Ort möglichst wenig zusätzliche digitale Arbeit
entstehen.
</p>
</Card>
<Card className="gap-0 p-6 sm:p-8">
<h3 className="text-xl font-extrabold tracking-tight text-navy">
Was Sie übernehmen
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Das Handwerk bleibt bei Ihnen:
</p>
<Checklist className="mt-5" items={SIE_UEBERNEHMEN} />
</Card>
</div>
</Section>
{/* 6. Und wenn Sie regelmäßig GebOS installieren? */}
<Section className="pt-0 pb-10 sm:pt-0 sm:pb-12 lg:pt-0 lg:pb-14">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-2xl font-extrabold tracking-tight text-balance text-navy sm:text-3xl">
Und wenn Sie regelmäßig GebOS installieren?
</h2>
<p className="mt-4 text-base leading-relaxed text-muted-foreground">
Auch dann soll der Einstieg freiwillig bleiben. Für Fachbetriebe mit
größerem Volumen können später zusätzliche Konditionen oder
Leistungen angeboten werden. Das normale Installieren von GebOS
bleibt davon unabhängig.
</p>
</div>
</Section>
</AudiencePage>
)
}
@@ -0,0 +1,271 @@
import { Link } from "react-router-dom"
import { ArrowDown, Code2, Layers, ShieldCheck, Tag } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Section, SectionHeading } from "@/components/gebos/section"
import { Checklist } from "@/components/gebos/checklist"
import {
AssetRender,
renders,
vectors,
} from "@/components/gebos/site-assets"
import { AudiencePage, AudienceClosing } from "@/pages/audiences/audience-page"
const platformRequirements = [
"kompatible Messtechnik",
"Kommunikationsinfrastruktur",
"Gateways",
"sichere Datenerfassung",
"Geräteverwaltung",
"Monitoring",
"Verbrauchsdatenverarbeitung",
"Nutzerportale",
"Schnittstellen",
"laufender technischer Betrieb",
]
const whiteLabelItems = [
"eigenes Branding",
"eigene Kundenbeziehung",
"mandantenfähige Verwaltung",
"Endkundenportale",
"APIs und Datenexporte",
"Geräteverwaltung",
"technischer Support",
]
const platformCapabilities = [
{
title: "Gerätekompatibilität",
sub: "Einbindung unterschiedlicher Zähler- und Gerätetypen auf Basis geeigneter Standards.",
},
{
title: "Gateway-Infrastruktur",
sub: "Zentrale Erfassung und sichere Übertragung der Gerätedaten.",
},
{
title: "Gerätemanagement",
sub: "Verwaltung großer Gerätebestände über Gebäude und Kunden hinweg.",
},
{
title: "Monitoring",
sub: "Probleme und Ausfälle erkennen, bevor daraus manueller Aufwand beim Kunden entsteht.",
},
{
title: "APIs und Integrationen",
sub: "Anbindung an eigene Systeme und bestehende Geschäftsprozesse.",
},
{
title: "Mandantenfähigkeit",
sub: "Kunden und Bestände getrennt innerhalb einer gemeinsamen Plattform verwalten.",
},
{
title: "White Label",
sub: "GebOS-Infrastruktur unter der Marke des Messdienstleisters einsetzen.",
},
]
const businessComponents = [
"Einkaufspreise für Hardware",
"Plattformgebühren",
"Mengenstaffeln",
"wiederkehrende Gebühren",
"White-Label-Konditionen",
"Support- und Servicelevel",
"mögliche Mindestmengen",
]
const stackFlow = ["Ihre Kunden", "Ihr Messdienst", "GebOS Plattform"]
/** Platform hero per asset kit 07: laptop dashboard, glass cubes, cloud support. */
function PlatformHeroArt() {
return (
<div className="relative flex aspect-[1.45] w-full items-center">
<img
src={vectors.cloudSupport}
alt=""
className="absolute top-0 right-0 w-[26%]"
/>
<AssetRender
render={renders.laptopDashboard}
className="mx-auto w-full"
imgClassName="w-full"
loading="eager"
/>
<img
src={vectors.glassCubes}
alt=""
className="absolute bottom-[2%] left-0 w-[24%]"
/>
</div>
)
}
export default function MessdienstleisterPage() {
return (
<AudiencePage
breadcrumb="Messdienstleister"
title="Für Messdienstleister"
tagline="Ihre Services. Unsere Plattform."
lead="Nutzen Sie GebOS als technische Grundlage für Ihren eigenen Messdienst skalierbar und zuverlässig."
actions={
<>
<Button asChild size="lg">
<Link to="/kontakt">Plattform kennenlernen</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link to="/kontakt">Konditionen anfragen</Link>
</Button>
</>
}
visual={<PlatformHeroArt />}
features={[
{
icon: <Tag />,
title: "White Label",
sub: "Ihr Branding, Ihre Kunden",
},
{
icon: <Code2 />,
title: "Offene APIs",
sub: "Einfach integrieren",
},
{
icon: <Layers />,
title: "Skalierbare Plattform",
sub: "Für jedes Geschäftsmodell",
},
{
icon: <ShieldCheck />,
title: "Zuverlässiger Betrieb",
sub: "Sicher und performant",
},
]}
closing={
<AudienceClosing
title="Möchten Sie GebOS für Ihren Messdienst einsetzen?"
lead="Wir zeigen Ihnen Plattform, technische Möglichkeiten und mögliche Geschäftsmodelle."
actions={
<>
<Button asChild size="lg">
<Link to="/kontakt">Plattform kennenlernen</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link to="/kontakt">Konditionen anfragen</Link>
</Button>
</>
}
/>
}
>
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
eyebrow="Infrastruktur"
title="Messdienstleistungen anbieten, ohne die gesamte Plattform selbst zu entwickeln"
lead="Moderne Messdienstleistungen benötigen mehr als einzelne Zähler. Erforderlich sind unter anderem:"
/>
<div className="mt-8 grid max-w-3xl gap-x-10 gap-y-2.5 sm:grid-cols-2">
<Checklist items={platformRequirements.slice(0, 5)} />
<Checklist items={platformRequirements.slice(5)} />
</div>
<p className="mt-8 text-base font-bold text-navy">
GebOS stellt diese Infrastruktur als Plattform bereit.
</p>
</Section>
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
align="center"
title="Konzentrieren Sie sich auf Ihre Kunden"
lead="Der Messdienstleister betreut seine Kunden und verkauft seine Dienstleistungen. GebOS stellt die technische Infrastruktur im Hintergrund bereit."
/>
<Card className="mx-auto mt-10 max-w-md">
<CardContent className="flex flex-col items-center gap-2.5 py-8">
{stackFlow.map((label) => (
<div
key={label}
className="flex flex-col items-center gap-2.5"
>
<div className="rounded-full border border-border bg-card px-6 py-2.5 text-sm font-bold text-navy shadow-pill">
{label}
</div>
<ArrowDown className="size-4 text-primary" />
</div>
))}
<div className="rounded-full bg-brand-gradient px-6 py-2.5 text-center text-sm font-bold text-white shadow-band">
Hardware · Datenerfassung · Software · Betrieb
</div>
</CardContent>
</Card>
</Section>
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
eyebrow="Ihre Marke"
title="White Label"
lead="GebOS kann als technische Grundlage für Dienstleistungen unter Ihrer eigenen Marke eingesetzt werden. Je nach Geschäftsmodell können unter anderem relevant sein:"
/>
<div className="mt-8 grid max-w-3xl gap-x-10 gap-y-2.5 sm:grid-cols-2">
<Checklist items={whiteLabelItems.slice(0, 4)} />
<Checklist items={whiteLabelItems.slice(4)} />
</div>
</Section>
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
align="center"
eyebrow="Technik"
title="Technische Plattform"
lead="Für Messdienstleister steht die technische Leistungsfähigkeit deutlich stärker im Vordergrund."
/>
<div className="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{platformCapabilities.map((item) => (
<Card key={item.title}>
<CardContent className="py-5">
<h3 className="text-sm font-bold text-navy">{item.title}</h3>
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">
{item.sub}
</p>
</CardContent>
</Card>
))}
</div>
</Section>
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
align="center"
eyebrow="Konditionen"
title="Das Geschäftsmodell muss funktionieren"
lead="Eine Plattform für Messdienstleister ist nur interessant, wenn auch der wirtschaftliche Rahmen stimmt. Deshalb werden kommerzielle Konditionen individuell beziehungsweise mengenabhängig vereinbart mit anderen Geschäftsmodellen, Mengenstaffeln und gegebenenfalls White-Label-Konditionen als im öffentlichen Preiskonfigurator."
/>
<div className="mx-auto mt-8 max-w-2xl text-center">
<p className="text-xs font-bold text-muted-foreground">
Mögliche Bestandteile
</p>
<div className="mt-2.5 flex flex-wrap justify-center gap-1.5">
{businessComponents.map((component) => (
<Badge key={component} variant="outline">
{component}
</Badge>
))}
</div>
<p className="mt-6 text-sm text-muted-foreground italic">
Die konkreten Konditionen werden nicht öffentlich auf der Website
dargestellt.
</p>
</div>
</Section>
<Section className="pt-0 sm:pt-0 lg:pt-0">
<SectionHeading
align="center"
title="Gemeinsam skalieren"
lead="GebOS soll insbesondere Messdienstleistern ermöglichen, neue digitale Dienstleistungen anzubieten, ohne dafür eine vollständige eigene Hardware- und Softwareentwicklung aufbauen zu müssen. Der Messdienstleister behält dabei seine Kundenbeziehung und sein eigenes Geschäftsmodell."
/>
</Section>
</AudiencePage>
)
}
@@ -0,0 +1,553 @@
import * as React from "react"
import { Link } from "react-router-dom"
import { ArrowRight, 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"
import { Checkbox } from "@/components/ui/checkbox"
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 { 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"
type Heat = "heizkoerper" | "fussbodenheizung" | "gemischt" | "unbekannt"
type Building = {
id: number
adresse: string
heat: Heat
wohnungen: number
zimmer: number
}
const HEAT_OPTIONS: { value: Heat; label: string }[] = [
{ value: "heizkoerper", label: "Heizkörper" },
{ value: "fussbodenheizung", label: "Fußbodenheizung" },
{ value: "gemischt", label: "Gemischt" },
{ value: "unbekannt", label: "Weiß ich nicht" },
]
const INITIAL_BUILDINGS: Building[] = [
{ id: 1, adresse: "", heat: "heizkoerper", wohnungen: 12, zimmer: 3 },
{ id: 2, adresse: "", heat: "heizkoerper", wohnungen: 8, zimmer: 3 },
{ id: 3, adresse: "", heat: "heizkoerper", wohnungen: 6, zimmer: 3 },
]
const UVI_MONTHLY = 10.5
const INSPECTION_MONTHLY = 2.2
const RWM_ONE_TIME = 42
function formatEuro(value: number) {
return value.toLocaleString("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
function StepCard({
step,
title,
children,
}: {
step: number
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>
<h3 className="text-lg font-extrabold tracking-tight text-navy">
{title}
</h3>
</div>
<div className="mt-5">{children}</div>
</Card>
)
}
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 [email, setEmail] = React.useState("")
const nextId = React.useRef(INITIAL_BUILDINGS.length + 1)
const updateBuilding = (id: number, patch: Partial<Building>) => {
setBuildings((prev) => prev.map((b) => (b.id === id ? { ...b, ...patch } : b)))
}
const addBuilding = () => {
setBuildings((prev) => [
...prev,
{ id: nextId.current++, adresse: "", heat: "heizkoerper", wohnungen: 8, zimmer: 3 },
])
}
const removeBuilding = (id: number) => {
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)
return (
<Container>
<Section className="pt-6 sm:pt-7 lg:pt-8">
<div className="relative">
<PageBreadcrumb
className="mb-4"
items={[
{ label: "Startseite", to: "/" },
{ label: "Konfigurator" },
]}
/>
<SectionHeading
title="Konfigurator"
lead="In 4 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
checked={uvi}
onCheckedChange={(v) => setUvi(v === true)}
className="mt-0.5"
/>
<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
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>
</StepCard>
<StepCard step={2} title="Gebäude angeben">
<div className="flex flex-col gap-5">
{buildings.map((b, i) => (
<div
key={b.id}
className="rounded-xl border border-border bg-brand-50/40 p-4 sm:p-5"
>
<div className="flex items-center justify-between">
<div className="text-sm font-bold text-navy">
Gebäude {i + 1}
</div>
{buildings.length > 1 ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-muted-foreground"
aria-label={`Gebäude ${i + 1} entfernen`}
onClick={() => removeBuilding(b.id)}
>
<X />
</Button>
) : null}
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor={`adresse-${b.id}`}>
Adresse{" "}
<span className="font-normal text-muted-foreground">
(optional)
</span>
</Label>
<Input
id={`adresse-${b.id}`}
placeholder="Venloer Straße 123, 50823 Köln"
value={b.adresse}
onChange={(e) =>
updateBuilding(b.id, { adresse: e.target.value })
}
/>
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor={`heat-${b.id}`}>Wärmeverteilung</Label>
<Select
value={b.heat}
onValueChange={(v) =>
updateBuilding(b.id, { heat: v as Heat })
}
>
<SelectTrigger id={`heat-${b.id}`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HEAT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`wohnungen-${b.id}`}>Wohnungen</Label>
<Input
id={`wohnungen-${b.id}`}
type="number"
min={1}
value={b.wohnungen}
onChange={(e) =>
updateBuilding(b.id, {
wohnungen: Math.max(
0,
Math.round(Number(e.target.value) || 0)
),
})
}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`zimmer-${b.id}`}>Ø Zimmer</Label>
<Input
id={`zimmer-${b.id}`}
type="number"
min={1}
value={b.zimmer}
onChange={(e) =>
updateBuilding(b.id, {
zimmer: Math.max(0, Number(e.target.value) || 0),
})
}
/>
</div>
</div>
</div>
))}
<Button
type="button"
variant="outline"
className="self-start"
onClick={addBuilding}
>
<Plus /> 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">
<form
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-3"
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="config-email">E-Mail-Adresse</Label>
<Input
id="config-email"
type="email"
placeholder="name@unternehmen.de"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<Button type="submit" className="self-start">
Konfiguration speichern &amp; fortsetzen
</Button>
<p className="text-xs text-muted-foreground">
Sie erhalten einen persönlichen Link (gebos.de/konfiguration/)
zu Ihrer Konfiguration sofort weitermachen, später
zurückkehren oder den Link intern weitergeben.
</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 */}
<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}{" "}
{totalBuildings === 1 ? "Gebäude" : "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
</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>
{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>
) : 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">
Die erste Berechnung ist eine unverbindliche Preisindikation.
Der genaue Preis ergibt sich aus der vollständigen
Objektkonfiguration.
</p>
</div>
</Card>
</div>
</div>
</Section>
{/* 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:"
/>
<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"
items={[
"geschätzte benötigte Messtechnik",
"GebOS Plattform",
"Datenerfassung",
"Unterjährige Verbrauchsinformation",
"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"
items={[
"geschätzte Anzahl Rauchwarnmelder",
"Verwaltung der Geräte",
"gebuchte digitale Dienstleistungen",
]}
/>
</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>
)
}
+223
View File
@@ -0,0 +1,223 @@
import * as React from "react"
import { Link } from "react-router-dom"
import { Building2, House, LineChart, Wrench } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Checklist } from "@/components/gebos/checklist"
import { Container, Section, SectionHeading } from "@/components/gebos/section"
import { PageBreadcrumb } from "@/components/gebos/page-breadcrumb"
import {
AssetRender,
renders,
vectors,
} from "@/components/gebos/site-assets"
type AudienceKey =
| "hauseigentuemer"
| "hausverwaltung"
| "messdienstleister"
| "installateur"
const AUDIENCES: Record<
AudienceKey,
{
select: string
tab: string
tabIcon: React.ReactNode
headline: string
text: string
bullets: string[]
cta: string
href: string
}
> = {
hauseigentuemer: {
select: "Hauseigentümer",
tab: "Hauseigentümer",
tabIcon: <House />,
headline: "Messdienstleistungen für Ihr Mehrfamilienhaus",
text: "Installieren Sie vorkonfigurierte Technik selbst beziehungsweise mit Ihrem bestehenden Fachbetrieb oder überlassen Sie GebOS die komplette Umsetzung.",
bullets: [
"einfache Installation",
"transparente Kosten",
"zuverlässiger laufender Betrieb",
"eigene Fachbetriebe weiter nutzen",
"wahlweise Selbstinstallation oder Komplettlösung",
],
cta: "GebOS für Hauseigentümer",
href: "/fuer-wen/hauseigentuemer",
},
hausverwaltung: {
select: "Hausverwaltung",
tab: "Hausverwaltungen",
tabIcon: <Building2 />,
headline: "Viele Gebäude. Ein einfacher Rollout.",
text: "Erfassen Sie Ihre Objekte einmal, erhalten Sie vorkonfigurierte Hardware und nutzen Sie bestehende Mitarbeiter oder Installationspartner für die Montage.",
bullets: [
"standardisierter Rollout",
"vorkonfigurierte Geräte",
"wenig Softwarearbeit vor Ort",
"zentrale Portfolioverwaltung",
"geringer administrativer Aufwand",
"schnelle Erweiterung um weitere Gebäude",
],
cta: "GebOS für Hausverwaltungen",
href: "/fuer-wen/hausverwaltungen",
},
messdienstleister: {
select: "Messdienstleister",
tab: "Messdienstleister",
tabIcon: <LineChart />,
headline: "Ihre Messdienstleistung. Unsere Infrastruktur.",
text: "Nutzen Sie GebOS als technische Grundlage für Ihre eigenen Dienstleistungen mit Hardware, Plattform, Gerätemanagement und optionalem White Label.",
bullets: [
"technische Plattform",
"Gerätekompatibilität",
"Skalierbarkeit",
"APIs und Integrationen",
"White Label",
"individuelles Geschäftsmodell",
],
cta: "GebOS für Messdienstleister",
href: "/fuer-wen/messdienstleister",
},
installateur: {
select: "Installateur & Heizungsbauer",
tab: "Installateure",
tabIcon: <Wrench />,
headline: "Bestellen. Montieren. Fertig.",
text: "Setzen Sie GebOS bei Ihren bestehenden Kunden ein, ohne zunächst ein Partnerprogramm durchlaufen zu müssen.",
bullets: [
"kein verpflichtender Partnerstatus",
"keine Mindestabnahme",
"vorkonfigurierte Hardware",
"keine aufwendige Softwarekonfiguration",
"bestehende Kundenbeziehung bleibt erhalten",
],
cta: "GebOS für Installateure",
href: "/fuer-wen/installateure",
},
}
const AUDIENCE_KEYS = Object.keys(AUDIENCES) as AudienceKey[]
/**
* Page-local hero composition (04-for-whom kit):
* building portfolio render, teal growth bars behind right,
* consumption ring in front at the lower left.
*/
function ForWhomArt() {
return (
<div className="relative mx-auto aspect-[1.28] w-full max-w-[560px]">
<img
src={vectors.tealBars}
alt=""
className="absolute top-[16%] right-0 w-[38%]"
/>
<AssetRender
render={renders.building}
className="absolute inset-x-[6%] top-[6%]"
imgClassName="w-full"
loading="eager"
/>
<img
src={vectors.consumptionRing}
alt=""
className="absolute bottom-[2%] left-[2%] w-[30%] drop-shadow-lg"
/>
</div>
)
}
export default function ForWhomPage() {
const [audience, setAudience] = React.useState<AudienceKey>("hausverwaltung")
const active = AUDIENCES[audience]
return (
<div className="bg-hero-glow">
<Container>
<Section className="pt-6 sm:pt-7 lg:pt-8">
<PageBreadcrumb
className="mb-4"
items={[
{ label: "Startseite", to: "/" },
{ label: "Für wen?" },
]}
/>
<SectionHeading
eyebrow="Finden Sie die passende Lösung"
title="Für wen ist GebOS?"
lead="GebOS wird von unterschiedlichen Kundengruppen eingesetzt. Wählen Sie aus, was am besten zu Ihnen passt."
/>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Label htmlFor="audience-select" className="text-muted-foreground">
Ich bin
</Label>
<Select
value={audience}
onValueChange={(value) => setAudience(value as AudienceKey)}
>
<SelectTrigger id="audience-select" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AUDIENCE_KEYS.map((key) => (
<SelectItem key={key} value={key}>
{AUDIENCES[key].select}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mt-6 grid items-center gap-10 lg:grid-cols-[1.05fr_0.95fr] lg:gap-6">
<Card className="max-w-xl gap-0 p-7">
<h2 className="text-xl font-extrabold tracking-tight text-balance text-navy sm:text-2xl">
{active.headline}
</h2>
<p className="mt-3 text-sm leading-relaxed text-pretty text-muted-foreground">
{active.text}
</p>
<p className="mt-5 text-sm font-bold text-navy">
Besonders wichtig für Sie:
</p>
<Checklist items={active.bullets} className="mt-3" />
<div className="mt-6">
<Button asChild>
<Link to={active.href}>{active.cta}</Link>
</Button>
</div>
</Card>
<ForWhomArt />
</div>
<Tabs
value={audience}
onValueChange={(value) => setAudience(value as AudienceKey)}
className="mt-12"
>
<TabsList>
{AUDIENCE_KEYS.map((key) => (
<TabsTrigger key={key} value={key}>
{AUDIENCES[key].tabIcon}
{AUDIENCES[key].tab}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</Section>
</Container>
</div>
)
}
+590
View File
@@ -0,0 +1,590 @@
import { Link } from "react-router-dom"
import {
ArrowRight,
Building2,
Calculator,
HardHat,
House,
Network,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Container, Section, SectionHeading } from "@/components/gebos/section"
import { PageHero } from "@/components/gebos/page-hero"
import { HeroArt } from "@/components/gebos/hero-art"
import { FeatureTile, IconBadge } from "@/components/gebos/icon-tile"
import { ProcessSteps, NumberedList } from "@/components/gebos/process-steps"
import { Checklist } from "@/components/gebos/checklist"
import { CtaBanner } from "@/components/gebos/cta-banner"
import {
AssetRender,
icons,
renders,
vectors,
} from "@/components/gebos/site-assets"
const features = [
{
media: <img src={icons.uvi} alt="" className="size-14" loading="lazy" />,
title: "UVI",
sub: "Monatliche Verbrauchsinformation",
},
{
media: (
<img src={icons.billing} alt="" className="size-14" loading="lazy" />
),
title: "Heizkosten\u00adabrechnung",
sub: "Jährlich korrekt abrechnen",
},
{
media: (
<img src={icons.smokeAlarm} alt="" className="size-14" loading="lazy" />
),
title: "Rauchwarnmelder",
sub: "Sicher überwacht, zentral verwaltet",
},
{
media: <img src={icons.radio} alt="" className="size-14" loading="lazy" />,
title: "Fernauslesbare Messtechnik",
sub: "Automatisch erfasst, sicher übertragen",
},
{
media: (
<img src={icons.portal} alt="" className="size-14" loading="lazy" />
),
title: "Portale & Verwaltung",
sub: "Alle Daten und Geräte im Blick",
},
]
const operationsScope = [
{
media: <img src={icons.uvi} alt="" className="size-12" loading="lazy" />,
title: "Unterjährige Verbrauchsinformation",
text: "Verbrauchsdaten werden automatisch erfasst und die regelmäßigen Verbrauchsinformationen für die Bewohner bereitgestellt.",
},
{
media: (
<img src={icons.billing} alt="" className="size-12" loading="lazy" />
),
title: "Heizkostenabrechnung",
text: "Die erfassten Verbrauchsdaten bilden die Grundlage für die jährliche Heizkostenabrechnung.",
},
{
media: (
<img src={icons.smokeAlarm} alt="" className="size-12" loading="lazy" />
),
title: "Rauchwarnmelder",
text: "Rauchwarnmelder können gemeinsam mit der übrigen Gebäudetechnik geplant, installiert und zentral verwaltet werden.",
},
{
media: <img src={icons.radio} alt="" className="size-12" loading="lazy" />,
title: "Fernauslesbare Messtechnik",
text: "Wärme- und Wasserverbrauch werden über kompatible Geräte automatisch erfasst.",
},
{
media: (
<AssetRender
render={renders.gateway}
className="flex size-12 items-center justify-center"
imgClassName="w-12"
/>
),
title: "Gateway und Datenübertragung",
text: "Die Messdaten werden zentral gesammelt und an GebOS übertragen.",
},
{
media: (
<img src={icons.portal} alt="" className="size-12" loading="lazy" />
),
title: "Portale und Verwaltung",
text: "Gebäude, Wohnungen, Geräte und Verbrauchsdaten werden zentral verwaltet. Bewohner erhalten Zugriff auf die für sie vorgesehenen Informationen.",
},
]
function StepMedia({ src }: { src: string }) {
return (
<span className="flex size-16 items-center justify-center">
<img src={src} alt="" className="size-16" loading="lazy" />
</span>
)
}
const processSteps = [
{
media: <StepMedia src={vectors.uviBadge} />,
title: "Gebäude erfassen",
description: "Daten zum Gebäude und den Wohnungen erfassen.",
},
{
media: (
<AssetRender
render={renders.building}
className="flex size-16 items-center justify-center"
imgClassName="w-16"
/>
),
title: "Gerätebedarf bestimmen",
description: "GebOS ermittelt die benötigte Ausstattung automatisch.",
},
{
media: <StepMedia src={vectors.packageBox} />,
title: "Vorkonfiguriert liefern",
description: "Hardware wird für Ihr Objekt vorbereitet geliefert.",
},
{
media: (
<AssetRender
render={renders.heatMeter}
className="flex size-16 items-center justify-center"
imgClassName="w-16"
/>
),
title: "Geräte montieren",
description: "Montage vor Ort ohne aufwendige Softwarekonfiguration.",
},
{
media: <StepMedia src={vectors.wirelessOrb} />,
title: "Automatisch betreiben",
description: "GebOS übernimmt Daten, Services und laufenden Betrieb.",
},
]
const lessEffort = [
{
vector: vectors.packageBox,
title: "Vorkonfigurierte Hardware",
text: "Geräte werden passend zum jeweiligen Gebäude und zur vorgesehenen Nutzung vorbereitet.",
},
{
vector: vectors.checkOrb,
title: "Keine aufwendige Einrichtung auf der Baustelle",
text: "Der Installateur kann sich auf die fachgerechte Montage konzentrieren.",
},
{
vector: vectors.checkOrb,
title: "Bestehende Fachbetriebe nutzen",
text: "Kein spezieller GebOS-Partner erforderlich eigene Hausmeister, Heizungsbauer oder bestehende Installationspartner können weiterhin eingesetzt werden.",
},
{
vector: vectors.checkOrb,
title: "Weniger doppelte Dateneingabe",
text: "Informationen aus Bestellung und Konfiguration werden für den späteren Betrieb weiterverwendet.",
},
]
const systemTasks = [
{
media: <img src={icons.uvi} alt="" className="size-10" loading="lazy" />,
title: "UVI",
text: "Regelmäßige Verbrauchsinformation für Bewohner.",
},
{
media: (
<img src={icons.billing} alt="" className="size-10" loading="lazy" />
),
title: "Heizkostenabrechnung",
text: "Verbrauchsdaten strukturiert erfassen und für die jährliche Abrechnung nutzen.",
},
{
media: (
<img src={icons.smokeAlarm} alt="" className="size-10" loading="lazy" />
),
title: "Rauchwarnmelder",
text: "Rauchwarnmelder gemeinsam mit der übrigen Gebäudetechnik planen und verwalten.",
},
{
media: <img src={icons.radio} alt="" className="size-10" loading="lazy" />,
title: "Messtechnik und Infrastruktur",
text: "Fernauslesbare Zähler, Sensoren und Kommunikationsinfrastruktur für den laufenden Betrieb.",
},
]
const audiences = [
{
icon: <House />,
name: "Hauseigentümer",
headline: "Messdienstleistungen für Ihr Mehrfamilienhaus",
text: "Installieren Sie vorkonfigurierte Technik selbst beziehungsweise mit Ihrem bestehenden Fachbetrieb oder überlassen Sie GebOS die komplette Umsetzung.",
points: [
"einfache Installation",
"transparente Kosten",
"zuverlässiger laufender Betrieb",
],
cta: "GebOS für Hauseigentümer",
to: "/fuer-wen/hauseigentuemer",
},
{
icon: <Building2 />,
name: "Hausverwaltungen",
headline: "Viele Gebäude. Ein einfacher Rollout.",
text: "Erfassen Sie Ihre Objekte einmal, erhalten Sie vorkonfigurierte Hardware und nutzen Sie bestehende Mitarbeiter oder Installationspartner für die Montage.",
points: [
"standardisierter Rollout",
"vorkonfigurierte Geräte",
"zentrale Portfolioverwaltung",
],
cta: "GebOS für Hausverwaltungen",
to: "/fuer-wen/hausverwaltungen",
},
{
icon: <Network />,
name: "Messdienstleister",
headline: "Ihre Messdienstleistung. Unsere Infrastruktur.",
text: "Nutzen Sie GebOS als technische Grundlage für Ihre eigenen Dienstleistungen mit Hardware, Plattform, Gerätemanagement und optionalem White Label.",
points: ["technische Plattform", "Skalierbarkeit", "APIs und White Label"],
cta: "GebOS für Messdienstleister",
to: "/fuer-wen/messdienstleister",
},
{
icon: <HardHat />,
name: "Installateure & Heizungsbauer",
headline: "Bestellen. Montieren. Fertig.",
text: "Setzen Sie GebOS bei Ihren bestehenden Kunden ein, ohne zunächst ein Partnerprogramm durchlaufen zu müssen.",
points: [
"kein verpflichtender Partnerstatus",
"keine Mindestabnahme",
"vorkonfigurierte Hardware",
],
cta: "GebOS für Installateure",
to: "/fuer-wen/installateure",
},
]
const scaleFlows = [
{
vector: vectors.tealBars,
label: "1 Gebäude",
sub: "Ein Eigentümer kann ein Mehrfamilienhaus konfigurieren.",
steps: [
{ title: "GebOS konfigurieren" },
{ title: "Installieren" },
{ title: "Betreiben" },
],
},
{
vector: vectors.consumptionRing,
label: "50 Gebäude",
sub: "Eine Hausverwaltung kann denselben Prozess auf viele Gebäude übertragen.",
steps: [
{ title: "Nach demselben Prozess konfigurieren" },
{ title: "Rollout organisieren" },
{ title: "Zentral betreiben" },
],
},
]
const priceFlow = [
{ title: "Wenige Angaben" },
{ title: "Erste Preisindikation" },
{ title: "Konfiguration speichern" },
{ title: "Gebäude vollständig konfigurieren" },
{ title: "Genauer Preis" },
]
export default function HomePage() {
return (
<>
<PageHero
title={
<>
Messdienstleistungen
<br className="hidden sm:block" />{" "}
<span className="text-primary">einfach</span> gemacht.
</>
}
lead="Unterjährige Verbrauchsinformation (UVI), Heizkostenabrechnung und Rauchwarnmelder von der vorkonfigurierten Hardware bis zum laufenden Betrieb."
actions={
<>
<Button asChild size="lg">
<Link to="/konfigurator">Preis berechnen</Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link to="/so-funktionierts">So funktioniert GebOS</Link>
</Button>
</>
}
visual={<HeroArt />}
/>
<Container>
<Section className="pt-0">
<div className="rounded-2xl border border-border bg-card p-6 shadow-card sm:p-8">
<div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 lg:grid-cols-5">
{features.map((feature) => (
<FeatureTile key={feature.sub} {...feature} />
))}
</div>
</div>
</Section>
<Section>
<SectionHeading
align="center"
eyebrow="Das System"
title="Alles, was für den laufenden Messbetrieb benötigt wird"
lead="GebOS verbindet Messtechnik, Datenerfassung und Software zu einem durchgängigen System. Statt einzelne Komponenten selbst zusammenzustellen und miteinander zu verbinden, erhalten Sie eine Lösung, bei der Bestellung, Hardware, Konfiguration und laufender Betrieb zusammenspielen."
/>
<div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{operationsScope.map((item) => (
<Card key={item.title} className="p-6 gap-0">
<CardContent className="flex h-full flex-col items-start gap-3 p-0">
{item.media}
<h3 className="text-base font-bold text-navy">
{item.title}
</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
{item.text}
</p>
</CardContent>
</Card>
))}
</div>
</Section>
<Section>
<SectionHeading
align="center"
eyebrow="Der Prozess"
title="So funktioniert GebOS"
lead="Von der Bestellung bis zum laufenden Betrieb GebOS verbindet Bestellung, Konfiguration und Installation miteinander."
/>
<ProcessSteps steps={processSteps} className="mt-12" />
<p className="mt-10 text-center text-base font-semibold italic text-navy">
Bestellung, Konfiguration und Installation greifen ineinander.
</p>
<div className="mt-6 flex justify-center">
<Button asChild size="lg">
<Link to="/konfigurator">Preis für mein Gebäude berechnen</Link>
</Button>
</div>
</Section>
<Section>
<SectionHeading
align="center"
eyebrow="Installation"
title="Weniger Aufwand vor Ort"
lead="Die Installation eines Messsystems sollte nicht daran scheitern, dass vor Ort komplizierte Software eingerichtet oder jedes Gerät manuell zugeordnet werden muss. GebOS bereitet die digitale Struktur bereits vor der Installation vor."
/>
<div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{lessEffort.map((item) => (
<Card key={item.title} className="p-6 gap-0">
<CardContent className="flex h-full flex-col items-start gap-3 p-0">
<img
src={item.vector}
alt=""
className="size-12"
loading="lazy"
/>
<h3 className="text-base font-bold text-navy">
{item.title}
</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
{item.text}
</p>
</CardContent>
</Card>
))}
</div>
</Section>
<Section>
<SectionHeading
align="center"
eyebrow="Leistungen"
title="Ein System für mehrere Aufgaben"
lead="GebOS bündelt wiederkehrende Mess- und Gebäudedienstleistungen auf einer gemeinsamen technischen Grundlage."
/>
<Card className="mt-12 divide-y divide-border p-0 gap-0">
{systemTasks.map((task) => (
<div
key={task.title}
className="flex flex-col gap-3 p-5 sm:flex-row sm:items-center sm:gap-5 sm:px-7"
>
{task.media}
<div className="min-w-0 flex-1">
<h3 className="text-sm font-bold text-navy">{task.title}</h3>
<p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
{task.text}
</p>
</div>
<Button
asChild
variant="link"
className="h-auto shrink-0 self-start p-0 sm:self-center"
>
<Link to="/loesungen">
Mehr erfahren
<ArrowRight />
</Link>
</Button>
</div>
))}
</Card>
</Section>
<Section>
<SectionHeading
align="center"
eyebrow="Skalierung"
title="Vom einzelnen Mehrfamilienhaus bis zum Portfolio"
lead="GebOS ist so aufgebaut, dass derselbe grundlegende Prozess sowohl für ein einzelnes Gebäude als auch für größere Bestände funktioniert. Neue Objekte werden nach einem einheitlichen Schema ergänzt und ausgerollt."
/>
<Card className="mt-12 p-0 gap-0 overflow-hidden">
<div className="grid divide-y divide-border sm:grid-cols-2 sm:divide-x sm:divide-y-0">
{scaleFlows.map((flow) => (
<div key={flow.label} className="flex flex-col gap-5 p-6 sm:p-8">
<div className="flex items-center gap-4">
<img
src={flow.vector}
alt=""
className="size-14"
loading="lazy"
/>
<div>
<h3 className="text-xl font-extrabold tracking-tight text-navy">
{flow.label}
</h3>
<p className="mt-0.5 text-sm text-muted-foreground">
{flow.sub}
</p>
</div>
</div>
<NumberedList steps={flow.steps} className="mt-1" />
</div>
))}
</div>
</Card>
</Section>
<Section>
<SectionHeading
eyebrow="Preis"
title="Was kostet GebOS?"
lead="Mit dem GebOS-Konfigurator erhalten Sie bereits mit wenigen Angaben eine erste Preisindikation."
/>
<div className="mt-10 grid items-start gap-10 lg:grid-cols-2 lg:gap-14">
<div>
<p className="text-sm leading-relaxed text-muted-foreground">
Dafür werden zunächst nur grundlegende Informationen benötigt,
beispielsweise:
</p>
<Checklist
className="mt-4"
items={[
"gewünschte Leistungen",
"Anzahl der Gebäude",
"Anzahl der Wohnungen",
"Art der Wärmeverteilung",
"durchschnittliche Anzahl der Zimmer",
]}
/>
<p className="mt-4 text-sm leading-relaxed text-muted-foreground">
Auf Basis dieser Angaben schätzt GebOS die voraussichtlich
benötigte Geräteanzahl und berechnet einen ungefähren Preis.
</p>
<h3 className="mt-8 text-lg font-extrabold tracking-tight text-navy">
Erst schätzen. Dann genau konfigurieren.
</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
Der Schnellkonfigurator arbeitet bewusst mit Annahmen. Für
einen genauen Preis können Sie die Konfiguration anschließend
speichern und Ihre Gebäude detailliert erfassen. Die bereits
eingegebenen Daten werden übernommen.
</p>
<Button asChild size="lg" className="mt-6">
<Link to="/konfigurator">Preis berechnen</Link>
</Button>
</div>
<div>
<Card className="p-6 gap-0 sm:p-8">
<CardContent className="p-0">
<div className="flex items-center gap-4">
<img
src={vectors.euroOrb}
alt=""
className="size-14"
loading="lazy"
/>
<h3 className="text-base font-bold text-navy">
In fünf Schritten zum genauen Preis
</h3>
</div>
<NumberedList steps={priceFlow} className="mt-6" />
</CardContent>
</Card>
<p className="mt-5 text-xs leading-relaxed text-muted-foreground italic">
Die erste Berechnung ist eine unverbindliche Preisindikation.
Die tatsächliche Ausstattung und der genaue Preis ergeben sich
aus der vollständigen Objektkonfiguration.
</p>
</div>
</div>
</Section>
<Section>
<SectionHeading
align="center"
eyebrow="Zielgruppen"
title="Für wen ist GebOS?"
lead="GebOS wird von unterschiedlichen Kundengruppen eingesetzt. Wählen Sie aus, was am besten zu Ihnen passt."
/>
<div className="mt-12 grid gap-5 sm:grid-cols-2">
{audiences.map((audience) => (
<Card key={audience.to} className="p-6 gap-0 sm:p-7">
<CardContent className="flex h-full flex-col items-start gap-3 p-0">
<div className="flex items-center gap-3">
<IconBadge variant="soft" shape="squircle" size="lg">
{audience.icon}
</IconBadge>
<span className="text-xs font-bold tracking-wide text-primary uppercase">
{audience.name}
</span>
</div>
<h3 className="text-lg font-extrabold tracking-tight text-navy">
{audience.headline}
</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
{audience.text}
</p>
<p className="mt-1 text-xs font-bold tracking-wide text-navy uppercase">
Besonders wichtig für Sie
</p>
<Checklist items={audience.points} className="mb-2" />
<Button asChild variant="outline" className="mt-auto">
<Link to={audience.to}>{audience.cta}</Link>
</Button>
</CardContent>
</Card>
))}
</div>
</Section>
<Section>
<CtaBanner
icon={<Calculator />}
title="Messdienstleistungen müssen nicht kompliziert sein."
description="Gebäude erfassen, passende Technik erhalten, montieren und anschließend zentral betreiben."
actions={
<>
<Button asChild variant="inverse" size="lg">
<Link to="/konfigurator">Preis berechnen</Link>
</Button>
<Button
asChild
variant="outline"
size="lg"
className="border-white/40 bg-transparent text-white hover:bg-white/10 hover:text-white"
>
<Link to="/so-funktionierts">So funktioniert GebOS</Link>
</Button>
</>
}
/>
</Section>
</Container>
</>
)
}
@@ -0,0 +1,181 @@
import type { ReactNode } from "react"
import { Link } from "react-router-dom"
import { Calculator, ChevronRight } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { CtaBanner } from "@/components/gebos/cta-banner"
import { ProcessSteps } from "@/components/gebos/process-steps"
import { Container, Section, SectionHeading } from "@/components/gebos/section"
import { PageBreadcrumb } from "@/components/gebos/page-breadcrumb"
import {
AssetRender,
renders,
vectors,
} from "@/components/gebos/site-assets"
function StepMedia({ children }: { children: ReactNode }) {
return (
<div className="flex h-16 items-end justify-center gap-1">{children}</div>
)
}
const steps = [
{
media: (
<StepMedia>
<img src={vectors.uviBadge} alt="" className="w-16" />
</StepMedia>
),
title: "Gebäude erfassen",
description:
"Bei der Konfiguration werden Gebäude, Wohnungen und die benötigten Leistungen erfasst.",
},
{
media: (
<StepMedia>
<AssetRender render={renders.building} imgClassName="w-16" />
</StepMedia>
),
title: "Gerätebedarf bestimmen",
description:
"Auf Basis der Gebäudedaten wird ermittelt, welche Messtechnik, Rauchwarnmelder und weiteren Komponenten benötigt werden.",
},
{
media: (
<StepMedia>
<img src={vectors.packageBox} alt="" className="w-16" />
</StepMedia>
),
title: "Vorkonfiguriert liefern",
description:
"Gebäude, Wohnungen und Geräte werden bereits vorbereitet. Die Hardware wird objektbezogen und installationsfertig geliefert.",
},
{
media: (
<StepMedia>
<AssetRender render={renders.heatMeter} imgClassName="w-12" />
<AssetRender render={renders.gateway} imgClassName="w-9" />
</StepMedia>
),
title: "Geräte montieren",
description: (
<>
Die Geräte müssen vor Ort im Wesentlichen nur noch fachgerecht montiert
werden.{" "}
<strong className="font-bold text-navy">
Keine aufwendige Softwarekonfiguration bei der Installation.
</strong>
</>
),
},
{
media: (
<StepMedia>
<img src={vectors.wirelessOrb} alt="" className="w-16" />
</StepMedia>
),
title: "Automatisch betreiben",
description:
"Nach der Installation übernimmt GebOS die laufende Datenerfassung und die gebuchten digitalen Dienstleistungen.",
},
]
const journey = [
"Besucher versteht GebOS",
"identifiziert seinen Anwendungsfall",
"berechnet einen ungefähren Preis",
"speichert seine Konfiguration",
"ergänzt die tatsächlichen Gebäudedaten",
"erhält den genauen Preis",
"erteilt den Auftrag",
"GebOS bereitet Gebäude und Geräte vor",
"vorkonfigurierte Hardware wird geliefert",
"Montage vor Ort",
"automatischer laufender Betrieb",
]
export default function HowItWorksPage() {
return (
<>
<div className="bg-hero-glow">
<Container>
<Section className="pt-6 pb-10 sm:pt-7 sm:pb-12 lg:pt-8 lg:pb-14">
<PageBreadcrumb
className="mb-4 justify-center"
items={[
{ label: "Startseite", to: "/" },
{ label: "So funktionierts" },
]}
/>
<SectionHeading
align="center"
title="So funktioniert GebOS"
lead="Von der Bestellung bis zum laufenden Betrieb. GebOS verbindet Bestellung, Konfiguration und Installation miteinander."
/>
<Card className="mt-10 gap-0 p-8 sm:mt-12 sm:p-10">
<ProcessSteps steps={steps} />
</Card>
<p className="mx-auto mt-10 max-w-xl text-center text-sm font-medium text-muted-foreground italic">
Bestellung, Konfiguration und Installation greifen ineinander.
</p>
</Section>
</Container>
</div>
<Container>
<Section>
<SectionHeading
align="center"
eyebrow="Gesamtprozess"
title="Der Gesamtprozess aus Kundensicht"
lead="Website, Konfigurator und operativer Rollout sind keine getrennten Systeme sie bauen aufeinander auf."
/>
<div className="mx-auto mt-10 flex max-w-4xl flex-wrap items-center justify-center gap-x-1.5 gap-y-3">
{journey.map((station, i) => (
<span key={station} className="flex items-center gap-1.5">
<Badge variant="outline" className="bg-card">
{station}
</Badge>
{i < journey.length - 1 ? (
<ChevronRight
aria-hidden="true"
className="size-4 shrink-0 text-brand-400"
/>
) : null}
</span>
))}
</div>
<div className="mx-auto mt-10 max-w-2xl text-center">
<p className="text-base font-bold text-navy sm:text-lg">
Der Konfigurator ist der Beginn des
GebOS-Bestell- und Rolloutprozesses.
</p>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
Die einmal eingegebenen Gebäudedaten werden nicht erneut erfasst,
sondern durch den gesamten Prozess weiterverwendet.
</p>
<p className="mt-6 font-mono text-xs tracking-wide text-muted-foreground uppercase">
Preisschätzung Konfiguration Bestellung Gerätevorbereitung
Installation Betrieb
</p>
</div>
</Section>
</Container>
<Container>
<Section className="pt-0">
<CtaBanner
icon={<Calculator />}
title="Was kostet GebOS für Ihr Gebäude?"
description="Mit wenigen Angaben erhalten Sie eine erste Preisindikation."
actions={
<Button variant="inverse" size="lg" asChild>
<Link to="/konfigurator">Preis berechnen</Link>
</Button>
}
/>
</Section>
</Container>
</>
)
}
@@ -0,0 +1,154 @@
import { ClipboardCheck, Euro, House, ShieldCheck, Tag, Users2, Wifi } from "lucide-react"
import { Separator } from "@/components/ui/separator"
import { Container, Section } from "@/components/gebos/section"
import { PageHero } from "@/components/gebos/page-hero"
import { PageBreadcrumb } from "@/components/gebos/page-breadcrumb"
import { HeroVisual } from "@/components/gebos/hero-visual"
import { IconBadge } from "@/components/gebos/icon-tile"
import { PriceCard } from "@/components/gebos/price-card"
import {
SensorCubesIllustration,
SmokeDetectorIllustration,
} from "@/components/gebos/illustrations"
const trustItems = [
{ icon: ClipboardCheck, label: ["Klare", "Vertragsstruktur"] },
{ icon: ShieldCheck, label: ["Keine versteckten", "Gebühren"] },
{ icon: Users2, label: ["Rabatte für", "große Objekte"] },
] as const
export default function KlarpreisPage() {
return (
<>
<PageHero
breadcrumb={
<PageBreadcrumb
items={[
{ label: "Startseite", to: "/" },
{ label: "GebOS Klarpreis" },
]}
/>
}
title={
<>
GebOS <span className="text-primary">Klarpreis</span>
</>
}
lead="Drei sauber getrennte Leistungen. Einfache Preise. Keine Überraschungen."
extra={
<div className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-4 py-2 text-sm text-muted-foreground shadow-pill">
<IconBadge variant="soft" size="sm">
<House />
</IconBadge>
<span>Ideal für Wohngebäude, Hausverwaltungen und Bestandshalter.</span>
</div>
}
visual={
<HeroVisual
media={<SmokeDetectorIllustration className="mx-auto w-48" />}
chipTitle="Inspektion OK"
chipSub="Letzte Prüfung 06.05.2024"
/>
}
/>
<Container>
<Section className="pt-0">
<div className="grid gap-6 lg:grid-cols-3">
<PriceCard
title="RWM-Hardware"
cadence="Einmalig"
price="42,00 €"
unit="je Wohnung einmalig"
allocation={{ label: "Nicht umlagefähig", tone: "negative" }}
description="Rauchwarnmelder inklusive Montage und Eigentumsübertragung."
media={<SmokeDetectorIllustration />}
/>
<PriceCard
title="Ferninspektion"
cadence="Monatlich"
price="2,20 €"
unit="je Wohnung / Monat"
allocation={{ label: "Grundsätzlich umlagefähig", tone: "positive" }}
description="Regelmäßige Prüfung, Nachweise, Meldungen und Support."
media={
<div className="relative">
<SmokeDetectorIllustration />
<Wifi className="absolute -right-1 top-1 size-5 text-primary" />
</div>
}
/>
<PriceCard
title={
<span className="text-[1.15rem] whitespace-nowrap">
UVI-Komplettbetrieb
</span>
}
cadence="Monatlich"
price="10,50 €"
unit="je Wohnung / Monat"
allocation={{ label: "Grundsätzlich umlagefähig", tone: "positive" }}
description="Sensoren, Daten, Portal und Verbrauchsinformation inklusive."
media={<SensorCubesIllustration />}
/>
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-[1.4fr_1fr]">
{/* total band */}
<div className="flex flex-wrap items-center gap-6 rounded-2xl bg-brand-gradient p-6 shadow-band sm:flex-nowrap sm:p-7">
<IconBadge variant="glass" size="xl">
<Euro />
</IconBadge>
<div>
<div className="font-semibold text-white/80">
Gesamtkosten laufend
</div>
<div className="mt-1 flex flex-wrap items-baseline gap-x-3">
<span className="text-4xl font-extrabold tracking-tight text-white">
12,70
</span>
<span className="text-sm text-white/75">
je Wohnung / Monat
</span>
</div>
</div>
<Separator
orientation="vertical"
className="hidden self-stretch bg-white/25 sm:block"
/>
<div className="flex items-center gap-3">
<IconBadge variant="glass" size="sm">
<Tag />
</IconBadge>
<p className="max-w-56 text-sm leading-snug text-white/80">
Alle laufenden Leistungen klar gebündelt ohne versteckte
Zusatzkosten.
</p>
</div>
</div>
{/* trust card */}
<div className="grid grid-cols-3 divide-x divide-border rounded-2xl bg-card p-6 shadow-card">
{trustItems.map(({ icon: Icon, label }) => (
<div
key={label[1]}
className="flex flex-col items-center justify-center gap-2.5 px-2 text-center"
>
<IconBadge variant="soft" size="lg" shape="circle">
<Icon />
</IconBadge>
<div className="text-sm leading-snug font-bold text-navy">
{label[0]}
<br />
{label[1]}
</div>
</div>
))}
</div>
</div>
</Section>
</Container>
</>
)
}
@@ -0,0 +1,39 @@
import type * as React from "react"
import { Container, Section, SectionHeading } from "@/components/gebos/section"
import {
PageBreadcrumb,
type Crumb,
} from "@/components/gebos/page-breadcrumb"
/**
* Minimal content page (Unternehmen / Kontakt) and stand-in while
* full pages are being built.
*/
function SimplePage({
title,
lead,
breadcrumb,
children,
}: {
title: React.ReactNode
lead?: React.ReactNode
breadcrumb?: Crumb[]
children?: React.ReactNode
}) {
return (
<div className="bg-hero-glow">
<Container>
<Section className={breadcrumb ? "pt-6 sm:pt-7 lg:pt-8" : undefined}>
{breadcrumb ? (
<PageBreadcrumb className="mb-4" items={breadcrumb} />
) : null}
<SectionHeading title={title} lead={lead} />
{children}
</Section>
</Container>
</div>
)
}
export { SimplePage }
@@ -0,0 +1,132 @@
import { Link } from "react-router-dom"
import { ArrowRight, Euro } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { CtaBanner } from "@/components/gebos/cta-banner"
import { Container, Section, SectionHeading } from "@/components/gebos/section"
import { PageBreadcrumb } from "@/components/gebos/page-breadcrumb"
import {
AssetRender,
renders,
vectors,
} from "@/components/gebos/site-assets"
const solutions = [
{
title: "UVI",
sub: "Monatliche Verbrauchsinformation",
description:
"Verbrauchsdaten werden automatisch erfasst und die regelmäßigen Verbrauchsinformationen für die Bewohner bereitgestellt.",
illustration: (
<div className="relative">
<AssetRender render={renders.gateway} imgClassName="w-28" />
<img
src={vectors.glassCubes}
alt=""
className="absolute -top-3 -right-8 w-14"
/>
</div>
),
},
{
title: "Heizkostenabrechnung",
sub: "Verbrauchsdaten korrekt abrechnen",
description:
"Verbrauchsdaten strukturiert erfassen und für die jährliche Abrechnung nutzen.",
illustration: <img src={vectors.consumptionRing} alt="" className="w-28" />,
},
{
title: "Rauchwarnmelder",
sub: "Sicher überwacht, zentral verwaltet",
description:
"Rauchwarnmelder gemeinsam mit der übrigen Gebäudetechnik planen und verwalten.",
illustration: <AssetRender render={renders.smokeAlarm} imgClassName="w-28" />,
},
]
export default function SolutionsPage() {
return (
<>
<div className="bg-hero-glow">
<Container>
<Section className="pt-6 pb-10 sm:pt-7 sm:pb-12 lg:pt-8 lg:pb-14">
<PageBreadcrumb
className="mb-4"
items={[
{ label: "Startseite", to: "/" },
{ label: "Lösungen" },
]}
/>
<SectionHeading
title="Unsere Lösungen"
lead="Drei sauber getrennte Leistungen. Perfekt zusammenspielend."
/>
<div className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{solutions.map((s) => (
<Card key={s.title} className="gap-0 p-6 sm:p-7">
<h3 className="text-xl font-extrabold tracking-tight text-navy">
{s.title}
</h3>
<p className="mt-1 text-sm text-muted-foreground">{s.sub}</p>
<div className="my-6 flex h-32 flex-1 items-center justify-center">
{s.illustration}
</div>
<p className="text-sm leading-relaxed text-muted-foreground">
{s.description}
</p>
<Button
variant="link"
className="mt-3 self-start px-0"
asChild
>
<Link to="/loesungen">
Mehr erfahren
<ArrowRight />
</Link>
</Button>
</Card>
))}
</div>
<Card className="mt-6 flex-col items-start gap-4 p-5 sm:flex-row sm:items-center sm:p-6">
<AssetRender
render={renders.gateway}
className="shrink-0"
imgClassName="w-14"
/>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-bold text-navy">
Messtechnik und Infrastruktur
</h3>
<p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
Fernauslesbare Zähler, Sensoren und
Kommunikationsinfrastruktur für den laufenden Betrieb.
</p>
</div>
<Button variant="link" className="shrink-0 px-0 sm:px-4" asChild>
<Link to="/loesungen">
Mehr erfahren
<ArrowRight />
</Link>
</Button>
</Card>
</Section>
</Container>
</div>
<Container>
<Section className="pt-0">
<CtaBanner
icon={<Euro />}
title="Jetzt Preis für Ihr Gebäude berechnen"
description="In wenigen Minuten zur ersten Preisindikation."
actions={
<Button variant="inverse" size="lg" asChild>
<Link to="/konfigurator">Preis berechnen</Link>
</Button>
}
/>
</Section>
</Container>
</>
)
}
+169
View File
@@ -0,0 +1,169 @@
@import "tailwindcss";
@import "tw-animate-css";
/* ------------------------------------------------------------------ */
/* GebOS design tokens */
/* Sampled from the GebOS Klarpreis + landing page designs: */
/* teal brand, deep navy type, cool off-white surfaces, */
/* soft shadows, generous radii, pill badges, gradient CTA bands. */
/* ------------------------------------------------------------------ */
:root {
/* surfaces */
--background: #f4f8f9;
--foreground: #071a3f;
--card: #ffffff;
--card-foreground: #071a3f;
--popover: #ffffff;
--popover-foreground: #071a3f;
/* brand */
--primary: #009b9a;
--primary-foreground: #ffffff;
--secondary: #eaf3f4;
--secondary-foreground: #071a3f;
--muted: #edf3f4;
--muted-foreground: #516078;
--accent: #e0f4f0;
--accent-foreground: #006c79;
/* feedback */
--destructive: #dc3439;
--destructive-foreground: #ffffff;
/* lines & focus */
--border: #e2eceb;
--input: #d7e3e4;
--ring: #009b9a;
/* charts (donut / bars in visuals) */
--chart-1: #009b9a;
--chart-2: #34e0d4;
--chart-3: #cdeee8;
--chart-4: #071a3f;
--chart-5: #94a9b8;
/* brand scale */
--brand-50: #eefaf7;
--brand-100: #d8f2ec;
--brand-200: #b5ece6;
--brand-300: #7ce0d6;
--brand-400: #34e0d4;
--brand-500: #03aaa4;
--brand-600: #009b9a;
--brand-700: #007d89;
--brand-800: #006c79;
--brand-900: #06474e;
--navy: #071a3f;
--radius: 0.75rem;
}
@theme inline {
--font-sans:
"Inter Variable", "Inter", ui-sans-serif, system-ui, -apple-system,
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-brand-50: var(--brand-50);
--color-brand-100: var(--brand-100);
--color-brand-200: var(--brand-200);
--color-brand-300: var(--brand-300);
--color-brand-400: var(--brand-400);
--color-brand-500: var(--brand-500);
--color-brand-600: var(--brand-600);
--color-brand-700: var(--brand-700);
--color-brand-800: var(--brand-800);
--color-brand-900: var(--brand-900);
--color-navy: var(--navy);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
/* soft layered card shadows from the designs */
--shadow-card: 0 1px 2px rgb(19 41 60 / 0.04), 0 8px 24px rgb(19 41 60 / 0.06);
--shadow-card-lg:
0 2px 4px rgb(19 41 60 / 0.04), 0 16px 48px rgb(19 41 60 / 0.1);
--shadow-band: 0 16px 40px rgb(10 100 89 / 0.28);
--shadow-pill: 0 1px 2px rgb(19 41 60 / 0.06);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-background text-foreground font-sans antialiased;
font-feature-settings:
"liga" 1,
"calt" 1,
"cv05" 1;
}
::selection {
@apply bg-brand-200 text-navy;
}
}
/* teal gradient band used for CTA banners and total-cost bar */
@utility bg-brand-gradient {
background-image: linear-gradient(
105deg,
var(--brand-800) 0%,
var(--brand-700) 35%,
var(--brand-600) 70%,
var(--brand-500) 100%
);
}
/* radial page glow behind heroes (subtle white vignette on cool bg) */
@utility bg-hero-glow {
background-image:
radial-gradient(
60rem 32rem at 85% -10%,
rgb(15 155 140 / 0.09),
transparent 60%
),
radial-gradient(
50rem 28rem at 10% 0%,
rgb(255 255 255 / 0.9),
transparent 65%
);
}
/* dotted connector line used by process steppers */
@utility border-dotted-brand {
border-color: var(--brand-300);
border-style: dotted;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />