save
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import * as React from "react"
|
||||
import { CircleCheck } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ChecklistItem({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<li className={cn("flex items-start gap-2.5 text-sm", className)}>
|
||||
<CircleCheck className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<span>{children}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function Checklist({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: React.ReactNode[]
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<ul className={cn("flex flex-col gap-2.5", className)}>
|
||||
{items.map((item, i) => (
|
||||
<ChecklistItem key={i}>{item}</ChecklistItem>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checklist, ChecklistItem }
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { IconBadge } from "@/components/gebos/icon-tile"
|
||||
|
||||
/**
|
||||
* Teal gradient call-to-action band:
|
||||
* glassy icon disc · bold white title + muted subline · actions right.
|
||||
*/
|
||||
function CtaBanner({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
className,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
actions: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-5 rounded-2xl bg-brand-gradient p-6 shadow-band sm:flex-row sm:items-center sm:gap-6 sm:p-7",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<IconBadge variant="glass" size="lg">
|
||||
{icon}
|
||||
</IconBadge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-lg font-bold tracking-tight text-white sm:text-xl">
|
||||
{title}
|
||||
</h3>
|
||||
{description ? (
|
||||
<p className="mt-1 text-sm leading-relaxed text-white/75">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-3">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { CtaBanner }
|
||||
@@ -0,0 +1,63 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import buildingAvif from "@/assets/gebos/building.avif"
|
||||
import buildingWebp from "@/assets/gebos/building.webp"
|
||||
import consumptionRing from "@/assets/gebos/consumption-ring.svg"
|
||||
import smokeAlarmAvif from "@/assets/gebos/smoke-alarm.avif"
|
||||
import smokeAlarmWebp from "@/assets/gebos/smoke-alarm.webp"
|
||||
import tealBars from "@/assets/gebos/teal-bars.svg"
|
||||
import uviBadge from "@/assets/gebos/uvi-badge.svg"
|
||||
|
||||
/**
|
||||
* Homepage hero composition from the GebOS visual asset kit:
|
||||
* building render (white matte → mix-blend-multiply), smoke alarm,
|
||||
* translucent data bars, UVI badge and consumption ring.
|
||||
* Layout mirrors the kit's demo.html so proportions can be tuned
|
||||
* without rerendering.
|
||||
*/
|
||||
function HeroArt({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-auto aspect-[1.28] w-full max-w-[680px] lg:mr-0 lg:ml-auto",
|
||||
className
|
||||
)}
|
||||
aria-label="GebOS Produktvisualisierung"
|
||||
role="img"
|
||||
>
|
||||
<img
|
||||
src={tealBars}
|
||||
alt=""
|
||||
className="absolute top-[12%] -right-[5%] z-0 w-[46%]"
|
||||
/>
|
||||
<picture>
|
||||
<source srcSet={buildingAvif} type="image/avif" />
|
||||
<img
|
||||
src={buildingWebp}
|
||||
alt="Modernes Mehrfamilienhaus"
|
||||
className="absolute top-[2%] left-[2%] z-1 h-[90%] w-[96%] object-contain mix-blend-multiply"
|
||||
/>
|
||||
</picture>
|
||||
<picture>
|
||||
<source srcSet={smokeAlarmAvif} type="image/avif" />
|
||||
<img
|
||||
src={smokeAlarmWebp}
|
||||
alt="Rauchwarnmelder"
|
||||
className="absolute top-[4%] -left-[2%] z-3 w-[22%] rounded-full mix-blend-multiply"
|
||||
/>
|
||||
</picture>
|
||||
<img
|
||||
src={uviBadge}
|
||||
alt=""
|
||||
className="absolute bottom-[7%] left-[2%] z-4 w-[15%]"
|
||||
/>
|
||||
<img
|
||||
src={consumptionRing}
|
||||
alt=""
|
||||
className="absolute -bottom-[2%] -right-[2%] z-5 w-[46%]"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { HeroArt }
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as React from "react"
|
||||
import { CircleCheck, Wifi } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { IconBadge } from "@/components/gebos/icon-tile"
|
||||
import {
|
||||
BuildingIllustration,
|
||||
DonutIllustration,
|
||||
} from "@/components/gebos/illustrations"
|
||||
|
||||
/**
|
||||
* Floating status chip ("Inspektion OK · 06.05.2024") used on hero visuals.
|
||||
*/
|
||||
function StatusChip({
|
||||
title,
|
||||
sub,
|
||||
className,
|
||||
}: {
|
||||
title: React.ReactNode
|
||||
sub?: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-xl border border-border/60 bg-card/95 px-3.5 py-2.5 shadow-card-lg backdrop-blur",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<IconBadge variant="solid" size="sm">
|
||||
<CircleCheck />
|
||||
</IconBadge>
|
||||
<div className="leading-tight">
|
||||
<div className="text-xs font-bold text-navy">{title}</div>
|
||||
{sub ? (
|
||||
<div className="text-[10px] text-muted-foreground">{sub}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable hero composition standing in for the 3D building renders:
|
||||
* glow stage, building, floating status card, signal chip and donut.
|
||||
* `media` swaps the centre illustration.
|
||||
*/
|
||||
function HeroVisual({
|
||||
media,
|
||||
chipTitle = "Inspektion OK",
|
||||
chipSub = "Letzte Prüfung 06.05.2024",
|
||||
className,
|
||||
}: {
|
||||
media?: React.ReactNode
|
||||
chipTitle?: React.ReactNode
|
||||
chipSub?: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("relative mx-auto w-full max-w-md", className)}>
|
||||
{/* stage */}
|
||||
<div className="absolute inset-x-6 top-8 bottom-0 rounded-[2.5rem] bg-gradient-to-br from-white via-brand-50 to-brand-100 shadow-card-lg" />
|
||||
<div className="relative px-10 pt-4 pb-8">
|
||||
{media ?? <BuildingIllustration className="mx-auto w-56" />}
|
||||
</div>
|
||||
{/* floating elements */}
|
||||
<StatusChip
|
||||
title={chipTitle}
|
||||
sub={chipSub}
|
||||
className="absolute top-16 -left-2 sm:left-0"
|
||||
/>
|
||||
<IconBadge
|
||||
variant="solid"
|
||||
size="lg"
|
||||
className="absolute bottom-20 -left-1 shadow-band sm:left-4"
|
||||
>
|
||||
<Wifi />
|
||||
</IconBadge>
|
||||
<DonutIllustration className="absolute -right-2 bottom-4 w-24 drop-shadow-lg sm:right-0" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { HeroVisual, StatusChip }
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const iconBadgeVariants = cva(
|
||||
"flex shrink-0 items-center justify-center [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
/* soft teal disc */
|
||||
soft: "bg-accent text-brand-700",
|
||||
/* white disc with hairline + teal line icon (feature rows) */
|
||||
outline: "border border-brand-100 bg-card text-primary shadow-pill",
|
||||
solid: "bg-primary text-primary-foreground",
|
||||
navy: "bg-navy text-white",
|
||||
/* translucent white on gradient bands */
|
||||
glass: "bg-white/15 text-white ring-1 ring-white/25",
|
||||
},
|
||||
size: {
|
||||
sm: "size-9 [&_svg]:size-4",
|
||||
md: "size-11 [&_svg]:size-5",
|
||||
lg: "size-14 [&_svg]:size-6",
|
||||
xl: "size-16 [&_svg]:size-7",
|
||||
},
|
||||
shape: {
|
||||
circle: "rounded-full",
|
||||
squircle: "rounded-xl",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "soft",
|
||||
size: "md",
|
||||
shape: "circle",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function IconBadge({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
shape,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof iconBadgeVariants>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(iconBadgeVariants({ variant, size, shape }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon-over-label tile used in the feature rows underneath heroes
|
||||
* (UVI · Heizkostenabrechnung · Rauchwarnmelder · …).
|
||||
*/
|
||||
function FeatureTile({
|
||||
icon,
|
||||
media,
|
||||
title,
|
||||
sub,
|
||||
align = "center",
|
||||
className,
|
||||
}: {
|
||||
icon?: React.ReactNode
|
||||
/** self-contained visual (e.g. asset-kit icon) rendered without IconBadge */
|
||||
media?: React.ReactNode
|
||||
title: React.ReactNode
|
||||
sub?: React.ReactNode
|
||||
align?: "center" | "start"
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2.5",
|
||||
align === "center" ? "items-center text-center" : "items-start",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{media ?? (
|
||||
<IconBadge variant="outline" shape="squircle" size="lg">
|
||||
{icon}
|
||||
</IconBadge>
|
||||
)}
|
||||
<div className="text-sm font-bold text-navy">{title}</div>
|
||||
{sub ? (
|
||||
<p className="-mt-1.5 text-xs leading-snug text-muted-foreground">
|
||||
{sub}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { IconBadge, iconBadgeVariants, FeatureTile }
|
||||
@@ -0,0 +1,154 @@
|
||||
import * as React from "react"
|
||||
|
||||
/**
|
||||
* Lightweight SVG stand-ins for the 3D renders in the designs.
|
||||
* All of them draw exclusively from the chart/brand tokens.
|
||||
*/
|
||||
|
||||
/** Smoke detector: white disc, vent ring, teal status LED. */
|
||||
function SmokeDetectorIllustration({ className }: { className?: string }) {
|
||||
const vents = Array.from({ length: 12 }, (_, i) => {
|
||||
const a = (i / 12) * Math.PI * 2
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx={60 + Math.cos(a) * 30}
|
||||
cy={60 + Math.sin(a) * 30}
|
||||
r="3.2"
|
||||
fill="var(--muted)"
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 120 120" aria-hidden="true" className={className}>
|
||||
<defs>
|
||||
<radialGradient id="sd-body" cx="0.35" cy="0.3" r="0.9">
|
||||
<stop offset="0" stopColor="#ffffff" />
|
||||
<stop offset="0.75" stopColor="#f2f6f7" />
|
||||
<stop offset="1" stopColor="#dde7e9" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="60" cy="64" r="52" fill="rgb(19 41 60 / 0.08)" />
|
||||
<circle cx="60" cy="60" r="52" fill="url(#sd-body)" />
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="41"
|
||||
fill="none"
|
||||
stroke="rgb(19 41 60 / 0.08)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{vents}
|
||||
<circle cx="60" cy="60" r="12" fill="#eef3f4" />
|
||||
<circle cx="60" cy="60" r="4" fill="var(--brand-500)" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Cluster of translucent isometric sensor cubes (UVI). */
|
||||
function SensorCubesIllustration({ className }: { className?: string }) {
|
||||
const cube = (x: number, y: number, s: number, o: number) => (
|
||||
<g transform={`translate(${x} ${y}) scale(${s})`} opacity={o}>
|
||||
<path d="M0 10 20 0l20 10-20 10Z" fill="var(--brand-300)" />
|
||||
<path d="M0 10v22l20 10V20Z" fill="var(--brand-500)" />
|
||||
<path d="M40 10v22L20 42V20Z" fill="var(--brand-600)" />
|
||||
</g>
|
||||
)
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 120 120" aria-hidden="true" className={className}>
|
||||
<g opacity="0.9">
|
||||
{cube(12, 12, 1.1, 0.55)}
|
||||
{cube(58, 30, 1.0, 0.8)}
|
||||
{cube(26, 56, 1.3, 1)}
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Donut chart in brand tones (portfolio / reporting motif). */
|
||||
function DonutIllustration({ className }: { className?: string }) {
|
||||
/* three segments: 55% teal, 25% light, 20% pale */
|
||||
const C = 2 * Math.PI * 40
|
||||
return (
|
||||
<svg viewBox="0 0 120 120" aria-hidden="true" className={className}>
|
||||
<g transform="rotate(-90 60 60)">
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="40"
|
||||
fill="none"
|
||||
stroke="var(--chart-3)"
|
||||
strokeWidth="22"
|
||||
/>
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="40"
|
||||
fill="none"
|
||||
stroke="var(--chart-2)"
|
||||
strokeWidth="22"
|
||||
strokeDasharray={`${C * 0.25} ${C}`}
|
||||
strokeDashoffset={-C * 0.55}
|
||||
/>
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="40"
|
||||
fill="none"
|
||||
stroke="var(--chart-1)"
|
||||
strokeWidth="22"
|
||||
strokeDasharray={`${C * 0.55} ${C}`}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Stylised apartment building: white slabs, window grid, teal accents. */
|
||||
function BuildingIllustration({ className }: { className?: string }) {
|
||||
const windows: React.ReactNode[] = []
|
||||
for (let row = 0; row < 5; row++) {
|
||||
for (let col = 0; col < 4; col++) {
|
||||
windows.push(
|
||||
<rect
|
||||
key={`${row}-${col}`}
|
||||
x={34 + col * 22}
|
||||
y={36 + row * 26}
|
||||
width="14"
|
||||
height="16"
|
||||
rx="2"
|
||||
fill={row === 1 && col === 2 ? "var(--brand-300)" : "#dbe6ea"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 160 200" aria-hidden="true" className={className}>
|
||||
<defs>
|
||||
<linearGradient id="bld-face" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#ffffff" />
|
||||
<stop offset="1" stopColor="#e9f0f2" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<ellipse cx="80" cy="188" rx="72" ry="10" fill="rgb(19 41 60 / 0.08)" />
|
||||
<rect x="24" y="22" width="112" height="166" rx="8" fill="url(#bld-face)" />
|
||||
<rect x="24" y="22" width="112" height="10" rx="5" fill="#f7fafb" />
|
||||
{windows}
|
||||
{/* balcony bands */}
|
||||
<rect x="28" y="60" width="104" height="3" rx="1.5" fill="#cfdde1" />
|
||||
<rect x="28" y="112" width="104" height="3" rx="1.5" fill="#cfdde1" />
|
||||
{/* entrance */}
|
||||
<rect x="70" y="160" width="20" height="28" rx="3" fill="var(--navy)" opacity="0.85" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
SmokeDetectorIllustration,
|
||||
SensorCubesIllustration,
|
||||
DonutIllustration,
|
||||
BuildingIllustration,
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import mark3dAvif from "@/assets/gebos/brand-mark-3d-96.avif"
|
||||
import mark3dWebp from "@/assets/gebos/brand-mark-3d-96.webp"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* GebOS brand mark: isometric cube inside a hexagon, teal gradient stroke,
|
||||
* followed by the two-tone wordmark ("Geb" navy / "OS" teal).
|
||||
*/
|
||||
function LogoMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className={cn("size-8", className)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="gebos-mark" x1="8" y1="6" x2="42" y2="42">
|
||||
<stop offset="0" stopColor="var(--brand-400)" />
|
||||
<stop offset="0.55" stopColor="var(--brand-600)" />
|
||||
<stop offset="1" stopColor="var(--navy)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* top face */}
|
||||
<path
|
||||
d="M24 5.5 40.5 15 24 24.5 7.5 15Z"
|
||||
fill="var(--brand-100)"
|
||||
opacity="0.7"
|
||||
/>
|
||||
{/* hexagon silhouette */}
|
||||
<path
|
||||
d="M24 4.5 41 14.3v19.4L24 43.5 7 33.7V14.3Z"
|
||||
stroke="url(#gebos-mark)"
|
||||
strokeWidth="3.4"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* inner cube edges */}
|
||||
<path
|
||||
d="M7.6 14.7 24 24.2m0 0 16.4-9.5M24 24.2v18.6"
|
||||
stroke="url(#gebos-mark)"
|
||||
strokeWidth="3.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 3D glass brand mark (rendered asset, genuine alpha). */
|
||||
function LogoMark3d({ className }: { className?: string }) {
|
||||
return (
|
||||
<picture>
|
||||
<source srcSet={mark3dAvif} type="image/avif" />
|
||||
<img
|
||||
src={mark3dWebp}
|
||||
alt=""
|
||||
width={96}
|
||||
height={96}
|
||||
className={cn("size-8 object-contain", className)}
|
||||
/>
|
||||
</picture>
|
||||
)
|
||||
}
|
||||
|
||||
function Logo({
|
||||
className,
|
||||
markClassName,
|
||||
inverse = false,
|
||||
mark = "line",
|
||||
}: {
|
||||
className?: string
|
||||
markClassName?: string
|
||||
inverse?: boolean
|
||||
/** "line" = flat SVG mark, "3d" = glass render */
|
||||
mark?: "line" | "3d"
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-2", className)}>
|
||||
{mark === "3d" ? (
|
||||
<LogoMark3d className={markClassName} />
|
||||
) : (
|
||||
<LogoMark className={markClassName} />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-[1.35rem] leading-none font-extrabold tracking-tight",
|
||||
inverse ? "text-white" : "text-navy"
|
||||
)}
|
||||
>
|
||||
Geb
|
||||
<span className={inverse ? "text-brand-300" : "text-primary"}>OS</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { Logo, LogoMark, LogoMark3d }
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Fragment } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
|
||||
export interface Crumb {
|
||||
label: string
|
||||
/** omit on the last crumb – it renders as the current page */
|
||||
to?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Site-wide breadcrumb trail: parent chain → current page.
|
||||
* Top-level pages start at "Startseite", audience pages at "Für wen?".
|
||||
*/
|
||||
function PageBreadcrumb({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: Crumb[]
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList className={className}>
|
||||
{items.map((item, i) => {
|
||||
const last = i === items.length - 1
|
||||
return (
|
||||
<Fragment key={item.label}>
|
||||
<BreadcrumbItem>
|
||||
{last || !item.to ? (
|
||||
<BreadcrumbPage>{item.label}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to={item.to}>{item.label}</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{last ? null : <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
|
||||
export { PageBreadcrumb }
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Container } from "@/components/gebos/section"
|
||||
|
||||
/**
|
||||
* Split hero: copy left (breadcrumb/eyebrow, H1, lead, CTAs, extra),
|
||||
* visual right, soft radial glow behind.
|
||||
*/
|
||||
function PageHero({
|
||||
breadcrumb,
|
||||
title,
|
||||
lead,
|
||||
actions,
|
||||
extra,
|
||||
visual,
|
||||
className,
|
||||
}: {
|
||||
breadcrumb?: React.ReactNode
|
||||
title: React.ReactNode
|
||||
lead?: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
/** slot below the actions (e.g. hint pill) */
|
||||
extra?: React.ReactNode
|
||||
visual?: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("bg-hero-glow", className)}>
|
||||
{breadcrumb ? (
|
||||
<Container className="pt-6 lg:pt-8">{breadcrumb}</Container>
|
||||
) : null}
|
||||
<Container
|
||||
className={cn(
|
||||
"grid items-center gap-10 pb-14 sm:pb-16 lg:grid-cols-[1.05fr_0.95fr] lg:gap-6 lg:pb-20",
|
||||
/* breadcrumb already provides the top offset */
|
||||
breadcrumb ? "pt-5" : "pt-14 sm:pt-16 lg:pt-20"
|
||||
)}
|
||||
>
|
||||
<div className={cn("max-w-xl", breadcrumb && "lg:self-start")}>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-balance text-navy sm:text-5xl">
|
||||
{title}
|
||||
</h1>
|
||||
{lead ? (
|
||||
<p className="mt-4 max-w-lg text-base leading-relaxed text-pretty text-muted-foreground sm:text-lg">
|
||||
{lead}
|
||||
</p>
|
||||
) : null}
|
||||
{actions ? (
|
||||
<div className="mt-7 flex flex-wrap items-center gap-3">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
{extra ? <div className="mt-6">{extra}</div> : null}
|
||||
</div>
|
||||
{visual ? <div className="relative w-full">{visual}</div> : null}
|
||||
</Container>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { PageHero }
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as React from "react"
|
||||
import { Ban, CircleCheck } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
/**
|
||||
* Klarpreis pricing card:
|
||||
* title · cadence pill · large price + unit · allocability pill ·
|
||||
* divider · description. Optional media floats top-right.
|
||||
*/
|
||||
function PriceCard({
|
||||
title,
|
||||
cadence,
|
||||
price,
|
||||
unit,
|
||||
allocation,
|
||||
description,
|
||||
media,
|
||||
className,
|
||||
}: {
|
||||
title: React.ReactNode
|
||||
/** "Einmalig" | "Monatlich" pill under the title */
|
||||
cadence: React.ReactNode
|
||||
price: React.ReactNode
|
||||
unit: React.ReactNode
|
||||
/** umlagefähig? tone drives soft-teal vs. soft-red pill */
|
||||
allocation: { label: React.ReactNode; tone: "positive" | "negative" }
|
||||
description: React.ReactNode
|
||||
media?: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Card className={cn("relative gap-0 p-6 sm:p-7", className)}>
|
||||
{media ? (
|
||||
<div className="pointer-events-none absolute top-6 right-6 w-24 sm:w-28">
|
||||
{media}
|
||||
</div>
|
||||
) : null}
|
||||
<h3 className="pr-24 text-xl font-bold tracking-tight text-navy">
|
||||
{title}
|
||||
</h3>
|
||||
<Badge className="mt-2.5">{cadence}</Badge>
|
||||
<div className="mt-5 text-[2.6rem] leading-none font-extrabold tracking-tight text-navy">
|
||||
{price}
|
||||
</div>
|
||||
<div className="mt-1.5 text-sm text-muted-foreground">{unit}</div>
|
||||
<Badge
|
||||
variant={allocation.tone === "positive" ? "default" : "destructive"}
|
||||
className="mt-4"
|
||||
>
|
||||
{allocation.tone === "positive" ? <CircleCheck /> : <Ban />}
|
||||
{allocation.label}
|
||||
</Badge>
|
||||
<Separator className="mt-5 mb-4" />
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export { PriceCard }
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { IconBadge } from "@/components/gebos/icon-tile"
|
||||
|
||||
export interface ProcessStep {
|
||||
icon?: React.ReactNode
|
||||
/** self-contained visual (asset render/vector) shown instead of IconBadge */
|
||||
media?: React.ReactNode
|
||||
title: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Numbered process row ("So funktioniert GebOS", steps 1–5):
|
||||
* numbered chips joined by a dotted teal line, device icon, title, copy.
|
||||
*/
|
||||
function ProcessSteps({
|
||||
steps,
|
||||
className,
|
||||
}: {
|
||||
steps: ProcessStep[]
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<ol
|
||||
className={cn(
|
||||
"relative grid gap-x-6 gap-y-10 sm:grid-cols-2",
|
||||
steps.length >= 5 ? "lg:grid-cols-5" : "lg:grid-cols-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-[10%] top-3.5 hidden border-t-2 border-dotted-brand lg:block"
|
||||
/>
|
||||
{steps.map((step, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="relative flex flex-col items-center gap-3 text-center"
|
||||
>
|
||||
<span className="z-10 flex size-7 items-center justify-center rounded-full bg-card text-xs font-bold text-primary shadow-pill ring-2 ring-brand-300">
|
||||
{i + 1}
|
||||
</span>
|
||||
{step.media ?? (
|
||||
<IconBadge variant="outline" shape="squircle" size="xl">
|
||||
{step.icon}
|
||||
</IconBadge>
|
||||
)}
|
||||
<h3 className="text-sm font-bold text-navy">{step.title}</h3>
|
||||
{step.description ? (
|
||||
<p className="-mt-1 max-w-[16rem] text-xs leading-relaxed text-muted-foreground">
|
||||
{step.description}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical variant used by the Konfigurator overview (steps 1–4).
|
||||
*/
|
||||
function NumberedList({
|
||||
steps,
|
||||
className,
|
||||
}: {
|
||||
steps: { title: React.ReactNode; description?: React.ReactNode }[]
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<ol className={cn("relative flex flex-col gap-6", className)}>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute top-2 bottom-2 left-3.5 border-l-2 border-dotted-brand"
|
||||
/>
|
||||
{steps.map((step, i) => (
|
||||
<li key={i} className="relative flex items-start gap-4">
|
||||
<span className="z-10 flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground shadow-pill">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="pt-0.5">
|
||||
<h3 className="text-sm font-bold text-navy">{step.title}</h3>
|
||||
{step.description ? (
|
||||
<p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
|
||||
{step.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
export { ProcessSteps, NumberedList }
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Container({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ className, ...props }: React.ComponentProps<"section">) {
|
||||
return (
|
||||
<section
|
||||
className={cn("scroll-mt-20 py-14 sm:py-18 lg:py-20", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard section intro: optional teal eyebrow, navy title, muted lead.
|
||||
*/
|
||||
function SectionHeading({
|
||||
eyebrow,
|
||||
title,
|
||||
lead,
|
||||
align = "left",
|
||||
className,
|
||||
}: {
|
||||
eyebrow?: React.ReactNode
|
||||
title: React.ReactNode
|
||||
lead?: React.ReactNode
|
||||
align?: "left" | "center"
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-2xl",
|
||||
align === "center" && "mx-auto text-center",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{eyebrow ? (
|
||||
<p className="mb-2 text-xs font-bold tracking-[0.14em] text-primary uppercase">
|
||||
{eyebrow}
|
||||
</p>
|
||||
) : null}
|
||||
<h2 className="text-3xl font-extrabold tracking-tight text-balance text-navy sm:text-4xl">
|
||||
{title}
|
||||
</h2>
|
||||
{lead ? (
|
||||
<p className="mt-3 text-base leading-relaxed text-pretty text-muted-foreground sm:text-lg">
|
||||
{lead}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Container, Section, SectionHeading }
|
||||
@@ -0,0 +1,253 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import barChartAvif from "@/assets/gebos/bar-chart.avif"
|
||||
import barChartWebp from "@/assets/gebos/bar-chart.webp"
|
||||
import brandCubeAvif from "@/assets/gebos/brand-cube-render.avif"
|
||||
import brandCubeWebp from "@/assets/gebos/brand-cube-render.webp"
|
||||
import brandMark3dAvif from "@/assets/gebos/brand-mark-3d.avif"
|
||||
import brandMark3dWebp from "@/assets/gebos/brand-mark-3d.webp"
|
||||
import brandMarkFlatAvif from "@/assets/gebos/brand-mark-flat.avif"
|
||||
import brandMarkFlatWebp from "@/assets/gebos/brand-mark-flat.webp"
|
||||
import buildingAlphaAvif from "@/assets/gebos/building-alpha.avif"
|
||||
import buildingAlphaWebp from "@/assets/gebos/building-alpha.webp"
|
||||
import buildingIsoAvif from "@/assets/gebos/building-iso.avif"
|
||||
import buildingIsoWebp from "@/assets/gebos/building-iso.webp"
|
||||
import buildingAvif from "@/assets/gebos/building.avif"
|
||||
import buildingWebp from "@/assets/gebos/building.webp"
|
||||
import checkOrb from "@/assets/gebos/check-orb.svg"
|
||||
import cloudSupport from "@/assets/gebos/cloud-support.svg"
|
||||
import consumptionRing from "@/assets/gebos/consumption-ring.svg"
|
||||
import cubesRoundAvif from "@/assets/gebos/cubes-round.avif"
|
||||
import cubesRoundWebp from "@/assets/gebos/cubes-round.webp"
|
||||
import drillToolAvif from "@/assets/gebos/drill-tool.avif"
|
||||
import drillToolWebp from "@/assets/gebos/drill-tool.webp"
|
||||
import euroOrb from "@/assets/gebos/euro-orb.svg"
|
||||
import gatewayAvif from "@/assets/gebos/gateway.avif"
|
||||
import gatewayWebp from "@/assets/gebos/gateway.webp"
|
||||
import glassCubes from "@/assets/gebos/glass-cubes.svg"
|
||||
import glassCubesStackAvif from "@/assets/gebos/glass-cubes-stack.avif"
|
||||
import glassCubesStackWebp from "@/assets/gebos/glass-cubes-stack.webp"
|
||||
import heatMeterAvif from "@/assets/gebos/heat-meter.avif"
|
||||
import heatMeterWebp from "@/assets/gebos/heat-meter.webp"
|
||||
import iconBilling from "@/assets/gebos/icon-billing.svg"
|
||||
import iconPortal from "@/assets/gebos/icon-portal.svg"
|
||||
import iconRadio from "@/assets/gebos/icon-radio.svg"
|
||||
import iconSmokeAlarm from "@/assets/gebos/icon-smoke-alarm.svg"
|
||||
import iconUvi from "@/assets/gebos/icon-uvi.svg"
|
||||
import laptopAvif from "@/assets/gebos/laptop-dashboard.avif"
|
||||
import laptopWebp from "@/assets/gebos/laptop-dashboard.webp"
|
||||
import packageBox from "@/assets/gebos/package-box.svg"
|
||||
import shippingBoxClosedAvif from "@/assets/gebos/shipping-box-closed.avif"
|
||||
import shippingBoxClosedWebp from "@/assets/gebos/shipping-box-closed.webp"
|
||||
import shippingBoxMetersAvif from "@/assets/gebos/shipping-box-meters.avif"
|
||||
import shippingBoxMetersWebp from "@/assets/gebos/shipping-box-meters.webp"
|
||||
import smokeAlarmAvif from "@/assets/gebos/smoke-alarm.avif"
|
||||
import smokeAlarmWebp from "@/assets/gebos/smoke-alarm.webp"
|
||||
import tealBars from "@/assets/gebos/teal-bars.svg"
|
||||
import uviBadge from "@/assets/gebos/uvi-badge.svg"
|
||||
import wirelessOrb from "@/assets/gebos/wireless-orb.svg"
|
||||
import wordmarkDarkAvif from "@/assets/gebos/wordmark-cubes-dark.avif"
|
||||
import wordmarkDarkWebp from "@/assets/gebos/wordmark-cubes-dark.webp"
|
||||
import wordmarkGlowAvif from "@/assets/gebos/wordmark-cubes-glow.avif"
|
||||
import wordmarkGlowWebp from "@/assets/gebos/wordmark-cubes-glow.webp"
|
||||
import workerTabletAvif from "@/assets/gebos/worker-tablet.avif"
|
||||
import workerTabletWebp from "@/assets/gebos/worker-tablet.webp"
|
||||
|
||||
export interface RenderAsset {
|
||||
avif: string
|
||||
webp: string
|
||||
alt: string
|
||||
/**
|
||||
* "white" – pure-white matte, needs multiply blending to sit on light surfaces.
|
||||
* "alpha" – genuine transparency, composited as-is.
|
||||
*/
|
||||
matte: "white" | "alpha"
|
||||
}
|
||||
|
||||
/**
|
||||
* Photographic / 3D renders. Always draw through <AssetRender>, which picks
|
||||
* the right compositing mode for the asset's matte.
|
||||
*/
|
||||
const renders = {
|
||||
/* --- site kit: white-matte masters --------------------------------- */
|
||||
building: {
|
||||
avif: buildingAvif,
|
||||
webp: buildingWebp,
|
||||
alt: "Modernes Mehrfamilienhaus",
|
||||
matte: "white",
|
||||
},
|
||||
smokeAlarm: {
|
||||
avif: smokeAlarmAvif,
|
||||
webp: smokeAlarmWebp,
|
||||
alt: "Rauchwarnmelder",
|
||||
matte: "white",
|
||||
},
|
||||
gateway: {
|
||||
avif: gatewayAvif,
|
||||
webp: gatewayWebp,
|
||||
alt: "GebOS Gateway",
|
||||
matte: "white",
|
||||
},
|
||||
heatMeter: {
|
||||
avif: heatMeterAvif,
|
||||
webp: heatMeterWebp,
|
||||
alt: "Wärmemengenzähler",
|
||||
matte: "white",
|
||||
},
|
||||
laptopDashboard: {
|
||||
avif: laptopAvif,
|
||||
webp: laptopWebp,
|
||||
alt: "GebOS Portal auf dem Laptop",
|
||||
matte: "white",
|
||||
},
|
||||
brandCube: {
|
||||
avif: brandCubeAvif,
|
||||
webp: brandCubeWebp,
|
||||
alt: "",
|
||||
matte: "white",
|
||||
},
|
||||
shippingBoxMeters: {
|
||||
avif: shippingBoxMetersAvif,
|
||||
webp: shippingBoxMetersWebp,
|
||||
alt: "Objektbezogen vorbereitete Hardware",
|
||||
matte: "white",
|
||||
},
|
||||
|
||||
/* --- transparent renders (free placement, any background) ---------- */
|
||||
/** same subject as `building`, but with genuine alpha – safe on any surface */
|
||||
buildingAlpha: {
|
||||
avif: buildingAlphaAvif,
|
||||
webp: buildingAlphaWebp,
|
||||
alt: "Modernes Mehrfamilienhaus",
|
||||
matte: "alpha",
|
||||
},
|
||||
/** isometric corner view of the same building type, genuine alpha */
|
||||
buildingIso: {
|
||||
avif: buildingIsoAvif,
|
||||
webp: buildingIsoWebp,
|
||||
alt: "Modernes Mehrfamilienhaus",
|
||||
matte: "alpha",
|
||||
},
|
||||
brandMark3d: {
|
||||
avif: brandMark3dAvif,
|
||||
webp: brandMark3dWebp,
|
||||
alt: "GebOS Markenzeichen",
|
||||
matte: "alpha",
|
||||
},
|
||||
brandMarkFlat: {
|
||||
avif: brandMarkFlatAvif,
|
||||
webp: brandMarkFlatWebp,
|
||||
alt: "GebOS Markenzeichen",
|
||||
matte: "alpha",
|
||||
},
|
||||
glassCubesStack: {
|
||||
avif: glassCubesStackAvif,
|
||||
webp: glassCubesStackWebp,
|
||||
alt: "",
|
||||
matte: "alpha",
|
||||
},
|
||||
cubesRound: {
|
||||
avif: cubesRoundAvif,
|
||||
webp: cubesRoundWebp,
|
||||
alt: "",
|
||||
matte: "alpha",
|
||||
},
|
||||
barChart: {
|
||||
avif: barChartAvif,
|
||||
webp: barChartWebp,
|
||||
alt: "",
|
||||
matte: "alpha",
|
||||
},
|
||||
drillTool: {
|
||||
avif: drillToolAvif,
|
||||
webp: drillToolWebp,
|
||||
alt: "",
|
||||
matte: "alpha",
|
||||
},
|
||||
workerTablet: {
|
||||
avif: workerTabletAvif,
|
||||
webp: workerTabletWebp,
|
||||
alt: "Monteur mit Tablet",
|
||||
matte: "alpha",
|
||||
},
|
||||
shippingBoxClosed: {
|
||||
avif: shippingBoxClosedAvif,
|
||||
webp: shippingBoxClosedWebp,
|
||||
alt: "Vorkonfigurierte Lieferung",
|
||||
matte: "alpha",
|
||||
},
|
||||
/** baked light glow – use on white/near-white surfaces */
|
||||
wordmarkGlow: {
|
||||
avif: wordmarkGlowAvif,
|
||||
webp: wordmarkGlowWebp,
|
||||
alt: "GebOS",
|
||||
matte: "alpha",
|
||||
},
|
||||
/** baked dark glow – use on navy/dark surfaces */
|
||||
wordmarkDark: {
|
||||
avif: wordmarkDarkAvif,
|
||||
webp: wordmarkDarkWebp,
|
||||
alt: "GebOS",
|
||||
matte: "alpha",
|
||||
},
|
||||
} satisfies Record<string, RenderAsset>
|
||||
|
||||
/** Transparent brand vectors (decorative; render as plain <img alt="">). */
|
||||
const vectors = {
|
||||
tealBars,
|
||||
uviBadge,
|
||||
consumptionRing,
|
||||
glassCubes,
|
||||
checkOrb,
|
||||
euroOrb,
|
||||
cloudSupport,
|
||||
packageBox,
|
||||
wirelessOrb,
|
||||
}
|
||||
|
||||
/** Feature icons with baked-in pale disc (use in FeatureTile `media`). */
|
||||
const icons = {
|
||||
uvi: iconUvi,
|
||||
billing: iconBilling,
|
||||
smokeAlarm: iconSmokeAlarm,
|
||||
radio: iconRadio,
|
||||
portal: iconPortal,
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a kit asset as AVIF→WebP <picture>. White-matte masters get
|
||||
* mix-blend-multiply so their matte disappears on light surfaces;
|
||||
* transparent assets composite normally.
|
||||
*/
|
||||
function AssetRender({
|
||||
render,
|
||||
alt,
|
||||
className,
|
||||
imgClassName,
|
||||
loading = "lazy",
|
||||
}: {
|
||||
render: RenderAsset
|
||||
/** override the registry alt (e.g. decorative usage) */
|
||||
alt?: string
|
||||
className?: string
|
||||
imgClassName?: string
|
||||
loading?: "lazy" | "eager"
|
||||
}) {
|
||||
return (
|
||||
<picture className={className}>
|
||||
<source srcSet={render.avif} type="image/avif" />
|
||||
<img
|
||||
src={render.webp}
|
||||
alt={alt ?? render.alt}
|
||||
loading={loading}
|
||||
className={cn(
|
||||
"object-contain",
|
||||
render.matte === "white" && "mix-blend-multiply",
|
||||
imgClassName
|
||||
)}
|
||||
/>
|
||||
</picture>
|
||||
)
|
||||
}
|
||||
|
||||
export { AssetRender, icons, renders, vectors }
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
import { Logo } from "@/components/gebos/logo"
|
||||
import { Container } from "@/components/gebos/section"
|
||||
|
||||
const COLUMNS: { title: string; links: { to: string; label: string }[] }[] = [
|
||||
{
|
||||
title: "Produkt",
|
||||
links: [
|
||||
{ to: "/loesungen", label: "Lösungen" },
|
||||
{ to: "/so-funktionierts", label: "So funktioniert’s" },
|
||||
{ to: "/konfigurator", label: "Konfigurator" },
|
||||
{ to: "/klarpreis", label: "GebOS Klarpreis" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Für wen?",
|
||||
links: [
|
||||
{ to: "/fuer-wen", label: "Übersicht" },
|
||||
{ to: "/fuer-wen/hauseigentuemer", label: "Hauseigentümer" },
|
||||
{ to: "/fuer-wen/hausverwaltungen", label: "Hausverwaltungen" },
|
||||
{ to: "/fuer-wen/messdienstleister", label: "Messdienstleister" },
|
||||
{ to: "/fuer-wen/installateure", label: "Installateure & Heizungsbauer" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Unternehmen",
|
||||
links: [
|
||||
{ to: "/unternehmen", label: "Über GebOS" },
|
||||
{ to: "/kontakt", label: "Kontakt" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function SiteFooter() {
|
||||
return (
|
||||
<footer className="mt-20 bg-navy text-white">
|
||||
<Container className="grid gap-10 py-14 sm:grid-cols-2 lg:grid-cols-[1.2fr_1fr_1fr_1fr]">
|
||||
<div>
|
||||
<Logo inverse />
|
||||
<p className="mt-4 max-w-xs text-sm leading-relaxed text-white/60">
|
||||
Messdienstleistungen einfach gemacht – von der vorkonfigurierten
|
||||
Hardware bis zum laufenden Betrieb.
|
||||
</p>
|
||||
</div>
|
||||
{COLUMNS.map((col) => (
|
||||
<nav key={col.title} aria-label={col.title}>
|
||||
<h3 className="text-xs font-bold tracking-[0.14em] text-brand-300 uppercase">
|
||||
{col.title}
|
||||
</h3>
|
||||
<ul className="mt-4 flex flex-col gap-2.5">
|
||||
{col.links.map((l) => (
|
||||
<li key={l.to}>
|
||||
<Link
|
||||
to={l.to}
|
||||
className="text-sm text-white/75 transition-colors hover:text-white"
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
))}
|
||||
</Container>
|
||||
<div className="border-t border-white/10">
|
||||
<Container className="flex flex-wrap items-center justify-between gap-3 py-5 text-xs text-white/50">
|
||||
<span>© 2026 GebOS. Alle Rechte vorbehalten.</span>
|
||||
<span>Impressum · Datenschutz · AGB</span>
|
||||
</Container>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export { SiteFooter }
|
||||
@@ -0,0 +1,148 @@
|
||||
import { ChevronDown, Menu } from "lucide-react"
|
||||
import { Link, NavLink, useLocation } from "react-router-dom"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Logo } from "@/components/gebos/logo"
|
||||
import { Container } from "@/components/gebos/section"
|
||||
|
||||
const AUDIENCES = [
|
||||
{ to: "/fuer-wen/hauseigentuemer", label: "Hauseigentümer" },
|
||||
{ to: "/fuer-wen/hausverwaltungen", label: "Hausverwaltungen" },
|
||||
{ to: "/fuer-wen/messdienstleister", label: "Messdienstleister" },
|
||||
{ to: "/fuer-wen/installateure", label: "Installateure & Heizungsbauer" },
|
||||
]
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ to: "/loesungen", label: "Lösungen" },
|
||||
{ to: "/so-funktionierts", label: "So funktioniert’s" },
|
||||
/* "Für wen?" rendered separately as dropdown */
|
||||
{ to: "/konfigurator", label: "Konfigurator" },
|
||||
{ to: "/unternehmen", label: "Unternehmen" },
|
||||
{ to: "/kontakt", label: "Kontakt" },
|
||||
]
|
||||
|
||||
const navItemClass = (active: boolean) =>
|
||||
cn(
|
||||
"rounded-full px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "bg-accent font-semibold text-brand-800 ring-1 ring-brand-200"
|
||||
: "text-foreground/70 hover:text-navy"
|
||||
)
|
||||
|
||||
function ForWhomMenu() {
|
||||
const { pathname } = useLocation()
|
||||
const active = pathname.startsWith("/fuer-wen")
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
navItemClass(active),
|
||||
"inline-flex cursor-pointer items-center gap-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40"
|
||||
)}
|
||||
>
|
||||
Für wen?
|
||||
<ChevronDown className="size-3.5 opacity-60" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/fuer-wen">Übersicht</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{AUDIENCES.map((a) => (
|
||||
<DropdownMenuItem key={a.to} asChild>
|
||||
<Link to={a.to}>{a.label}</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileMenu() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="lg:hidden">
|
||||
<Menu />
|
||||
<span className="sr-only">Menü öffnen</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
{NAV_LINKS.slice(0, 2).map((l) => (
|
||||
<DropdownMenuItem key={l.to} asChild>
|
||||
<Link to={l.to}>{l.label}</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Für wen?</DropdownMenuLabel>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/fuer-wen">Übersicht</Link>
|
||||
</DropdownMenuItem>
|
||||
{AUDIENCES.map((a) => (
|
||||
<DropdownMenuItem key={a.to} asChild>
|
||||
<Link to={a.to}>{a.label}</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{NAV_LINKS.slice(2).map((l) => (
|
||||
<DropdownMenuItem key={l.to} asChild>
|
||||
<Link to={l.to}>{l.label}</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/klarpreis">Klarpreis</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function SiteHeader() {
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b border-border/60 bg-background/85 backdrop-blur">
|
||||
<Container className="flex h-16 items-center justify-between gap-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="rounded-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40"
|
||||
>
|
||||
<Logo mark="3d" markClassName="size-9" />
|
||||
<span className="sr-only">GebOS Startseite</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-1 lg:flex">
|
||||
{NAV_LINKS.slice(0, 2).map((l) => (
|
||||
<NavLink key={l.to} to={l.to} className={({ isActive }) => navItemClass(isActive)}>
|
||||
{l.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<ForWhomMenu />
|
||||
{NAV_LINKS.slice(2).map((l) => (
|
||||
<NavLink key={l.to} to={l.to} className={({ isActive }) => navItemClass(isActive)}>
|
||||
{l.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button asChild size="sm" className="hidden sm:inline-flex">
|
||||
<Link to="/konfigurator">Preis berechnen</Link>
|
||||
</Button>
|
||||
<MobileMenu />
|
||||
</div>
|
||||
</Container>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export { SiteHeader }
|
||||
Reference in New Issue
Block a user