save
This commit is contained in:
@@ -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 drillToolWebp from "@/assets/gebos/drill-tool.webp"
|
||||
import euroOrb from "@/assets/gebos/euro-orb.svg"
|
||||
import fullHeroSectionAvif from "@/assets/gebos/full-hero-section.avif"
|
||||
import fullHeroSectionWebp from "@/assets/gebos/full-hero-section.webp"
|
||||
import gatewayAvif from "@/assets/gebos/gateway.avif"
|
||||
import gatewayWebp from "@/assets/gebos/gateway.webp"
|
||||
import glassCubes from "@/assets/gebos/glass-cubes.svg"
|
||||
@@ -29,6 +31,8 @@ import glassCubesStackAvif from "@/assets/gebos/glass-cubes-stack.avif"
|
||||
import glassCubesStackWebp from "@/assets/gebos/glass-cubes-stack.webp"
|
||||
import heatMeterAvif from "@/assets/gebos/heat-meter.avif"
|
||||
import heatMeterWebp from "@/assets/gebos/heat-meter.webp"
|
||||
import houseSmallIsoAvif from "@/assets/gebos/house-small-isometric.avif"
|
||||
import houseSmallIsoWebp from "@/assets/gebos/house-small-isometric.webp"
|
||||
import iconBilling from "@/assets/gebos/icon-billing.svg"
|
||||
import iconPortal from "@/assets/gebos/icon-portal.svg"
|
||||
import iconRadio from "@/assets/gebos/icon-radio.svg"
|
||||
@@ -36,7 +40,13 @@ import iconSmokeAlarm from "@/assets/gebos/icon-smoke-alarm.svg"
|
||||
import iconUvi from "@/assets/gebos/icon-uvi.svg"
|
||||
import laptopAvif from "@/assets/gebos/laptop-dashboard.avif"
|
||||
import laptopWebp from "@/assets/gebos/laptop-dashboard.webp"
|
||||
import laptopPortfolioAvif from "@/assets/gebos/laptop-portfolio.avif"
|
||||
import laptopPortfolioWebp from "@/assets/gebos/laptop-portfolio.webp"
|
||||
import packageBox from "@/assets/gebos/package-box.svg"
|
||||
import refreshLoopCloudAvif from "@/assets/gebos/refresh-loop-cloud.avif"
|
||||
import refreshLoopCloudWebp from "@/assets/gebos/refresh-loop-cloud.webp"
|
||||
import refreshLoopAvif from "@/assets/gebos/refresh-loop.avif"
|
||||
import refreshLoopWebp from "@/assets/gebos/refresh-loop.webp"
|
||||
import shippingBoxClosedAvif from "@/assets/gebos/shipping-box-closed.avif"
|
||||
import shippingBoxClosedWebp from "@/assets/gebos/shipping-box-closed.webp"
|
||||
import shippingBoxMetersAvif from "@/assets/gebos/shipping-box-meters.avif"
|
||||
@@ -44,12 +54,18 @@ import shippingBoxMetersWebp from "@/assets/gebos/shipping-box-meters.webp"
|
||||
import smokeAlarmAvif from "@/assets/gebos/smoke-alarm.avif"
|
||||
import smokeAlarmWebp from "@/assets/gebos/smoke-alarm.webp"
|
||||
import tealBars from "@/assets/gebos/teal-bars.svg"
|
||||
import technicianPipeAvif from "@/assets/gebos/technician-pipe.avif"
|
||||
import technicianPipeWebp from "@/assets/gebos/technician-pipe.webp"
|
||||
import uviBadge from "@/assets/gebos/uvi-badge.svg"
|
||||
import wirelessOrb from "@/assets/gebos/wireless-orb.svg"
|
||||
import wordmarkDarkAvif from "@/assets/gebos/wordmark-cubes-dark.avif"
|
||||
import wordmarkDarkWebp from "@/assets/gebos/wordmark-cubes-dark.webp"
|
||||
import wordmarkGlowAvif from "@/assets/gebos/wordmark-cubes-glow.avif"
|
||||
import wordmarkGlowWebp from "@/assets/gebos/wordmark-cubes-glow.webp"
|
||||
import workerDrillingGateway2Avif from "@/assets/gebos/worker-drilling-gateway-2.avif"
|
||||
import workerDrillingGateway2Webp from "@/assets/gebos/worker-drilling-gateway-2.webp"
|
||||
import workerDrillingGatewayAvif from "@/assets/gebos/worker-drilling-gateway.avif"
|
||||
import workerDrillingGatewayWebp from "@/assets/gebos/worker-drilling-gateway.webp"
|
||||
import workerTabletAvif from "@/assets/gebos/worker-tablet.avif"
|
||||
import workerTabletWebp from "@/assets/gebos/worker-tablet.webp"
|
||||
|
||||
@@ -112,6 +128,24 @@ const renders = {
|
||||
alt: "Objektbezogen vorbereitete Hardware",
|
||||
matte: "white",
|
||||
},
|
||||
technicianPipe: {
|
||||
avif: technicianPipeAvif,
|
||||
webp: technicianPipeWebp,
|
||||
alt: "Techniker prüft Rohrleitung mit Messgerät",
|
||||
matte: "white",
|
||||
},
|
||||
workerDrillingGateway: {
|
||||
avif: workerDrillingGatewayAvif,
|
||||
webp: workerDrillingGatewayWebp,
|
||||
alt: "Monteur montiert GebOS Gateway an der Wand",
|
||||
matte: "white",
|
||||
},
|
||||
workerDrillingGateway2: {
|
||||
avif: workerDrillingGateway2Avif,
|
||||
webp: workerDrillingGateway2Webp,
|
||||
alt: "Monteur montiert GebOS Gateway an Trockenbauwand",
|
||||
matte: "white",
|
||||
},
|
||||
|
||||
/* --- transparent renders (free placement, any background) ---------- */
|
||||
/** same subject as `building`, but with genuine alpha – safe on any surface */
|
||||
@@ -128,6 +162,27 @@ const renders = {
|
||||
alt: "Modernes Mehrfamilienhaus",
|
||||
matte: "alpha",
|
||||
},
|
||||
/** compact single-family house, isometric, genuine alpha */
|
||||
houseSmallIso: {
|
||||
avif: houseSmallIsoAvif,
|
||||
webp: houseSmallIsoWebp,
|
||||
alt: "Einfamilienhaus, isometrisch",
|
||||
matte: "alpha",
|
||||
},
|
||||
/** full hero scene: building, gateway, consumption bars & ring on a glass platform */
|
||||
fullHeroSection: {
|
||||
avif: fullHeroSectionAvif,
|
||||
webp: fullHeroSectionWebp,
|
||||
alt: "GebOS Systemübersicht: Gebäude, Gateway und Verbrauchsdaten",
|
||||
matte: "alpha",
|
||||
},
|
||||
/** same laptop mockup as `laptopDashboard`, portfolio/room screen, genuine alpha */
|
||||
laptopPortfolio: {
|
||||
avif: laptopPortfolioAvif,
|
||||
webp: laptopPortfolioWebp,
|
||||
alt: "GebOS Portfolio-Ansicht auf dem Laptop",
|
||||
matte: "alpha",
|
||||
},
|
||||
brandMark3d: {
|
||||
avif: brandMark3dAvif,
|
||||
webp: brandMark3dWebp,
|
||||
@@ -176,6 +231,18 @@ const renders = {
|
||||
alt: "Vorkonfigurierte Lieferung",
|
||||
matte: "alpha",
|
||||
},
|
||||
refreshLoop: {
|
||||
avif: refreshLoopAvif,
|
||||
webp: refreshLoopWebp,
|
||||
alt: "",
|
||||
matte: "alpha",
|
||||
},
|
||||
refreshLoopCloud: {
|
||||
avif: refreshLoopCloudAvif,
|
||||
webp: refreshLoopCloudWebp,
|
||||
alt: "",
|
||||
matte: "alpha",
|
||||
},
|
||||
/** baked light glow – use on white/near-white surfaces */
|
||||
wordmarkGlow: {
|
||||
avif: wordmarkGlowAvif,
|
||||
@@ -216,8 +283,10 @@ const icons = {
|
||||
|
||||
/**
|
||||
* Renders a kit asset as AVIF→WebP <picture>. White-matte masters get
|
||||
* mix-blend-multiply so their matte disappears on light surfaces;
|
||||
* transparent assets composite normally.
|
||||
* mix-blend-multiply so their matte disappears on light surfaces, plus a
|
||||
* hairline brightness lift: the masters matte out at 252–254, which multiply
|
||||
* would otherwise leave as a visible 1 % box on a white card. Transparent
|
||||
* assets composite normally.
|
||||
*/
|
||||
function AssetRender({
|
||||
render,
|
||||
@@ -242,7 +311,7 @@ function AssetRender({
|
||||
loading={loading}
|
||||
className={cn(
|
||||
"object-contain",
|
||||
render.matte === "white" && "mix-blend-multiply",
|
||||
render.matte === "white" && "mix-blend-multiply brightness-[1.015]",
|
||||
imgClassName
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -34,42 +34,44 @@ const COLUMNS: { title: string; links: { to: string; label: string }[] }[] = [
|
||||
|
||||
function SiteFooter() {
|
||||
return (
|
||||
<footer className="mt-20 bg-navy text-white">
|
||||
<Container className="grid gap-10 py-14 sm:grid-cols-2 lg:grid-cols-[1.2fr_1fr_1fr_1fr]">
|
||||
<div>
|
||||
<Logo inverse />
|
||||
<p className="mt-4 max-w-xs text-sm leading-relaxed text-white/60">
|
||||
Messdienstleistungen einfach gemacht – von der vorkonfigurierten
|
||||
Hardware bis zum laufenden Betrieb.
|
||||
</p>
|
||||
</div>
|
||||
{COLUMNS.map((col) => (
|
||||
<nav key={col.title} aria-label={col.title}>
|
||||
<h3 className="text-xs font-bold tracking-[0.14em] text-brand-300 uppercase">
|
||||
{col.title}
|
||||
</h3>
|
||||
<ul className="mt-4 flex flex-col gap-2.5">
|
||||
{col.links.map((l) => (
|
||||
<li key={l.to}>
|
||||
<Link
|
||||
to={l.to}
|
||||
className="text-sm text-white/75 transition-colors hover:text-white"
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
))}
|
||||
</Container>
|
||||
<div className="border-t border-white/10">
|
||||
<Container className="flex flex-wrap items-center justify-between gap-3 py-5 text-xs text-white/50">
|
||||
<span>© 2026 GebOS. Alle Rechte vorbehalten.</span>
|
||||
<span>Impressum · Datenschutz · AGB</span>
|
||||
<div className="mt-20 px-3 pb-3 sm:px-5 sm:pb-5">
|
||||
<footer className="glass-navy inset-shadow-glass-slab shadow-glass-slab rounded-3xl text-white">
|
||||
<Container className="grid gap-10 py-14 sm:grid-cols-2 lg:grid-cols-[1.2fr_1fr_1fr_1fr]">
|
||||
<div>
|
||||
<Logo inverse />
|
||||
<p className="mt-4 max-w-xs text-sm leading-relaxed text-white/60">
|
||||
Messdienstleistungen einfach gemacht – von der vorkonfigurierten
|
||||
Hardware bis zum laufenden Betrieb.
|
||||
</p>
|
||||
</div>
|
||||
{COLUMNS.map((col) => (
|
||||
<nav key={col.title} aria-label={col.title}>
|
||||
<h3 className="text-xs font-bold tracking-[0.14em] text-brand-300 uppercase">
|
||||
{col.title}
|
||||
</h3>
|
||||
<ul className="mt-4 flex flex-col gap-2.5">
|
||||
{col.links.map((l) => (
|
||||
<li key={l.to}>
|
||||
<Link
|
||||
to={l.to}
|
||||
className="text-sm text-white/75 transition-colors hover:text-white"
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
))}
|
||||
</Container>
|
||||
</div>
|
||||
</footer>
|
||||
<div className="border-t border-white/10">
|
||||
<Container className="flex flex-wrap items-center justify-between gap-3 py-5 text-xs text-white/60">
|
||||
<span>© 2026 GebOS. Alle Rechte vorbehalten.</span>
|
||||
<span>Impressum · Datenschutz · AGB</span>
|
||||
</Container>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,22 +4,33 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Glass physics shared by the filled variants: the backdrop is blurred and
|
||||
* saturated the way the cube renders bend what is behind them, the lit rim
|
||||
* and the caustic ride the inset-shadow slot, the cast bloom the outer one.
|
||||
* Tint and edge colour come from the `glass-*` utilities in globals.css.
|
||||
*/
|
||||
const glass =
|
||||
"backdrop-blur-md backdrop-saturate-150 inset-shadow-glass shadow-glass hover:shadow-glass-lg"
|
||||
|
||||
const glassOnHover =
|
||||
"hover:backdrop-blur-md hover:backdrop-saturate-150 hover:inset-shadow-glass hover:shadow-glass"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg text-sm font-semibold whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-sm hover:bg-brand-700",
|
||||
outline:
|
||||
"border border-border bg-card text-foreground shadow-pill hover:border-brand-200 hover:bg-brand-50",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-brand-100",
|
||||
ghost: "text-foreground hover:bg-secondary",
|
||||
default: `${glass} glass-teal text-primary-foreground hover:glass-lit`,
|
||||
outline: `${glass} glass-clear text-foreground hover:glass-lit`,
|
||||
secondary: `${glass} glass-mint text-secondary-foreground hover:glass-lit`,
|
||||
ghost: `${glassOnHover} text-foreground hover:glass-clear`,
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
/* white button sitting on the teal gradient band */
|
||||
inverse: "bg-white text-brand-800 shadow-sm hover:bg-brand-50",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
/* frosted white button sitting on the teal gradient band */
|
||||
inverse: `${glass} glass-frost text-brand-800 hover:glass-lit`,
|
||||
/* its quieter neighbour: the band still reads through the glass */
|
||||
inverseOutline: `${glass} glass-veil text-white hover:glass-lit`,
|
||||
destructive: `${glass} glass-danger text-destructive-foreground hover:glass-lit`,
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-5 py-2",
|
||||
|
||||
Reference in New Issue
Block a user