save
This commit is contained in:
@@ -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 funktioniert’s" },
|
||||
/* "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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user