This commit is contained in:
Lars Nolden
2026-08-13 15:46:04 +02:00
parent 920b1da4b0
commit 2c1a8ae9b5
178 changed files with 10289 additions and 0 deletions
@@ -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 }