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
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

@@ -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 drillToolAvif from "@/assets/gebos/drill-tool.avif"
import drillToolWebp from "@/assets/gebos/drill-tool.webp" import drillToolWebp from "@/assets/gebos/drill-tool.webp"
import euroOrb from "@/assets/gebos/euro-orb.svg" 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 gatewayAvif from "@/assets/gebos/gateway.avif"
import gatewayWebp from "@/assets/gebos/gateway.webp" import gatewayWebp from "@/assets/gebos/gateway.webp"
import glassCubes from "@/assets/gebos/glass-cubes.svg" 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 glassCubesStackWebp from "@/assets/gebos/glass-cubes-stack.webp"
import heatMeterAvif from "@/assets/gebos/heat-meter.avif" import heatMeterAvif from "@/assets/gebos/heat-meter.avif"
import heatMeterWebp from "@/assets/gebos/heat-meter.webp" 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 iconBilling from "@/assets/gebos/icon-billing.svg"
import iconPortal from "@/assets/gebos/icon-portal.svg" import iconPortal from "@/assets/gebos/icon-portal.svg"
import iconRadio from "@/assets/gebos/icon-radio.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 iconUvi from "@/assets/gebos/icon-uvi.svg"
import laptopAvif from "@/assets/gebos/laptop-dashboard.avif" import laptopAvif from "@/assets/gebos/laptop-dashboard.avif"
import laptopWebp from "@/assets/gebos/laptop-dashboard.webp" 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 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 shippingBoxClosedAvif from "@/assets/gebos/shipping-box-closed.avif"
import shippingBoxClosedWebp from "@/assets/gebos/shipping-box-closed.webp" import shippingBoxClosedWebp from "@/assets/gebos/shipping-box-closed.webp"
import shippingBoxMetersAvif from "@/assets/gebos/shipping-box-meters.avif" 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 smokeAlarmAvif from "@/assets/gebos/smoke-alarm.avif"
import smokeAlarmWebp from "@/assets/gebos/smoke-alarm.webp" import smokeAlarmWebp from "@/assets/gebos/smoke-alarm.webp"
import tealBars from "@/assets/gebos/teal-bars.svg" 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 uviBadge from "@/assets/gebos/uvi-badge.svg"
import wirelessOrb from "@/assets/gebos/wireless-orb.svg" import wirelessOrb from "@/assets/gebos/wireless-orb.svg"
import wordmarkDarkAvif from "@/assets/gebos/wordmark-cubes-dark.avif" import wordmarkDarkAvif from "@/assets/gebos/wordmark-cubes-dark.avif"
import wordmarkDarkWebp from "@/assets/gebos/wordmark-cubes-dark.webp" import wordmarkDarkWebp from "@/assets/gebos/wordmark-cubes-dark.webp"
import wordmarkGlowAvif from "@/assets/gebos/wordmark-cubes-glow.avif" import wordmarkGlowAvif from "@/assets/gebos/wordmark-cubes-glow.avif"
import wordmarkGlowWebp from "@/assets/gebos/wordmark-cubes-glow.webp" 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 workerTabletAvif from "@/assets/gebos/worker-tablet.avif"
import workerTabletWebp from "@/assets/gebos/worker-tablet.webp" import workerTabletWebp from "@/assets/gebos/worker-tablet.webp"
@@ -112,6 +128,24 @@ const renders = {
alt: "Objektbezogen vorbereitete Hardware", alt: "Objektbezogen vorbereitete Hardware",
matte: "white", 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) ---------- */ /* --- transparent renders (free placement, any background) ---------- */
/** same subject as `building`, but with genuine alpha safe on any surface */ /** same subject as `building`, but with genuine alpha safe on any surface */
@@ -128,6 +162,27 @@ const renders = {
alt: "Modernes Mehrfamilienhaus", alt: "Modernes Mehrfamilienhaus",
matte: "alpha", 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: { brandMark3d: {
avif: brandMark3dAvif, avif: brandMark3dAvif,
webp: brandMark3dWebp, webp: brandMark3dWebp,
@@ -176,6 +231,18 @@ const renders = {
alt: "Vorkonfigurierte Lieferung", alt: "Vorkonfigurierte Lieferung",
matte: "alpha", 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 */ /** baked light glow use on white/near-white surfaces */
wordmarkGlow: { wordmarkGlow: {
avif: wordmarkGlowAvif, avif: wordmarkGlowAvif,
@@ -216,8 +283,10 @@ const icons = {
/** /**
* Renders a kit asset as AVIF→WebP <picture>. White-matte masters get * Renders a kit asset as AVIF→WebP <picture>. White-matte masters get
* mix-blend-multiply so their matte disappears on light surfaces; * mix-blend-multiply so their matte disappears on light surfaces, plus a
* transparent assets composite normally. * 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({ function AssetRender({
render, render,
@@ -242,7 +311,7 @@ function AssetRender({
loading={loading} loading={loading}
className={cn( className={cn(
"object-contain", "object-contain",
render.matte === "white" && "mix-blend-multiply", render.matte === "white" && "mix-blend-multiply brightness-[1.015]",
imgClassName imgClassName
)} )}
/> />
@@ -34,7 +34,8 @@ const COLUMNS: { title: string; links: { to: string; label: string }[] }[] = [
function SiteFooter() { function SiteFooter() {
return ( return (
<footer className="mt-20 bg-navy text-white"> <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]"> <Container className="grid gap-10 py-14 sm:grid-cols-2 lg:grid-cols-[1.2fr_1fr_1fr_1fr]">
<div> <div>
<Logo inverse /> <Logo inverse />
@@ -64,12 +65,13 @@ function SiteFooter() {
))} ))}
</Container> </Container>
<div className="border-t border-white/10"> <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"> <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>© 2026 GebOS. Alle Rechte vorbehalten.</span>
<span>Impressum · Datenschutz · AGB</span> <span>Impressum · Datenschutz · AGB</span>
</Container> </Container>
</div> </div>
</footer> </footer>
</div>
) )
} }
+21 -10
View File
@@ -4,22 +4,33 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" 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( 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", "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: { variants: {
variant: { variant: {
default: default: `${glass} glass-teal text-primary-foreground hover:glass-lit`,
"bg-primary text-primary-foreground shadow-sm hover:bg-brand-700", outline: `${glass} glass-clear text-foreground hover:glass-lit`,
outline: secondary: `${glass} glass-mint text-secondary-foreground hover:glass-lit`,
"border border-border bg-card text-foreground shadow-pill hover:border-brand-200 hover:bg-brand-50", ghost: `${glassOnHover} text-foreground hover:glass-clear`,
secondary: "bg-secondary text-secondary-foreground hover:bg-brand-100",
ghost: "text-foreground hover:bg-secondary",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
/* white button sitting on the teal gradient band */ /* frosted white button sitting on the teal gradient band */
inverse: "bg-white text-brand-800 shadow-sm hover:bg-brand-50", inverse: `${glass} glass-frost text-brand-800 hover:glass-lit`,
destructive: /* its quieter neighbour: the band still reads through the glass */
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", inverseOutline: `${glass} glass-veil text-white hover:glass-lit`,
destructive: `${glass} glass-danger text-destructive-foreground hover:glass-lit`,
}, },
size: { size: {
default: "h-10 px-5 py-2", default: "h-10 px-5 py-2",
@@ -3,6 +3,7 @@ import * as React from "react"
import { Container, Section, SectionHeading } from "@/components/gebos/section" import { Container, Section, SectionHeading } from "@/components/gebos/section"
import { PageBreadcrumb } from "@/components/gebos/page-breadcrumb" import { PageBreadcrumb } from "@/components/gebos/page-breadcrumb"
import { PageHero } from "@/components/gebos/page-hero" import { PageHero } from "@/components/gebos/page-hero"
import { FeatureBand } from "@/components/gebos/feature-band"
import { FeatureTile } from "@/components/gebos/icon-tile" import { FeatureTile } from "@/components/gebos/icon-tile"
type AudienceFeature = { type AudienceFeature = {
@@ -34,6 +35,7 @@ function AudiencePage({
actions, actions,
visual, visual,
features, features,
featureLayout = "tiles",
children, children,
closing, closing,
}: { }: {
@@ -45,6 +47,11 @@ function AudiencePage({
actions?: React.ReactNode actions?: React.ReactNode
visual?: React.ReactNode visual?: React.ReactNode
features: AudienceFeature[] features: AudienceFeature[]
/**
* `tiles` — bare icon-over-label tiles on the page background.
* `band` — one divided white card (larger line icons, two-line subs).
*/
featureLayout?: "tiles" | "band"
/** additional full-width spec sections between feature row and closing */ /** additional full-width spec sections between feature row and closing */
children?: React.ReactNode children?: React.ReactNode
closing?: React.ReactNode closing?: React.ReactNode
@@ -69,6 +76,9 @@ function AudiencePage({
/> />
<Container> <Container>
<Section className="pt-2 sm:pt-4 lg:pt-6"> <Section className="pt-2 sm:pt-4 lg:pt-6">
{featureLayout === "band" ? (
<FeatureBand items={features} />
) : (
<div className="grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-4">
{features.map((feature, i) => ( {features.map((feature, i) => (
<FeatureTile <FeatureTile
@@ -80,6 +90,7 @@ function AudiencePage({
/> />
))} ))}
</div> </div>
)}
</Section> </Section>
{children} {children}
{closing} {closing}
@@ -1,19 +1,27 @@
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { Euro, Handshake, ReceiptText, ShieldCheck, Wrench } from "lucide-react" import { Clock, Euro, HardHat, Network } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { import {
Table, BenefitGrid,
TableBody, type BenefitGridItem,
TableCell, } from "@/components/gebos/benefit-grid"
TableHead, import {
TableHeader, ComparisonTable,
TableRow, type ComparisonColumn,
} from "@/components/ui/table" type ComparisonRow,
} from "@/components/gebos/comparison-table"
import { Section, SectionHeading } from "@/components/gebos/section" import { Section, SectionHeading } from "@/components/gebos/section"
import { CtaBanner } from "@/components/gebos/cta-banner" import { CtaBanner } from "@/components/gebos/cta-banner"
import { NumberedList } from "@/components/gebos/process-steps" import { StepRail } from "@/components/gebos/process-steps"
import {
CloudAutomationIcon,
CrossedToolsIcon,
EuroCircleIcon,
PortalGaugeIcon,
PreconfiguredIcon,
} from "@/components/gebos/line-icons"
import { OptionCard } from "@/components/gebos/option-card"
import { AssetRender, renders } from "@/components/gebos/site-assets" import { AssetRender, renders } from "@/components/gebos/site-assets"
import { AudiencePage } from "@/pages/audiences/audience-page" import { AudiencePage } from "@/pages/audiences/audience-page"
@@ -31,7 +39,7 @@ function HomeownerArt() {
imgClassName="w-full" imgClassName="w-full"
loading="eager" loading="eager"
/> />
<AssetRender {/* <AssetRender
render={renders.smokeAlarm} render={renders.smokeAlarm}
className="absolute top-[8%] left-[2%] w-[26%]" className="absolute top-[8%] left-[2%] w-[26%]"
imgClassName="w-full" imgClassName="w-full"
@@ -50,69 +58,140 @@ function HomeownerArt() {
render={renders.cubesRound} render={renders.cubesRound}
alt="" alt=""
className="absolute top-[50%] right-[30%] w-[16%] scale-200 z-30" className="absolute top-[50%] right-[30%] w-[16%] scale-200 z-30"
/> */}
</div>
)
}
/**
* Page-local scene for the full-service route: the fitter drilling the gateway
* onto the drywall panel. 4:3 frame to match the shipping-box render next door.
* The render is a self-contained scene whose subject occupies the middle 79%
* of its 4:5 canvas, so it is scaled past the frame and dropped below it: the
* subject then fills the frame's height while the surrounding matte — invisible
* on the card — is what overflows.
*/
function FullServiceArt() {
return (
<div className="relative w-full aspect-[4/3]">
<AssetRender
render={renders.workerDrillingGateway2}
className="absolute inset-x-0 -bottom-[15%] mx-auto w-[74%]"
/> />
</div> </div>
) )
} }
/**
* Portal laptop for the benefit grid's centre cell. Measured on the master,
* the laptop occupies 11.088.5 % of the canvas height and 7.783.9 % of its
* width, i.e. 77.4 % of each with clear air around it. Fitting the *canvas*
* to the cell would therefore leave the laptop floating small in the middle,
* so the canvas is inflated past the cell by exactly that air (11.04 % /
* 0.7744 above, 11.52 % / 0.7744 below): the laptop then fills the cell's
* height, flush with the flanking cards' floor and a grid gap clear of the
* row above — it never crosses into a card. `object-contain` keeps that true
* whatever height the row settles at; the wider right inset cancels the
* laptop's own leftward bias inside the canvas so it reads centred.
*/
function PortalLaptopArt() {
return (
<AssetRender
render={renders.laptopDashboard}
className="mx-auto block w-full max-w-md lg:absolute lg:-top-[14.3%] lg:-right-[12.3%] lg:-bottom-[14.9%] lg:-left-[3%] lg:mx-0 lg:w-auto lg:max-w-none"
imgClassName="h-full w-full object-contain"
/>
)
}
const SELF_INSTALL_STEPS = [ const SELF_INSTALL_STEPS = [
{ {
media: (
<AssetRender render={renders.houseSmallIso} imgClassName="w-28" />
),
title: "Gebäude angeben", title: "Gebäude angeben",
description: description:
"Sie teilen uns die grundlegenden Informationen zum Gebäude und den Wohnungen mit.", "Daten zu Ihrem Gebäude und den Wohneinheiten in wenigen Schritten erfassen.",
}, },
{ {
media: (
<>
<AssetRender render={renders.heatMeter} imgClassName="w-24" />
<AssetRender render={renders.smokeAlarm} imgClassName="w-20" />
</>
),
title: "Passende Ausstattung zusammenstellen", title: "Passende Ausstattung zusammenstellen",
description: description: "Wir empfehlen die benötigte Ausstattung Sie wählen aus.",
"Die benötigten Zähler, Kommunikationskomponenten und gegebenenfalls Rauchwarnmelder werden zusammengestellt.",
}, },
{ {
media: (
<AssetRender render={renders.shippingBoxClosed} imgClassName="w-36" />
),
title: "Vorkonfiguriert erhalten", title: "Vorkonfiguriert erhalten",
description: description: "Ihre Technik kommt vorkonfiguriert und einsatzbereit an.",
"Die Geräte werden bereits für Ihr Gebäude vorbereitet geliefert.",
}, },
{ {
media: <AssetRender render={renders.drillTool} imgClassName="w-32" />,
title: "Montieren", title: "Montieren",
description: description:
"Sie selbst, Ihr Hausmeister oder Ihr Fachbetrieb installiert die Geräte.", "Sie montieren mit Ihrem Handwerker oder selbst flexibel und unkompliziert.",
}, },
{ {
media: (
<AssetRender render={renders.refreshLoopCloud} imgClassName="w-28" />
),
title: "GebOS übernimmt", title: "GebOS übernimmt",
description: description:
"Nach der Inbetriebnahme laufen Datenerfassung und die gebuchten Dienstleistungen über GebOS.", "Wir übernehmen Ablesung, Übertragung und Auswertung automatisch.",
}, },
] ]
const WHY_GEBOS = [ const WHY_GEBOS: BenefitGridItem[] = [
{ {
icon: <EuroCircleIcon />,
title: "Kosten kontrollieren", title: "Kosten kontrollieren",
text: "Entscheiden Sie selbst, welche Arbeiten Sie übernehmen und welche Sie abgeben möchten.", text: "Entscheiden Sie selbst, welche Arbeiten Sie übernehmen und welche Sie abgeben möchten.",
}, },
{ {
icon: <Network strokeWidth={1.6} />,
title: "Keine unnötige technische Komplexität", title: "Keine unnötige technische Komplexität",
text: "Die Hardware wird für das jeweilige Gebäude vorbereitet.", text: "Die Hardware wird für das jeweilige Gebäude vorbereitet.",
}, },
{ {
icon: <HardHat strokeWidth={1.6} />,
title: "Bestehenden Handwerker nutzen", title: "Bestehenden Handwerker nutzen",
text: "Sie benötigen keinen speziellen GebOS-Installateur.", text: "Sie benötigen keinen speziellen GebOS-Installateur.",
}, },
{ {
icon: <PortalGaugeIcon />,
title: "Alles an einem Ort", title: "Alles an einem Ort",
text: "UVI, Abrechnung, Geräte und weitere Gebäudedienstleistungen werden zentral verwaltet.", text: "UVI, Abrechnung, Geräte und weitere Gebäudedienstleistungen werden zentral verwaltet.",
}, },
{ {
icon: <Clock strokeWidth={1.6} />,
title: "Weniger laufender Aufwand", title: "Weniger laufender Aufwand",
text: "Nach der Einrichtung werden Verbrauchsdaten automatisch erfasst und verarbeitet.", text: "Nach der Einrichtung werden Verbrauchsdaten automatisch erfasst und verarbeitet.",
}, },
] ]
const VARIANT_ROWS = [ const VARIANT_COLUMNS: ComparisonColumn[] = [
["Planung", "gemeinsam mit GebOS", "GebOS"], { title: "Selbst installieren", highlight: true },
["Hardware", "GebOS", "GebOS"], { title: "Komplettlösung" },
["Installation", "Kunde / eigener Fachbetrieb", "organisiert durch GebOS"], ]
["Softwareeinrichtung", "vorbereitet durch GebOS", "GebOS"],
["Laufender Betrieb", "GebOS", "GebOS"], const VARIANT_ROWS: ComparisonRow[] = [
["Kosten", "möglichst niedrig", "höhere Planungssicherheit und Komfort"], { label: "Planung", cells: ["gemeinsam mit GebOS", "GebOS"] },
{ label: "Hardware", cells: ["GebOS", "GebOS"] },
{
label: "Installation",
cells: ["Kunde / eigener Fachbetrieb", "organisiert durch GebOS"],
},
{ label: "Softwareeinrichtung", cells: ["vorbereitet durch GebOS", "GebOS"] },
{ label: "Laufender Betrieb", cells: ["GebOS", "GebOS"] },
{
label: "Kosten",
cells: ["möglichst niedrig", "höhere Planungssicherheit und Komfort"],
},
] ]
export default function HauseigentuemerPage() { export default function HauseigentuemerPage() {
@@ -131,26 +210,27 @@ export default function HauseigentuemerPage() {
</Button> </Button>
} }
visual={<HomeownerArt />} visual={<HomeownerArt />}
featureLayout="band"
features={[ features={[
{ {
icon: <Wrench />, icon: <PreconfiguredIcon />,
title: "Selbst installieren", title: "Vorkonfigurierte Technik",
sub: "Kosten sparen, flexibel bleiben", sub: "Einsatzbereit, perfekt aufeinander abgestimmt.",
}, },
{ {
icon: <ShieldCheck />, icon: <CrossedToolsIcon />,
title: "Komplettlösung", title: "Flexibel installieren",
sub: "Wir kümmern uns um alles", sub: "Selbst übernehmen oder vom Profi montieren lassen.",
}, },
{ {
icon: <ReceiptText />, icon: <CloudAutomationIcon />,
title: "Transparente Preise", title: "Automatischer Betrieb",
sub: "Keine versteckten Kosten", sub: "GebOS übernimmt Ablesung, Übertragung und Auswertung.",
}, },
{ {
icon: <Handshake />, icon: <PortalGaugeIcon />,
title: "Bestehende Partner", title: "Alles zentral verwaltet",
sub: "Ihre Handwerker bleiben", sub: "Alle Daten, Geräte und Gebäude im Blick.",
}, },
]} ]}
closing={ closing={
@@ -181,51 +261,45 @@ export default function HauseigentuemerPage() {
title="Zwei Wege zu GebOS" title="Zwei Wege zu GebOS"
/> />
<div className="mt-10 grid gap-6 md:grid-cols-2"> <div className="mt-10 grid gap-6 md:grid-cols-2">
<Card className="relative gap-0 overflow-hidden p-7"> <OptionCard
<AssetRender title="Selbst installieren"
render={renders.drillTool} lead="Sie erhalten vorkonfigurierte Technik und installieren mit Ihrem Handwerker oder in Eigenregie."
className="pointer-events-none absolute -top-2 -right-3 w-28 opacity-90" takeaway={
/> <>
<h3 className="pr-24 text-lg font-extrabold tracking-tight text-navy"> Sie organisieren die Montage.
Selbst installieren <br />
</h3> GebOS übernimmt den digitalen Betrieb.
<p className="mt-3 text-sm leading-relaxed text-pretty text-muted-foreground"> </>
Sie möchten Kosten niedrig halten und können die Installation }
selbst oder über einen vertrauten Heizungsbauer, Hausmeister visual={
oder anderen Fachbetrieb organisieren? GebOS liefert die
benötigte Technik passend zu Ihrem Gebäude vorkonfiguriert.
</p>
<p className="mt-3 text-sm font-bold leading-relaxed text-navy">
Sie organisieren die Montage. GebOS übernimmt den digitalen
Betrieb.
</p>
<div className="mt-6">
<Button asChild size="sm">
<Link to="/konfigurator">Preis berechnen</Link>
</Button>
</div>
</Card>
<Card className="relative gap-0 overflow-hidden p-7">
<AssetRender <AssetRender
render={renders.shippingBoxMeters} render={renders.shippingBoxMeters}
className="pointer-events-none absolute -top-1 -right-4 w-32" className="block w-full"
/> />
<h3 className="pr-28 text-lg font-extrabold tracking-tight text-navy"> }
Komplett installieren lassen action={
</h3> <Button asChild size="lg">
<p className="mt-3 text-sm leading-relaxed text-pretty text-muted-foreground"> <Link to="/konfigurator">Preis berechnen</Link>
Sie möchten sich möglichst wenig mit Planung, Beschaffung und </Button>
Installation beschäftigen? GebOS organisiert die komplette }
Umsetzung für Sie. Von der Ermittlung der benötigten Geräte />
über die Installation bis zum laufenden Betrieb erhalten Sie <OptionCard
eine durchgängige Lösung. title="Komplett installieren lassen"
</p> lead="Lehnen Sie sich zurück. GebOS übernimmt Planung, Installation und Einrichtung inklusive Service."
<div className="mt-6"> takeaway={
<Button asChild size="sm" variant="outline"> <>
Ein Ansprechpartner. Alles aus einer Hand.
<br />
Komplettlösung mit voller Betreuung.
</>
}
visual={<FullServiceArt />}
action={
<Button asChild size="lg" variant="outline">
<Link to="/kontakt">Komplettlösung anfragen</Link> <Link to="/kontakt">Komplettlösung anfragen</Link>
</Button> </Button>
</div> }
</Card> />
</div> </div>
</Section> </Section>
@@ -234,7 +308,7 @@ export default function HauseigentuemerPage() {
eyebrow="Schritt für Schritt" eyebrow="Schritt für Schritt"
title="So funktioniert die Selbstinstallation" title="So funktioniert die Selbstinstallation"
/> />
<NumberedList steps={SELF_INSTALL_STEPS} className="mt-10 max-w-2xl" /> <StepRail steps={SELF_INSTALL_STEPS} className="mt-10" />
</Section> </Section>
<Section className="pt-0 sm:pt-0 lg:pt-0"> <Section className="pt-0 sm:pt-0 lg:pt-0">
@@ -242,16 +316,11 @@ export default function HauseigentuemerPage() {
eyebrow="Ihre Vorteile" eyebrow="Ihre Vorteile"
title="Warum GebOS für Hauseigentümer?" title="Warum GebOS für Hauseigentümer?"
/> />
<div className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> <BenefitGrid
{WHY_GEBOS.map((item) => ( className="mt-10"
<Card key={item.title} className="gap-0 p-6"> items={WHY_GEBOS}
<h3 className="text-sm font-bold text-navy">{item.title}</h3> visual={<PortalLaptopArt />}
<p className="mt-2 text-sm leading-relaxed text-muted-foreground"> />
{item.text}
</p>
</Card>
))}
</div>
</Section> </Section>
<Section className="pt-0 sm:pt-0 lg:pt-0"> <Section className="pt-0 sm:pt-0 lg:pt-0">
@@ -259,32 +328,11 @@ export default function HauseigentuemerPage() {
eyebrow="Im Vergleich" eyebrow="Im Vergleich"
title="Welche Variante passt zu mir?" title="Welche Variante passt zu mir?"
/> />
<Card className="mt-10 gap-0 overflow-hidden p-2 sm:p-4"> <ComparisonTable
<Table> className="mt-10"
<TableHeader> columns={VARIANT_COLUMNS}
<TableRow> rows={VARIANT_ROWS}
<TableHead className="w-[30%]" /> />
<TableHead>Selbst installieren</TableHead>
<TableHead>Komplettlösung</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{VARIANT_ROWS.map(([label, self, full]) => (
<TableRow key={label}>
<TableCell className="font-bold text-navy">
{label}
</TableCell>
<TableCell className="text-muted-foreground">
{self}
</TableCell>
<TableCell className="text-muted-foreground">
{full}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</Section> </Section>
</AudiencePage> </AudiencePage>
) )
+1 -4
View File
@@ -37,8 +37,6 @@ const HEAT_OPTIONS: { value: Heat; label: string }[] = [
const INITIAL_BUILDINGS: Building[] = [ const INITIAL_BUILDINGS: Building[] = [
{ id: 1, adresse: "", heat: "heizkoerper", wohnungen: 12, zimmer: 3 }, { id: 1, adresse: "", heat: "heizkoerper", wohnungen: 12, zimmer: 3 },
{ id: 2, adresse: "", heat: "heizkoerper", wohnungen: 8, zimmer: 3 },
{ id: 3, adresse: "", heat: "heizkoerper", wohnungen: 6, zimmer: 3 },
] ]
const UVI_MONTHLY = 10.5 const UVI_MONTHLY = 10.5
@@ -357,8 +355,7 @@ export default function ConfiguratorPage() {
</h3> </h3>
<div className="mt-4 flex flex-col gap-1"> <div className="mt-4 flex flex-col gap-1">
<div className="text-2xl font-extrabold tracking-tight text-navy"> <div className="text-2xl font-extrabold tracking-tight text-navy">
{totalBuildings}{" "} {totalBuildings} Gebäude
{totalBuildings === 1 ? "Gebäude" : "Gebäude"}
</div> </div>
<div className="text-2xl font-extrabold tracking-tight text-navy"> <div className="text-2xl font-extrabold tracking-tight text-navy">
{totalUnits} {totalUnits === 1 ? "Wohnung" : "Wohnungen"} {totalUnits} {totalUnits === 1 ? "Wohnung" : "Wohnungen"}
+1 -6
View File
@@ -572,12 +572,7 @@ export default function HomePage() {
<Button asChild variant="inverse" size="lg"> <Button asChild variant="inverse" size="lg">
<Link to="/konfigurator">Preis berechnen</Link> <Link to="/konfigurator">Preis berechnen</Link>
</Button> </Button>
<Button <Button asChild variant="inverseOutline" size="lg">
asChild
variant="outline"
size="lg"
className="border-white/40 bg-transparent text-white hover:bg-white/10 hover:text-white"
>
<Link to="/so-funktionierts">So funktioniert GebOS</Link> <Link to="/so-funktionierts">So funktioniert GebOS</Link>
</Button> </Button>
</> </>
+189
View File
@@ -56,6 +56,18 @@
--brand-900: #06474e; --brand-900: #06474e;
--navy: #071a3f; --navy: #071a3f;
/* glass — sampled off the cube renders (glass-cubes-stack, brand-cube) */
--glass-spec: #dffcfc; /* specular top face */
--glass-edge: #97f1f4; /* light piped along the lit edge */
--glass-deep: #176163; /* shaded face, and the bloom it casts */
/* per-surface slots, retuned by the glass-* utilities below */
--glass-rim: color-mix(in srgb, var(--glass-spec) 55%, transparent);
--glass-ring: rgb(255 255 255 / 0.2);
--glass-ring-lit: rgb(255 255 255 / 0.35);
--glass-caustic: color-mix(in srgb, var(--glass-edge) 50%, transparent);
--glass-bloom: color-mix(in srgb, var(--glass-deep) 38%, transparent);
--radius: 0.75rem; --radius: 0.75rem;
} }
@@ -112,6 +124,21 @@
0 2px 4px rgb(19 41 60 / 0.04), 0 16px 48px rgb(19 41 60 / 0.1); 0 2px 4px rgb(19 41 60 / 0.04), 0 16px 48px rgb(19 41 60 / 0.1);
--shadow-band: 0 16px 40px rgb(10 100 89 / 0.28); --shadow-band: 0 16px 40px rgb(10 100 89 / 0.28);
--shadow-pill: 0 1px 2px rgb(19 41 60 / 0.06); --shadow-pill: 0 1px 2px rgb(19 41 60 / 0.06);
/* glass: lit top edge, piped rim, caustic pooling at the base */
--inset-shadow-glass:
inset 0 1px 0 0 var(--glass-rim), inset 0 0 0 1px var(--glass-ring),
inset 0 -9px 12px -9px var(--glass-caustic);
--shadow-glass:
0 1px 2px rgb(19 41 60 / 0.05), 0 10px 22px -10px var(--glass-bloom);
--shadow-glass-lg:
0 2px 4px rgb(19 41 60 / 0.06), 0 18px 34px -12px var(--glass-bloom);
/* the same optics at slab scale, for surfaces the size of the footer */
--inset-shadow-glass-slab:
inset 0 2px 0 0 var(--glass-rim), inset 0 0 0 1px var(--glass-ring),
inset 0 -30px 44px -30px var(--glass-caustic);
--shadow-glass-slab:
0 2px 6px rgb(19 41 60 / 0.05), 0 30px 60px -24px var(--glass-bloom);
} }
@layer base { @layer base {
@@ -167,3 +194,165 @@
border-color: var(--brand-300); border-color: var(--brand-300);
border-style: dotted; border-style: dotted;
} }
/* ------------------------------------------------------------------ */
/* Glass surfaces */
/* Read off the cube renders: light falls in from the top left, the */
/* body deepens towards the base, the edge pipes light around the */
/* silhouette and pools into a caustic underneath. */
/* Same colour space as the tokens above the bodies are brand */
/* tokens thinned `in srgb`, and hand-written gradients interpolate */
/* in srgb too (Tailwind's own gradient utilities would go oklab). */
/* ------------------------------------------------------------------ */
/* primary actions: teal glass, lit face → shaded base */
@utility glass-teal {
--glass-rim: color-mix(in srgb, var(--glass-spec) 45%, transparent);
--glass-ring: rgb(255 255 255 / 0.2);
--glass-ring-lit: rgb(255 255 255 / 0.38);
--glass-caustic: color-mix(in srgb, var(--glass-edge) 55%, transparent);
--glass-bloom: color-mix(in srgb, var(--brand-800) 45%, transparent);
background-image:
linear-gradient(
135deg,
color-mix(in srgb, var(--glass-spec) 20%, transparent) 0%,
color-mix(in srgb, var(--glass-spec) 5%, transparent) 32%,
transparent 58%
),
linear-gradient(
180deg,
color-mix(in srgb, var(--brand-600) 96%, transparent) 0%,
color-mix(in srgb, var(--brand-700) 98%, transparent) 52%,
var(--brand-800) 100%
);
}
/* secondary actions: thin mint glass over light surfaces */
@utility glass-mint {
--glass-rim: rgb(255 255 255 / 0.85);
--glass-ring: rgb(255 255 255 / 0.7);
--glass-ring-lit: rgb(255 255 255 / 0.9);
--glass-caustic: color-mix(in srgb, var(--glass-edge) 40%, transparent);
--glass-bloom: color-mix(in srgb, var(--glass-deep) 22%, transparent);
background-image:
linear-gradient(
135deg,
rgb(255 255 255 / 0.55) 0%,
rgb(255 255 255 / 0.15) 40%,
transparent 66%
),
linear-gradient(
180deg,
color-mix(in srgb, var(--brand-100) 82%, transparent) 0%,
color-mix(in srgb, var(--brand-200) 74%, transparent) 100%
);
}
/* quiet actions: clear glass, only the edge and a teal cast give it away */
@utility glass-clear {
--glass-rim: rgb(255 255 255 / 0.9);
--glass-ring: var(--input);
--glass-ring-lit: color-mix(in srgb, var(--brand-300) 80%, transparent);
--glass-caustic: color-mix(in srgb, var(--glass-edge) 35%, transparent);
--glass-bloom: color-mix(in srgb, var(--glass-deep) 16%, transparent);
background-image:
linear-gradient(
135deg,
rgb(255 255 255 / 0.7) 0%,
rgb(255 255 255 / 0.3) 42%,
transparent 68%
),
linear-gradient(
180deg,
rgb(255 255 255 / 0.72) 0%,
color-mix(in srgb, var(--brand-50) 66%, transparent) 100%
);
}
/* frosted white glass for the teal gradient bands */
@utility glass-frost {
--glass-rim: rgb(255 255 255 / 0.95);
--glass-ring: rgb(255 255 255 / 0.8);
--glass-ring-lit: rgb(255 255 255 / 0.95);
--glass-caustic: color-mix(in srgb, var(--glass-edge) 45%, transparent);
--glass-bloom: color-mix(in srgb, var(--brand-900) 45%, transparent);
background-image:
linear-gradient(
135deg,
rgb(255 255 255 / 0.4) 0%,
rgb(255 255 255 / 0.1) 42%,
transparent 66%
),
linear-gradient(
180deg,
rgb(255 255 255 / 0.94) 0%,
color-mix(in srgb, var(--brand-50) 88%, transparent) 100%
);
}
/* the same band, second action: glass with the band still showing through */
@utility glass-veil {
--glass-rim: rgb(255 255 255 / 0.55);
--glass-ring: rgb(255 255 255 / 0.45);
--glass-ring-lit: rgb(255 255 255 / 0.7);
--glass-caustic: rgb(255 255 255 / 0.3);
--glass-bloom: color-mix(in srgb, var(--brand-900) 35%, transparent);
background-image:
linear-gradient(
135deg,
rgb(255 255 255 / 0.22) 0%,
rgb(255 255 255 / 0.06) 40%,
transparent 64%
),
linear-gradient(180deg, rgb(255 255 255 / 0.16) 0%, rgb(255 255 255 / 0.07) 100%);
}
/* cast navy glass, thick enough to read as a slab (footer) */
@utility glass-navy {
--glass-rim: color-mix(in srgb, var(--glass-edge) 42%, transparent);
--glass-ring: rgb(255 255 255 / 0.12);
--glass-ring-lit: rgb(255 255 255 / 0.2);
--glass-caustic: color-mix(in srgb, var(--glass-edge) 34%, transparent);
--glass-bloom: color-mix(in srgb, var(--navy) 32%, transparent);
background-image:
linear-gradient(
135deg,
color-mix(in srgb, var(--glass-spec) 13%, transparent) 0%,
color-mix(in srgb, var(--glass-spec) 3%, transparent) 26%,
transparent 52%
),
linear-gradient(
180deg,
color-mix(in srgb, var(--navy) 88%, transparent) 0%,
color-mix(in srgb, var(--navy) 96%, transparent) 72%,
color-mix(in srgb, var(--navy) 98%, transparent) 100%
);
}
@utility glass-danger {
--glass-rim: rgb(255 255 255 / 0.4);
--glass-ring: rgb(255 255 255 / 0.25);
--glass-ring-lit: rgb(255 255 255 / 0.42);
--glass-caustic: rgb(255 215 216 / 0.45);
--glass-bloom: color-mix(in srgb, var(--destructive) 45%, transparent);
background-image:
linear-gradient(
135deg,
rgb(255 255 255 / 0.28) 0%,
rgb(255 255 255 / 0.06) 34%,
transparent 62%
),
linear-gradient(
180deg,
color-mix(in srgb, var(--destructive) 94%, transparent) 0%,
color-mix(in srgb, var(--destructive) 99%, transparent) 100%
);
}
/* hover/active: the face turns further into the light */
@utility glass-lit {
--glass-rim: color-mix(in srgb, var(--glass-spec) 90%, transparent);
--glass-ring: var(--glass-ring-lit);
--glass-caustic: color-mix(in srgb, var(--glass-edge) 92%, transparent);
background-color: color-mix(in srgb, var(--glass-spec) 16%, transparent);
}