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 }
@@ -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 }