This commit is contained in:
Lars Nolden
2026-08-13 21:52:39 +02:00
parent 33690b30a3
commit 9806427b16
16 changed files with 603 additions and 342 deletions
@@ -1,18 +1,36 @@
import * as React from "react"
import { CircleCheck } from "lucide-react"
import { CircleCheck, CircleMinus } from "lucide-react"
import { cn } from "@/lib/utils"
/** "excluded" strikes the item out of the list: muted text, minus instead of check. */
type ChecklistTone = "included" | "excluded"
function ChecklistItem({
children,
className,
tone = "included",
}: {
children: React.ReactNode
className?: string
tone?: ChecklistTone
}) {
const excluded = tone === "excluded"
const Icon = excluded ? CircleMinus : CircleCheck
return (
<li className={cn("flex items-start gap-2.5 text-sm", className)}>
<CircleCheck className="mt-0.5 size-4 shrink-0 text-primary" />
<li
className={cn(
"flex items-start gap-2.5 text-sm",
excluded && "text-muted-foreground line-through decoration-muted-foreground/40",
className
)}
>
<Icon
className={cn(
"mt-0.5 size-4 shrink-0",
excluded ? "text-muted-foreground/70" : "text-primary"
)}
/>
<span>{children}</span>
</li>
)
@@ -21,14 +39,18 @@ function ChecklistItem({
function Checklist({
items,
className,
tone,
}: {
items: React.ReactNode[]
className?: string
tone?: ChecklistTone
}) {
return (
<ul className={cn("flex flex-col gap-2.5", className)}>
{items.map((item, i) => (
<ChecklistItem key={i}>{item}</ChecklistItem>
<ChecklistItem key={i} tone={tone}>
{item}
</ChecklistItem>
))}
</ul>
)
@@ -22,10 +22,18 @@ 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 floorHeatingAvif from "@/assets/gebos/floor-heating.avif"
import floorHeatingWebp from "@/assets/gebos/floor-heating.webp"
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 glassBadge1Avif from "@/assets/gebos/glass-badge-1.avif"
import glassBadge1Webp from "@/assets/gebos/glass-badge-1.webp"
import glassBadge2Avif from "@/assets/gebos/glass-badge-2.avif"
import glassBadge2Webp from "@/assets/gebos/glass-badge-2.webp"
import glassBadge3Avif from "@/assets/gebos/glass-badge-3.avif"
import glassBadge3Webp from "@/assets/gebos/glass-badge-3.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"
@@ -43,6 +51,8 @@ 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 radiatorAvif from "@/assets/gebos/radiator.avif"
import radiatorWebp from "@/assets/gebos/radiator.webp"
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"
@@ -201,6 +211,27 @@ const renders = {
alt: "",
matte: "alpha",
},
/** glossy numbered badge "1" process-step numeral, genuine alpha */
glassBadge1: {
avif: glassBadge1Avif,
webp: glassBadge1Webp,
alt: "",
matte: "alpha",
},
/** glossy numbered badge "2" process-step numeral, genuine alpha */
glassBadge2: {
avif: glassBadge2Avif,
webp: glassBadge2Webp,
alt: "",
matte: "alpha",
},
/** glossy numbered badge "3" process-step numeral, genuine alpha */
glassBadge3: {
avif: glassBadge3Avif,
webp: glassBadge3Webp,
alt: "",
matte: "alpha",
},
cubesRound: {
avif: cubesRoundAvif,
webp: cubesRoundWebp,
@@ -219,6 +250,18 @@ const renders = {
alt: "",
matte: "alpha",
},
radiator: {
avif: radiatorAvif,
webp: radiatorWebp,
alt: "Heizkörper",
matte: "alpha",
},
floorHeating: {
avif: floorHeatingAvif,
webp: floorHeatingWebp,
alt: "Fußbodenheizungsrohre",
matte: "alpha",
},
workerTablet: {
avif: workerTabletAvif,
webp: workerTabletWebp,
@@ -1,5 +1,6 @@
import * as React from "react"
import { ChevronDown, Menu } from "lucide-react"
import { Link, NavLink, useLocation } from "react-router-dom"
import { Link, NavLink, matchPath, useLocation } from "react-router-dom"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -21,44 +22,120 @@ const AUDIENCES = [
{ to: "/fuer-wen/installateure", label: "Installateure & Heizungsbauer" },
]
const NAV_LINKS = [
type NavItem = {
to: string
label: string
/** present ⇒ rendered as a dropdown; `to` stays the section root that marks it active */
menu?: typeof AUDIENCES
}
/** Source of truth for nav order desktop pill, dropdown and mobile sheet all read it. */
const NAV_ITEMS: NavItem[] = [
{ to: "/loesungen", label: "Lösungen" },
{ to: "/so-funktionierts", label: "So funktionierts" },
/* "Für wen?" rendered separately as dropdown */
{ to: "/fuer-wen", label: "Für wen?", menu: AUDIENCES },
{ to: "/konfigurator", label: "Konfigurator" },
{ to: "/unternehmen", label: "Unternehmen" },
{ to: "/kontakt", label: "Kontakt" },
]
/**
* Only colour changes between states: the highlight itself is one shared pill
* that slides, so a weight change here would reflow the row it has to land on.
*/
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"
"relative z-10 rounded-full px-3 py-1.5 text-sm font-medium transition-colors duration-300",
active ? "text-brand-800" : "text-foreground/70 hover:text-navy"
)
function ForWhomMenu() {
const { pathname } = useLocation()
const active = pathname.startsWith("/fuer-wen")
/**
* Slide + width morph. The ease overshoots just enough to read as a spring
* overshoot scales with travel distance, so anything springier bleeds the pill
* out of the row and into the logo on a full-width jump.
*/
const pillClass = cn(
"pointer-events-none absolute top-0 left-0 rounded-full bg-accent ring-1 ring-brand-200",
"transition-[transform,width,height,opacity] duration-[440ms] ease-[cubic-bezier(0.34,1.16,0.42,1)]",
"motion-reduce:transition-none"
)
type Pill = {
left: number
top: number
width: number
height: number
/** no nav item matches the route (home, Klarpreis …) fade out, keep the geometry */
visible: boolean
}
const samePill = (a: Pill, b: Pill) =>
a.left === b.left &&
a.top === b.top &&
a.width === b.width &&
a.height === b.height &&
a.visible === b.visible
/**
* Measures the active item so a single highlight can travel between menus.
* Re-measures whenever the row resizes (viewport, font swap), and reads layout
* before paint so the pill never shows up a frame late at the old position.
*/
function useActivePill(activeKey: string | undefined) {
const navRef = React.useRef<HTMLElement | null>(null)
const [pill, setPill] = React.useState<Pill | null>(null)
React.useLayoutEffect(() => {
const nav = navRef.current
if (!nav) return
const measure = () => {
const item = activeKey
? nav.querySelector<HTMLElement>(`[data-nav="${activeKey}"]`)
: null
setPill((prev) => {
if (!item) return prev?.visible ? { ...prev, visible: false } : prev
const next: Pill = {
left: item.offsetLeft,
top: item.offsetTop,
width: item.offsetWidth,
height: item.offsetHeight,
visible: true,
}
return prev && samePill(prev, next) ? prev : next
})
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(nav)
return () => observer.disconnect()
}, [activeKey])
return { navRef, pill }
}
function AudienceMenu({ item, active }: { item: NavItem; active: boolean }) {
return (
<DropdownMenu>
<DropdownMenuTrigger
data-nav={item.to}
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?
{item.label}
<ChevronDown className="size-3.5 opacity-60" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem asChild>
<Link to="/fuer-wen">Übersicht</Link>
<Link to={item.to}>Übersicht</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{AUDIENCES.map((a) => (
{item.menu?.map((a) => (
<DropdownMenuItem key={a.to} asChild>
<Link to={a.to}>{a.label}</Link>
</DropdownMenuItem>
@@ -68,6 +145,45 @@ function ForWhomMenu() {
)
}
function DesktopNav() {
const { pathname } = useLocation()
/** mirrors NavLink's default (non-`end`) matching, but for the whole row at once */
const activeKey = NAV_ITEMS.find((item) =>
matchPath({ path: item.to, end: false }, pathname)
)?.to
const { navRef, pill } = useActivePill(activeKey)
return (
<nav ref={navRef} className="relative hidden items-center gap-1 lg:flex">
{pill && (
<span
aria-hidden
className={cn(pillClass, pill.visible ? "opacity-100" : "opacity-0")}
style={{
transform: `translate3d(${pill.left}px, ${pill.top}px, 0)`,
width: pill.width,
height: pill.height,
}}
/>
)}
{NAV_ITEMS.map((item) =>
item.menu ? (
<AudienceMenu key={item.to} item={item} active={item.to === activeKey} />
) : (
<NavLink
key={item.to}
to={item.to}
data-nav={item.to}
className={navItemClass(item.to === activeKey)}
>
{item.label}
</NavLink>
)
)}
</nav>
)
}
function MobileMenu() {
return (
<DropdownMenu>
@@ -78,27 +194,27 @@ function MobileMenu() {
</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>
))}
{NAV_ITEMS.map((item) =>
item.menu ? (
<React.Fragment key={item.to}>
<DropdownMenuSeparator />
<DropdownMenuLabel>{item.label}</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link to={item.to}>Übersicht</Link>
</DropdownMenuItem>
{item.menu.map((a) => (
<DropdownMenuItem key={a.to} asChild>
<Link to={a.to}>{a.label}</Link>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</React.Fragment>
) : (
<DropdownMenuItem key={item.to} asChild>
<Link to={item.to}>{item.label}</Link>
</DropdownMenuItem>
)
)}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/klarpreis">Klarpreis</Link>
@@ -120,19 +236,7 @@ function SiteHeader() {
<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>
<DesktopNav />
<div className="flex items-center gap-3">
<Button asChild size="sm" className="hidden sm:inline-flex">
@@ -12,7 +12,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-5 shrink-0 cursor-pointer rounded-md border border-input bg-card shadow-pill transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
"peer flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-[5px] border-2 border-navy/25 bg-card shadow-pill transition-colors outline-none hover:border-navy/45 focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:hover:border-primary",
className
)}
{...props}
@@ -21,7 +21,7 @@ function Checkbox({
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
<CheckIcon className="size-3.5 stroke-[3]" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)