This commit is contained in:
Lars Nolden
2026-08-13 18:47:55 +02:00
parent 2c1a8ae9b5
commit 33690b30a3
30 changed files with 1070 additions and 186 deletions
@@ -0,0 +1,56 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Card } from "@/components/ui/card"
type BenefitGridItem = {
icon: React.ReactNode
title: React.ReactNode
text: React.ReactNode
}
/**
* Benefit cards ringing a product render: three across the top row, two
* flanking the render below. Built for five items — the render claims the
* bottom row's middle cell from `lg`, and grid auto-placement then pushes the
* fifth card past it into the last column, so no card needs explicit coords.
*
* The middle cell is handed to the caller as a positioned, never-clipped
* containing block, so the visual can fill it or lap out of it as its own
* matte requires — sizing it is the caller's job. Below `lg` the placement is
* dropped and the visual simply trails the cards.
*/
function BenefitGrid({
items,
visual,
className,
}: {
items: BenefitGridItem[]
visual: React.ReactNode
className?: string
}) {
return (
<div
className={cn(
"grid gap-5 sm:grid-cols-2 lg:grid-cols-[1fr_1.2fr_1fr]",
className
)}
>
{items.map((item, i) => (
<Card key={i} className="gap-0 p-6 sm:p-7">
<span className="text-brand-700 [&_svg]:size-10">{item.icon}</span>
<h3 className="mt-6 text-base leading-snug font-bold text-balance text-navy">
{item.title}
</h3>
<p className="mt-3 text-sm leading-relaxed text-pretty text-muted-foreground">
{item.text}
</p>
</Card>
))}
<div className="relative lg:col-start-2 lg:row-start-2">{visual}</div>
</div>
)
}
export { BenefitGrid }
export type { BenefitGridItem }
@@ -0,0 +1,207 @@
import * as React from "react"
import { CircleCheck } from "lucide-react"
import { cn } from "@/lib/utils"
import { Card } from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
type ComparisonColumn = {
title: React.ReactNode
/** lifts the column onto a tinted panel and fills in its checks */
highlight?: boolean
/** check beside every value in this column, cell overrides win (default true) */
check?: boolean
}
/** a cell that opts out of — or into — the check its column prescribes */
type ComparisonCell = {
value?: React.ReactNode
check?: boolean
}
type ComparisonRow = {
label: React.ReactNode
/** one entry per column, in column order */
cells: (React.ReactNode | ComparisonCell)[]
}
/** hairline between rows, painted per cell (see `ComparisonTable`) */
const HAIRLINE = "border-b border-b-border/70"
/** flanks of the highlighted column's panel */
const PANEL = "border-x border-x-brand-200 bg-brand-50/70"
/** `{ value?, check? }` is a cell spec, anything else is renderable content */
function isCellSpec(
cell: React.ReactNode | ComparisonCell
): cell is ComparisonCell {
return (
typeof cell === "object" &&
cell !== null &&
!Array.isArray(cell) &&
!React.isValidElement(cell)
)
}
/** solid teal badge for the highlighted column, thin mint ring for the rest */
function CheckMark({ solid }: { solid?: boolean }) {
return (
<CircleCheck
className={cn(
"size-5",
solid
? "[&>circle]:fill-primary [&>circle]:stroke-primary [&>path]:stroke-primary-foreground [&>path]:stroke-[2.5]"
: "text-brand-300"
)}
/>
)
}
/**
* Value over the check gutter. A checked column reserves the gutter in every
* cell — head included — so its text keeps one centre axis whether or not a
* given row carries a check.
*/
function ValueSlot({
children,
gutter,
check,
}: {
children: React.ReactNode
gutter: boolean
check?: React.ReactNode
}) {
return (
<div className="flex items-center gap-3">
<span className="flex-1 text-pretty">{children}</span>
{gutter ? (
<span aria-hidden className="size-5 shrink-0">
{check}
</span>
) : null}
</div>
)
}
/**
* Variant comparison ("Welche Variante passt zu mir?"): row labels down the
* left, one column per variant, every value trailed by a check. `highlight`
* lifts the recommended column onto a tinted panel running from the head to
* the last row and turns its checks solid.
*
* That panel is why the table is `border-separate`: only cells can carry the
* rounded corners a `<col>` cannot — so the row hairlines are painted per
* cell, since separated borders drop the ones on `<tr>`.
*/
function ComparisonTable({
columns,
rows,
labelHead,
labelWidth = "26%",
className,
}: {
columns: ComparisonColumn[]
rows: ComparisonRow[]
/** content for the otherwise empty top-left cell */
labelHead?: React.ReactNode
/** width of the label column, the rest split the remainder evenly */
labelWidth?: string
className?: string
}) {
const cells = rows.map((row) =>
columns.map((_, c) => {
const cell = row.cells[c]
return isCellSpec(cell) ? cell : { value: cell }
})
)
/* one gutter decision per column, so heads and values agree */
const gutters = columns.map((column, c) =>
cells.some((row) => row[c].check ?? column.check ?? true)
)
return (
<Card className={cn("gap-0 p-2 sm:p-4", className)}>
<Table className="min-w-xl table-fixed border-separate border-spacing-0">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead
className={cn("h-14 px-4 whitespace-normal", HAIRLINE)}
style={{ width: labelWidth }}
>
{labelHead}
</TableHead>
{columns.map((column, c) => (
<TableHead
key={c}
className={cn(
"h-14 px-4 text-center text-base whitespace-normal",
HAIRLINE,
column.highlight &&
"rounded-t-2xl border-t border-t-brand-200 text-primary",
column.highlight && PANEL
)}
>
<ValueSlot gutter={gutters[c]}>{column.title}</ValueSlot>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, r) => {
const last = r === rows.length - 1
return (
<TableRow key={r} className="hover:bg-transparent">
<TableCell
className={cn(
"px-4 py-5 font-bold text-navy",
!last && HAIRLINE
)}
>
{row.label}
</TableCell>
{columns.map((column, c) => {
const cell = cells[r][c]
return (
<TableCell
key={c}
className={cn(
"px-4 py-5 text-center",
!last && HAIRLINE,
column.highlight && PANEL,
column.highlight &&
last &&
"rounded-b-2xl border-b border-b-brand-200"
)}
>
<ValueSlot
gutter={gutters[c]}
check={
(cell.check ?? column.check ?? true) ? (
<CheckMark solid={column.highlight} />
) : null
}
>
{cell.value}
</ValueSlot>
</TableCell>
)
})}
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
)
}
export { ComparisonTable }
export type { ComparisonCell, ComparisonColumn, ComparisonRow }
@@ -0,0 +1,60 @@
import * as React from "react"
import { cn } from "@/lib/utils"
type FeatureBandItem = {
icon?: React.ReactNode
title: React.ReactNode
sub?: React.ReactNode
}
/**
* Feature row as one white card: hairline-divided columns (2 up on mobile,
* 4 from `sm`), each an oversized teal line icon over a navy title and a
* muted sub. Dividers are inset because the card pads around the columns.
*/
function FeatureBand({
items,
className,
}: {
items: FeatureBandItem[]
className?: string
}) {
return (
<div
className={cn(
"grid grid-cols-2 rounded-3xl bg-card p-4 shadow-card sm:grid-cols-4",
className
)}
>
{items.map((item, i) => (
<div
key={i}
className={cn(
"flex flex-col items-center px-4 py-5 text-center sm:px-6",
/* dividers between columns and rows of the current layout only */
i % 2 !== 0 && "border-l",
i >= 2 && "border-t",
i % 4 !== 0 ? "sm:border-l" : "sm:border-l-0",
i >= 4 ? "sm:border-t" : "sm:border-t-0"
)}
>
{item.icon ? (
<span className="text-brand-700 [&_svg]:size-12">{item.icon}</span>
) : null}
<div className="mt-5 text-[0.9375rem] leading-snug font-bold text-balance text-navy">
{item.title}
</div>
{item.sub ? (
<p className="mt-2 max-w-[15rem] text-[0.8125rem] leading-relaxed text-balance text-muted-foreground">
{item.sub}
</p>
) : null}
</div>
))}
</div>
)
}
export { FeatureBand }
export type { FeatureBandItem }
@@ -0,0 +1,125 @@
import * as React from "react"
/**
* GebOS line icons drawn on the lucide grid (24×24, currentColor stroke) for
* the motifs lucide has no equivalent of. Stroke is a hair lighter than
* lucide's 2 because these render large (≈48px) in feature bands.
*/
function LineIcon({
strokeWidth = 1.6,
children,
...props
}: React.ComponentProps<"svg">) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
{children}
</svg>
)
}
/**
* Euro glyph inside a ring: costs stay transparent and under control.
* lucide has the bare glyph only, so it is shrunk about the icon centre and
* its stroke width divided by the same factor to keep the ring's weight.
*/
function EuroCircleIcon({
strokeWidth = 1.6,
...props
}: React.ComponentProps<"svg">) {
const glyphScale = 0.52
return (
<LineIcon strokeWidth={strokeWidth} {...props}>
<circle cx="12" cy="12" r="9.6" />
<g
transform={`translate(12 12) scale(${glyphScale}) translate(-12 -12)`}
strokeWidth={Number(strokeWidth) / glyphScale}
>
<path d="M4 10h12" />
<path d="M4 14h9" />
<path d="M19 6a7.7 7.7 0 0 0-5.2-2A7.9 7.9 0 0 0 6 12c0 4.4 3.5 8 7.8 8 2 0 3.8-.8 5.2-2" />
</g>
</LineIcon>
)
}
/** Cycle arrows around a radio dot: hardware that arrives ready to run. */
function PreconfiguredIcon(props: React.ComponentProps<"svg">) {
return (
<LineIcon {...props}>
<path d="M3.2 10.13A9 9 0 0 1 20.8 10.13" />
<path d="M22.11 7.66 20.8 10.13 18.59 8.41" />
<path d="M20.8 13.87A9 9 0 0 1 3.2 13.87" />
<path d="M1.89 16.34 3.2 13.87 5.41 15.59" />
<circle cx="10" cy="12" r="1.2" fill="currentColor" stroke="none" />
<path d="M11.93 9.7A3 3 0 0 1 11.93 14.3" />
<path d="M13.41 7.94A5.3 5.3 0 0 1 13.41 16.06" />
</LineIcon>
)
}
/**
* Wrench crossed with a screwdriver: install it yourself or hand it to a
* trade. Wrench contour is lucide's, mirrored onto the "\" diagonal so the
* jaw sits top-left; the screwdriver is drawn along +x and rotated onto "/",
* nudged clear of the jaw and filled with the card surface so it reads as
* lying on top of the wrench.
*/
function CrossedToolsIcon(props: React.ComponentProps<"svg">) {
return (
<LineIcon {...props}>
<path
transform="translate(24 0) scale(-1 1)"
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"
/>
<path
transform="rotate(-45 12 12) translate(-0.6 1.2)"
fill="var(--card)"
d="M5.7 9.9H10.4V10.5H11.8V11.25H17.6L18.3 10.95H20.4V13.05H18.3L17.6 12.75H11.8V13.5H10.4V14.1H5.7A2.1 2.1 0 0 1 5.7 9.9Z"
/>
</LineIcon>
)
}
/** Cloud with a bolt: metering data flows and is processed without anyone. */
function CloudAutomationIcon(props: React.ComponentProps<"svg">) {
return (
<LineIcon {...props}>
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
<path d="M13.1 10.7 10.5 14.6h1.85l-.68 2.6 2.6-3.9h-1.85z" />
</LineIcon>
)
}
/** Dashboard gauge on a screen: every building and meter in one portal. */
function PortalGaugeIcon(props: React.ComponentProps<"svg">) {
return (
<LineIcon {...props}>
<rect x="4.6" y="4" width="14.8" height="13.8" rx="2.4" />
<path d="M3 20h18" />
<path d="M8.02 13.18A4 4 0 0 1 15.98 13.18" />
<path d="M12 13.6 13.89 10.9" />
<circle cx="12" cy="13.6" r="0.8" fill="currentColor" stroke="none" />
</LineIcon>
)
}
export {
CloudAutomationIcon,
CrossedToolsIcon,
EuroCircleIcon,
LineIcon,
PortalGaugeIcon,
PreconfiguredIcon,
}
@@ -0,0 +1,48 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Card } from "@/components/ui/card"
/**
* One of two mutually exclusive delivery routes (Selbst installieren /
* Komplettlösung): centred copy over a bold takeaway, the 3D scene below,
* CTA pinned to the card foot so side-by-side cards line their buttons up.
*/
function OptionCard({
title,
lead,
takeaway,
visual,
action,
className,
}: {
title: React.ReactNode
lead: React.ReactNode
/** bold navy claim that closes the copy block */
takeaway?: React.ReactNode
visual: React.ReactNode
action: React.ReactNode
className?: string
}) {
return (
<Card className={cn("items-center gap-0 p-6 text-center sm:p-8", className)}>
<h3 className="text-xl font-extrabold tracking-tight text-balance text-navy sm:text-2xl">
{title}
</h3>
<p className="mt-3 max-w-sm text-sm leading-relaxed text-pretty text-muted-foreground">
{lead}
</p>
{takeaway ? (
<p className="mt-4 max-w-sm text-sm leading-relaxed font-bold text-balance text-navy">
{takeaway}
</p>
) : null}
<div className="mt-7 flex w-full flex-1 items-end justify-center">
{visual}
</div>
<div className="mt-7 flex w-full justify-center">{action}</div>
</Card>
)
}
export { OptionCard }
@@ -94,4 +94,70 @@ function NumberedList({
)
}
export { ProcessSteps, NumberedList }
/**
* Step rail on a card (Selbstinstallation, steps 15): numbered rings threaded
* on a solid teal rail, hairline-divided columns beneath, each an asset render
* over title and copy. Each column draws its own half of the rail, so the run
* ends at the first and last ring whatever the step count. Rail and dividers
* appear only once the columns sit in one row.
*/
function StepRail({
steps,
className,
}: {
steps: ProcessStep[]
className?: string
}) {
return (
<ol
className={cn(
"grid grid-cols-1 gap-y-10 rounded-3xl bg-card p-5 shadow-card sm:grid-cols-2 sm:p-7 lg:gap-y-0",
steps.length >= 5 ? "lg:grid-cols-5" : "lg:grid-cols-4",
className
)}
>
{steps.map((step, i) => (
<li key={i} className="relative flex flex-col items-center">
<span
aria-hidden="true"
/* rail sits on the ring's centre line (size-9 ring → 18px) */
className={cn(
"absolute top-[17px] hidden h-0.5 bg-brand-300 lg:block",
i === 0 ? "left-1/2" : "left-0",
i === steps.length - 1 ? "right-1/2" : "right-0"
)}
/>
<span className="z-10 flex size-9 items-center justify-center rounded-full bg-card text-sm font-bold text-navy ring-2 ring-brand-300">
{i + 1}
</span>
<div
className={cn(
"mt-5 flex w-full flex-1 flex-col items-center px-2 text-center",
/* dividers start below the rail, between side-by-side columns */
i % 2 === 1 && "sm:border-l",
i > 0 && "lg:border-l"
)}
>
<div className="flex h-28 items-end justify-center gap-1">
{step.media ?? (
<IconBadge variant="outline" shape="squircle" size="xl">
{step.icon}
</IconBadge>
)}
</div>
<h3 className="mt-4 text-sm font-bold text-balance text-navy">
{step.title}
</h3>
{step.description ? (
<p className="mt-2 max-w-[15rem] text-xs leading-relaxed text-balance text-muted-foreground">
{step.description}
</p>
) : null}
</div>
</li>
))}
</ol>
)
}
export { ProcessSteps, NumberedList, StepRail }
@@ -22,6 +22,8 @@ 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 fullHeroSectionAvif from "@/assets/gebos/full-hero-section.avif"
import fullHeroSectionWebp from "@/assets/gebos/full-hero-section.webp"
import gatewayAvif from "@/assets/gebos/gateway.avif"
import gatewayWebp from "@/assets/gebos/gateway.webp"
import glassCubes from "@/assets/gebos/glass-cubes.svg"
@@ -29,6 +31,8 @@ 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 houseSmallIsoAvif from "@/assets/gebos/house-small-isometric.avif"
import houseSmallIsoWebp from "@/assets/gebos/house-small-isometric.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"
@@ -36,7 +40,13 @@ 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 laptopPortfolioAvif from "@/assets/gebos/laptop-portfolio.avif"
import laptopPortfolioWebp from "@/assets/gebos/laptop-portfolio.webp"
import packageBox from "@/assets/gebos/package-box.svg"
import refreshLoopCloudAvif from "@/assets/gebos/refresh-loop-cloud.avif"
import refreshLoopCloudWebp from "@/assets/gebos/refresh-loop-cloud.webp"
import refreshLoopAvif from "@/assets/gebos/refresh-loop.avif"
import refreshLoopWebp from "@/assets/gebos/refresh-loop.webp"
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"
@@ -44,12 +54,18 @@ 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 technicianPipeAvif from "@/assets/gebos/technician-pipe.avif"
import technicianPipeWebp from "@/assets/gebos/technician-pipe.webp"
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 workerDrillingGateway2Avif from "@/assets/gebos/worker-drilling-gateway-2.avif"
import workerDrillingGateway2Webp from "@/assets/gebos/worker-drilling-gateway-2.webp"
import workerDrillingGatewayAvif from "@/assets/gebos/worker-drilling-gateway.avif"
import workerDrillingGatewayWebp from "@/assets/gebos/worker-drilling-gateway.webp"
import workerTabletAvif from "@/assets/gebos/worker-tablet.avif"
import workerTabletWebp from "@/assets/gebos/worker-tablet.webp"
@@ -112,6 +128,24 @@ const renders = {
alt: "Objektbezogen vorbereitete Hardware",
matte: "white",
},
technicianPipe: {
avif: technicianPipeAvif,
webp: technicianPipeWebp,
alt: "Techniker prüft Rohrleitung mit Messgerät",
matte: "white",
},
workerDrillingGateway: {
avif: workerDrillingGatewayAvif,
webp: workerDrillingGatewayWebp,
alt: "Monteur montiert GebOS Gateway an der Wand",
matte: "white",
},
workerDrillingGateway2: {
avif: workerDrillingGateway2Avif,
webp: workerDrillingGateway2Webp,
alt: "Monteur montiert GebOS Gateway an Trockenbauwand",
matte: "white",
},
/* --- transparent renders (free placement, any background) ---------- */
/** same subject as `building`, but with genuine alpha safe on any surface */
@@ -128,6 +162,27 @@ const renders = {
alt: "Modernes Mehrfamilienhaus",
matte: "alpha",
},
/** compact single-family house, isometric, genuine alpha */
houseSmallIso: {
avif: houseSmallIsoAvif,
webp: houseSmallIsoWebp,
alt: "Einfamilienhaus, isometrisch",
matte: "alpha",
},
/** full hero scene: building, gateway, consumption bars & ring on a glass platform */
fullHeroSection: {
avif: fullHeroSectionAvif,
webp: fullHeroSectionWebp,
alt: "GebOS Systemübersicht: Gebäude, Gateway und Verbrauchsdaten",
matte: "alpha",
},
/** same laptop mockup as `laptopDashboard`, portfolio/room screen, genuine alpha */
laptopPortfolio: {
avif: laptopPortfolioAvif,
webp: laptopPortfolioWebp,
alt: "GebOS Portfolio-Ansicht auf dem Laptop",
matte: "alpha",
},
brandMark3d: {
avif: brandMark3dAvif,
webp: brandMark3dWebp,
@@ -176,6 +231,18 @@ const renders = {
alt: "Vorkonfigurierte Lieferung",
matte: "alpha",
},
refreshLoop: {
avif: refreshLoopAvif,
webp: refreshLoopWebp,
alt: "",
matte: "alpha",
},
refreshLoopCloud: {
avif: refreshLoopCloudAvif,
webp: refreshLoopCloudWebp,
alt: "",
matte: "alpha",
},
/** baked light glow use on white/near-white surfaces */
wordmarkGlow: {
avif: wordmarkGlowAvif,
@@ -216,8 +283,10 @@ const icons = {
/**
* 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.
* mix-blend-multiply so their matte disappears on light surfaces, plus a
* hairline brightness lift: the masters matte out at 252254, which multiply
* would otherwise leave as a visible 1 % box on a white card. Transparent
* assets composite normally.
*/
function AssetRender({
render,
@@ -242,7 +311,7 @@ function AssetRender({
loading={loading}
className={cn(
"object-contain",
render.matte === "white" && "mix-blend-multiply",
render.matte === "white" && "mix-blend-multiply brightness-[1.015]",
imgClassName
)}
/>
@@ -34,42 +34,44 @@ const COLUMNS: { title: string; links: { to: string; label: string }[] }[] = [
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>
<div className="mt-20 px-3 pb-3 sm:px-5 sm:pb-5">
<footer className="glass-navy inset-shadow-glass-slab shadow-glass-slab rounded-3xl 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>
</footer>
<div className="border-t border-white/10">
<Container className="flex flex-wrap items-center justify-between gap-3 py-5 text-xs text-white/60">
<span>© 2026 GebOS. Alle Rechte vorbehalten.</span>
<span>Impressum · Datenschutz · AGB</span>
</Container>
</div>
</footer>
</div>
)
}
+21 -10
View File
@@ -4,22 +4,33 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
/**
* Glass physics shared by the filled variants: the backdrop is blurred and
* saturated the way the cube renders bend what is behind them, the lit rim
* and the caustic ride the inset-shadow slot, the cast bloom the outer one.
* Tint and edge colour come from the `glass-*` utilities in globals.css.
*/
const glass =
"backdrop-blur-md backdrop-saturate-150 inset-shadow-glass shadow-glass hover:shadow-glass-lg"
const glassOnHover =
"hover:backdrop-blur-md hover:backdrop-saturate-150 hover:inset-shadow-glass hover:shadow-glass"
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",
default: `${glass} glass-teal text-primary-foreground hover:glass-lit`,
outline: `${glass} glass-clear text-foreground hover:glass-lit`,
secondary: `${glass} glass-mint text-secondary-foreground hover:glass-lit`,
ghost: `${glassOnHover} text-foreground hover:glass-clear`,
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",
/* frosted white button sitting on the teal gradient band */
inverse: `${glass} glass-frost text-brand-800 hover:glass-lit`,
/* its quieter neighbour: the band still reads through the glass */
inverseOutline: `${glass} glass-veil text-white hover:glass-lit`,
destructive: `${glass} glass-danger text-destructive-foreground hover:glass-lit`,
},
size: {
default: "h-10 px-5 py-2",