save
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
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 1–5): 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 252–254, 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,42 +34,44 @@ 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">
|
||||||
<Container className="grid gap-10 py-14 sm:grid-cols-2 lg:grid-cols-[1.2fr_1fr_1fr_1fr]">
|
<footer className="glass-navy inset-shadow-glass-slab shadow-glass-slab rounded-3xl text-white">
|
||||||
<div>
|
<Container className="grid gap-10 py-14 sm:grid-cols-2 lg:grid-cols-[1.2fr_1fr_1fr_1fr]">
|
||||||
<Logo inverse />
|
<div>
|
||||||
<p className="mt-4 max-w-xs text-sm leading-relaxed text-white/60">
|
<Logo inverse />
|
||||||
Messdienstleistungen einfach gemacht – von der vorkonfigurierten
|
<p className="mt-4 max-w-xs text-sm leading-relaxed text-white/60">
|
||||||
Hardware bis zum laufenden Betrieb.
|
Messdienstleistungen einfach gemacht – von der vorkonfigurierten
|
||||||
</p>
|
Hardware bis zum laufenden Betrieb.
|
||||||
</div>
|
</p>
|
||||||
{COLUMNS.map((col) => (
|
</div>
|
||||||
<nav key={col.title} aria-label={col.title}>
|
{COLUMNS.map((col) => (
|
||||||
<h3 className="text-xs font-bold tracking-[0.14em] text-brand-300 uppercase">
|
<nav key={col.title} aria-label={col.title}>
|
||||||
{col.title}
|
<h3 className="text-xs font-bold tracking-[0.14em] text-brand-300 uppercase">
|
||||||
</h3>
|
{col.title}
|
||||||
<ul className="mt-4 flex flex-col gap-2.5">
|
</h3>
|
||||||
{col.links.map((l) => (
|
<ul className="mt-4 flex flex-col gap-2.5">
|
||||||
<li key={l.to}>
|
{col.links.map((l) => (
|
||||||
<Link
|
<li key={l.to}>
|
||||||
to={l.to}
|
<Link
|
||||||
className="text-sm text-white/75 transition-colors hover:text-white"
|
to={l.to}
|
||||||
>
|
className="text-sm text-white/75 transition-colors hover:text-white"
|
||||||
{l.label}
|
>
|
||||||
</Link>
|
{l.label}
|
||||||
</li>
|
</Link>
|
||||||
))}
|
</li>
|
||||||
</ul>
|
))}
|
||||||
</nav>
|
</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>
|
</Container>
|
||||||
</div>
|
<div className="border-t border-white/10">
|
||||||
</footer>
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,17 +76,21 @@ function AudiencePage({
|
|||||||
/>
|
/>
|
||||||
<Container>
|
<Container>
|
||||||
<Section className="pt-2 sm:pt-4 lg:pt-6">
|
<Section className="pt-2 sm:pt-4 lg:pt-6">
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-4">
|
{featureLayout === "band" ? (
|
||||||
{features.map((feature, i) => (
|
<FeatureBand items={features} />
|
||||||
<FeatureTile
|
) : (
|
||||||
key={i}
|
<div className="grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-4">
|
||||||
icon={feature.icon}
|
{features.map((feature, i) => (
|
||||||
media={feature.media}
|
<FeatureTile
|
||||||
title={feature.title}
|
key={i}
|
||||||
sub={feature.sub}
|
icon={feature.icon}
|
||||||
/>
|
media={feature.media}
|
||||||
))}
|
title={feature.title}
|
||||||
</div>
|
sub={feature.sub}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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.0–88.5 % of the canvas height and 7.7–83.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
|
<AssetRender
|
||||||
benötigte Technik passend zu Ihrem Gebäude vorkonfiguriert.
|
render={renders.shippingBoxMeters}
|
||||||
</p>
|
className="block w-full"
|
||||||
<p className="mt-3 text-sm font-bold leading-relaxed text-navy">
|
/>
|
||||||
Sie organisieren die Montage. GebOS übernimmt den digitalen
|
}
|
||||||
Betrieb.
|
action={
|
||||||
</p>
|
<Button asChild size="lg">
|
||||||
<div className="mt-6">
|
|
||||||
<Button asChild size="sm">
|
|
||||||
<Link to="/konfigurator">Preis berechnen</Link>
|
<Link to="/konfigurator">Preis berechnen</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</Card>
|
/>
|
||||||
<Card className="relative gap-0 overflow-hidden p-7">
|
<OptionCard
|
||||||
<AssetRender
|
title="Komplett installieren lassen"
|
||||||
render={renders.shippingBoxMeters}
|
lead="Lehnen Sie sich zurück. GebOS übernimmt Planung, Installation und Einrichtung – inklusive Service."
|
||||||
className="pointer-events-none absolute -top-1 -right-4 w-32"
|
takeaway={
|
||||||
/>
|
<>
|
||||||
<h3 className="pr-28 text-lg font-extrabold tracking-tight text-navy">
|
Ein Ansprechpartner. Alles aus einer Hand.
|
||||||
Komplett installieren lassen
|
<br />
|
||||||
</h3>
|
Komplettlösung mit voller Betreuung.
|
||||||
<p className="mt-3 text-sm leading-relaxed text-pretty text-muted-foreground">
|
</>
|
||||||
Sie möchten sich möglichst wenig mit Planung, Beschaffung und
|
}
|
||||||
Installation beschäftigen? GebOS organisiert die komplette
|
visual={<FullServiceArt />}
|
||||||
Umsetzung für Sie. Von der Ermittlung der benötigten Geräte
|
action={
|
||||||
über die Installation bis zum laufenden Betrieb erhalten Sie
|
<Button asChild size="lg" variant="outline">
|
||||||
eine durchgängige Lösung.
|
|
||||||
</p>
|
|
||||||
<div className="mt-6">
|
|
||||||
<Button asChild size="sm" 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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||