@@ -4,6 +4,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SolenOS – Messdienstleistungen einfach gemacht</title>
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48x48.png" />
|
||||
<meta
|
||||
name="description"
|
||||
content="SolenOS Component Library – shadcn-basierte Komponenten für die SolenOS Landing Pages."
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,685 @@
|
||||
import * as React from "react"
|
||||
import { ArrowRight, Plus } from "lucide-react"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checklist } from "@/components/solenos/checklist"
|
||||
|
||||
/**
|
||||
* One entry of the grid. The same record feeds all three faces a card can
|
||||
* show, so nothing has to be authored twice: teaser in the grid, single line
|
||||
* in the rail, editorial spread once it is open.
|
||||
*/
|
||||
type ExpandingCard = {
|
||||
/** stable identity – React keys and the open-card state both key off it */
|
||||
id: string
|
||||
/** small mark, carried by the teaser and (shrunk) by the rail line */
|
||||
media?: React.ReactNode
|
||||
/** larger render; only the open card has the room for it */
|
||||
visual?: React.ReactNode
|
||||
title: string
|
||||
text: string
|
||||
/** detail bullets, revealed with the open card */
|
||||
points?: string[]
|
||||
/** deep link, rendered as the open card's action */
|
||||
to?: string
|
||||
/** defaults to "Mehr erfahren" */
|
||||
linkLabel?: string
|
||||
}
|
||||
|
||||
/** which side of the container the open card sits on – the rail takes the other */
|
||||
type Side = "left" | "right"
|
||||
|
||||
/** the face a card currently shows */
|
||||
type Face = "brief" | "rail" | "open"
|
||||
|
||||
/** matches the `gap-5` of the static section grids this one grows out of */
|
||||
const GRID_GAP = 20
|
||||
/** the rail packs tighter than the grid, so a column of five still reads as one block */
|
||||
const RAIL_GAP = 12
|
||||
const RAIL_MIN_H = 68
|
||||
const RAIL_MAX_H = 108
|
||||
/** container widths, not viewport: the grid has to work in any column */
|
||||
const TWO_COL = 560
|
||||
const THREE_COL = 900
|
||||
/** below this the open card stacks its render under the copy instead of beside it */
|
||||
const SPREAD_MIN = 520
|
||||
/** share of the open card reserved for its render */
|
||||
const VISUAL_SHARE = 0.34
|
||||
/** stand-in row height for the single render before the first measurement lands */
|
||||
const FALLBACK_ROW_H = 232
|
||||
|
||||
type Frame = { x: number; y: number; w: number; h: number }
|
||||
|
||||
type Plan = {
|
||||
/** absolute box of every card, in container coordinates */
|
||||
frames: Frame[]
|
||||
/** height the container has to hold for this state */
|
||||
height: number
|
||||
cols: number
|
||||
/** open card next to a rail – false while everything is a grid, or on one column */
|
||||
split: boolean
|
||||
/** width a teaser face is laid out at */
|
||||
cardW: number
|
||||
/** width an open card gets: the full row on one column, two thirds in the split */
|
||||
openW: number
|
||||
railW: number
|
||||
/** padding of the open face – the layout needs it to turn copy into a card height */
|
||||
pad: number
|
||||
/** width reserved for the open card's render, 0 while the card is too narrow for it */
|
||||
visualW: number
|
||||
/** open card wide enough to set copy beside its render */
|
||||
spread: boolean
|
||||
}
|
||||
|
||||
const EMPTY_PLAN: Plan = {
|
||||
frames: [],
|
||||
height: 0,
|
||||
cols: 1,
|
||||
split: false,
|
||||
cardW: 0,
|
||||
openW: 0,
|
||||
railW: 0,
|
||||
pad: 24,
|
||||
visualW: 0,
|
||||
spread: false,
|
||||
}
|
||||
|
||||
const clamp = (value: number, low: number, high: number) =>
|
||||
Math.min(high, Math.max(low, value))
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, "0")
|
||||
|
||||
/**
|
||||
* The whole layout, as one pure function: every card gets an absolute frame, so
|
||||
* a state change is a transform and a size to interpolate instead of a reflow.
|
||||
* Grid, rail and open card all fall out of the same three numbers – container
|
||||
* width, measured teaser height, measured open-copy height.
|
||||
*/
|
||||
function planLayout({
|
||||
count,
|
||||
width,
|
||||
teaserHeights,
|
||||
copyHeights,
|
||||
active,
|
||||
side,
|
||||
}: {
|
||||
count: number
|
||||
width: number
|
||||
/** natural height of each teaser face, measured at grid card width */
|
||||
teaserHeights: number[]
|
||||
/** natural height of each open-card copy block, measured at open width */
|
||||
copyHeights: number[]
|
||||
active: number
|
||||
side: Side
|
||||
}): Plan {
|
||||
if (count === 0 || width <= 0) return EMPTY_PLAN
|
||||
|
||||
const cols = width >= THREE_COL ? 3 : width >= TWO_COL ? 2 : 1
|
||||
const cardW = (width - (cols - 1) * GRID_GAP) / cols
|
||||
const rows = Math.ceil(count / cols)
|
||||
|
||||
/* only trust the measurement once every card has reported, so a half-mounted
|
||||
grid never bakes a too-short row height into the open card's height */
|
||||
const measured =
|
||||
teaserHeights.length === count && teaserHeights.every((h) => h > 0)
|
||||
? Math.max(...teaserHeights)
|
||||
: 0
|
||||
const rowH = measured || FALLBACK_ROW_H
|
||||
const gridH = rows * rowH + (rows - 1) * GRID_GAP
|
||||
|
||||
const railW = (width - GRID_GAP) / 3
|
||||
const railCount = Math.max(0, count - 1)
|
||||
const split = active >= 0 && cols > 1 && railCount > 0
|
||||
const openW = split ? width - railW - GRID_GAP : width
|
||||
const spread = openW >= SPREAD_MIN
|
||||
const pad = spread ? 32 : 24
|
||||
const visualW = spread ? Math.round(openW * VISUAL_SHARE) : 0
|
||||
const copyH = (i: number) => (copyHeights[i] ?? 0) + 2 * pad
|
||||
|
||||
const layout = { cols, split, cardW, openW, railW, pad, visualW, spread }
|
||||
|
||||
/* no rail: either the plain grid, or a single column where the open card has
|
||||
nowhere to go but down */
|
||||
if (!split) {
|
||||
const frames: Frame[] = []
|
||||
const height = (i: number) =>
|
||||
i === active ? Math.max(copyH(i), rowH) : rowH
|
||||
|
||||
if (cols === 1) {
|
||||
let y = 0
|
||||
for (let i = 0; i < count; i++) {
|
||||
frames.push({ x: 0, y, w: cardW, h: height(i) })
|
||||
y += frames[i].h + GRID_GAP
|
||||
}
|
||||
return { ...layout, frames, height: y - GRID_GAP }
|
||||
}
|
||||
|
||||
let bottom = 0
|
||||
for (let i = 0; i < count; i++) {
|
||||
const frame = {
|
||||
x: (i % cols) * (cardW + GRID_GAP),
|
||||
y: Math.floor(i / cols) * (rowH + GRID_GAP),
|
||||
w: cardW,
|
||||
h: height(i),
|
||||
}
|
||||
frames.push(frame)
|
||||
bottom = Math.max(bottom, frame.y + frame.h)
|
||||
}
|
||||
return { ...layout, frames, height: Math.max(gridH, bottom) }
|
||||
}
|
||||
|
||||
/* split: the open card keeps the grid's footprint, the rest becomes a column */
|
||||
const railH = clamp(
|
||||
(gridH - (railCount - 1) * RAIL_GAP) / railCount,
|
||||
RAIL_MIN_H,
|
||||
RAIL_MAX_H
|
||||
)
|
||||
const railTotal = railCount * railH + (railCount - 1) * RAIL_GAP
|
||||
const height = Math.max(gridH, railTotal, copyH(active))
|
||||
const openX = side === "left" ? 0 : railW + GRID_GAP
|
||||
const railX = side === "left" ? openW + GRID_GAP : 0
|
||||
const railTop = (height - railTotal) / 2
|
||||
|
||||
const frames: Frame[] = []
|
||||
let slot = 0
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (i === active) {
|
||||
frames.push({ x: openX, y: 0, w: openW, h: height })
|
||||
continue
|
||||
}
|
||||
frames.push({
|
||||
x: railX,
|
||||
y: railTop + slot * (railH + RAIL_GAP),
|
||||
w: railW,
|
||||
h: railH,
|
||||
})
|
||||
slot++
|
||||
}
|
||||
|
||||
return { ...layout, frames, height }
|
||||
}
|
||||
|
||||
type Metrics = { width: number; teaser: number[]; copy: number[] }
|
||||
|
||||
const sameNumbers = (a: number[], b: number[]) =>
|
||||
a.length === b.length && a.every((v, i) => v === b[i])
|
||||
|
||||
const sameMetrics = (a: Metrics, b: Metrics) =>
|
||||
a.width === b.width &&
|
||||
sameNumbers(a.teaser, b.teaser) &&
|
||||
sameNumbers(a.copy, b.copy)
|
||||
|
||||
/**
|
||||
* Reads the container width plus the natural height of the two faces that have
|
||||
* to size themselves from real copy. Both are laid out at the width they will
|
||||
* have in their own state, so a measurement never depends on the state the grid
|
||||
* is in – and no card reflows while it travels.
|
||||
*
|
||||
* Re-reading on every width change keeps that honest: the pass runs in a layout
|
||||
* effect, so the corrected heights are in before the browser paints.
|
||||
*/
|
||||
function useGridMetrics(count: number) {
|
||||
const hostRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const teaserRefs = React.useRef<(HTMLElement | null)[]>([])
|
||||
const copyRefs = React.useRef<(HTMLElement | null)[]>([])
|
||||
const [metrics, setMetrics] = React.useState<Metrics>({
|
||||
width: 0,
|
||||
teaser: [],
|
||||
copy: [],
|
||||
})
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const host = hostRef.current
|
||||
if (!host) return
|
||||
|
||||
const heights = (nodes: (HTMLElement | null)[]) =>
|
||||
nodes
|
||||
.slice(0, count)
|
||||
.map((node) =>
|
||||
node ? Math.ceil(node.getBoundingClientRect().height) : 0
|
||||
)
|
||||
|
||||
const read = () =>
|
||||
setMetrics((prev) => {
|
||||
const next: Metrics = {
|
||||
width: host.clientWidth,
|
||||
teaser: heights(teaserRefs.current),
|
||||
copy: heights(copyRefs.current),
|
||||
}
|
||||
return sameMetrics(prev, next) ? prev : next
|
||||
})
|
||||
|
||||
read()
|
||||
|
||||
const observer = new ResizeObserver(read)
|
||||
observer.observe(host)
|
||||
for (const node of [...teaserRefs.current, ...copyRefs.current]) {
|
||||
if (node) observer.observe(node)
|
||||
}
|
||||
return () => observer.disconnect()
|
||||
/* width is a dep on purpose: a resize changes the width the faces are laid
|
||||
out at, so their heights have to be read again in the same pre-paint pass */
|
||||
}, [count, metrics.width])
|
||||
|
||||
return { hostRef, teaserRefs, copyRefs, metrics }
|
||||
}
|
||||
|
||||
/** frames travel on ease-out quint: off immediately, settling without overshoot */
|
||||
const FRAME_MOTION =
|
||||
"transition-[transform,width,height] duration-[560ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
||||
|
||||
const FACE_MOTION =
|
||||
"transition-[opacity,translate,scale] duration-300 ease-out motion-reduce:transition-none"
|
||||
|
||||
/** the leaving face clears out at once, the arriving one lets the box move first */
|
||||
const faceMotion = (visible: boolean) =>
|
||||
cn(
|
||||
FACE_MOTION,
|
||||
visible
|
||||
? "opacity-100 translate-y-0 delay-[140ms]"
|
||||
: "opacity-0 translate-y-1"
|
||||
)
|
||||
|
||||
/** faces are decoration for the button underneath – only their links take a click */
|
||||
const FACE_BASE = "pointer-events-none absolute top-0 left-0 z-20"
|
||||
|
||||
/** grid face: mark, title, full copy – the card as the static section had it */
|
||||
function TeaserFace({
|
||||
card,
|
||||
width,
|
||||
visible,
|
||||
innerRef,
|
||||
}: {
|
||||
card: ExpandingCard
|
||||
width: number
|
||||
visible: boolean
|
||||
innerRef: (node: HTMLElement | null) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
ref={innerRef}
|
||||
inert={!visible}
|
||||
aria-hidden={!visible}
|
||||
style={{ width }}
|
||||
className={cn(
|
||||
FACE_BASE,
|
||||
"flex flex-col items-start gap-3 p-6",
|
||||
faceMotion(visible)
|
||||
)}
|
||||
>
|
||||
{card.media}
|
||||
<h3 className="text-base font-bold text-balance text-navy">
|
||||
{card.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-pretty text-muted-foreground">
|
||||
{card.text}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** rail face: one line per card, the mark shrunk to keep the row scannable */
|
||||
function RailFace({
|
||||
card,
|
||||
width,
|
||||
visible,
|
||||
}: {
|
||||
card: ExpandingCard
|
||||
width: number
|
||||
visible: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
inert={!visible}
|
||||
aria-hidden={!visible}
|
||||
style={{ width }}
|
||||
className={cn(
|
||||
FACE_BASE,
|
||||
"flex h-full items-center gap-3 pr-13 pl-5",
|
||||
faceMotion(visible)
|
||||
)}
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center [&_img]:size-9 [&_picture]:size-9">
|
||||
{card.media}
|
||||
</span>
|
||||
{/* long compounds ("Verbrauchsinformation") have to be allowed to break,
|
||||
or the clamp never gets to put its ellipsis anywhere */}
|
||||
<h3 className="line-clamp-2 min-w-0 text-[13px] leading-snug font-bold break-words hyphens-auto text-navy">
|
||||
{card.title}
|
||||
</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open face: index, title, the copy in full, the detail bullets and the deep
|
||||
* link. Padding is inline because the layout has to add it to the measured copy
|
||||
* height to know how tall the card must be on a single column.
|
||||
*/
|
||||
function OpenFace({
|
||||
card,
|
||||
index,
|
||||
total,
|
||||
plan,
|
||||
visible,
|
||||
panelId,
|
||||
innerRef,
|
||||
}: {
|
||||
card: ExpandingCard
|
||||
index: number
|
||||
total: number
|
||||
plan: Plan
|
||||
visible: boolean
|
||||
panelId: string
|
||||
innerRef: (node: HTMLElement | null) => void
|
||||
}) {
|
||||
const { openW, pad, visualW, spread } = plan
|
||||
return (
|
||||
<div
|
||||
id={panelId}
|
||||
inert={!visible}
|
||||
aria-hidden={!visible}
|
||||
style={{
|
||||
width: openW,
|
||||
padding: pad,
|
||||
paddingRight: visualW ? visualW + pad : pad,
|
||||
}}
|
||||
className={cn(
|
||||
FACE_BASE,
|
||||
"flex h-full flex-col justify-center",
|
||||
faceMotion(visible)
|
||||
)}
|
||||
>
|
||||
<div ref={innerRef} className="flex max-w-lg flex-col gap-3">
|
||||
<p className="flex items-center gap-3 text-[11px] font-bold tracking-[0.16em] text-primary uppercase">
|
||||
<span className="tabular-nums">{pad2(index + 1)}</span>
|
||||
<span aria-hidden className="h-px w-8 bg-brand-200" />
|
||||
<span className="tabular-nums text-brand-300">{pad2(total)}</span>
|
||||
</p>
|
||||
<h3
|
||||
className={cn(
|
||||
"font-extrabold tracking-tight text-balance text-navy",
|
||||
spread ? "text-2xl" : "text-xl"
|
||||
)}
|
||||
>
|
||||
{card.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-pretty text-muted-foreground">
|
||||
{card.text}
|
||||
</p>
|
||||
{card.points?.length ? (
|
||||
<Checklist items={card.points} className="mt-1" />
|
||||
) : null}
|
||||
{card.to ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="link"
|
||||
className="pointer-events-auto mt-1 h-auto self-start p-0 font-bold"
|
||||
>
|
||||
<Link to={card.to}>
|
||||
{card.linkLabel ?? "Mehr erfahren"}
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{!spread && card.visual ? (
|
||||
<span className="mt-3 flex justify-center">{card.visual}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle disc, borrowed from the FAQ list so both disclosures speak the same
|
||||
* language: hairline teal plus that fills in and turns into an × once its card
|
||||
* opens, and slides to the middle of the row while the card is in the rail.
|
||||
*/
|
||||
function CardToggleMark({ face }: { face: Face }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute z-30 flex items-center justify-center rounded-full border border-brand-100 bg-card text-primary shadow-pill",
|
||||
"transition-all duration-300 motion-reduce:transition-none",
|
||||
face === "rail"
|
||||
? "top-1/2 right-4 size-7 -translate-y-1/2"
|
||||
: "top-5 right-5 size-8",
|
||||
face === "open"
|
||||
? "rotate-45 border-transparent bg-primary text-primary-foreground"
|
||||
: "group-hover:border-brand-200 group-hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" strokeWidth={2.5} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function GridCard({
|
||||
card,
|
||||
index,
|
||||
total,
|
||||
face,
|
||||
frame,
|
||||
plan,
|
||||
delay,
|
||||
animate,
|
||||
panelId,
|
||||
onToggle,
|
||||
teaserRef,
|
||||
copyRef,
|
||||
}: {
|
||||
card: ExpandingCard
|
||||
index: number
|
||||
total: number
|
||||
face: Face
|
||||
frame: Frame | undefined
|
||||
plan: Plan
|
||||
delay: number
|
||||
animate: boolean
|
||||
panelId: string
|
||||
onToggle: () => void
|
||||
teaserRef: (node: HTMLElement | null) => void
|
||||
copyRef: (node: HTMLElement | null) => void
|
||||
}) {
|
||||
const open = face === "open"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0",
|
||||
animate ? FRAME_MOTION : "transition-none",
|
||||
open ? "z-20" : "z-10"
|
||||
)}
|
||||
style={{
|
||||
transform: `translate3d(${frame?.x ?? 0}px, ${frame?.y ?? 0}px, 0)`,
|
||||
width: frame?.w ?? 0,
|
||||
height: frame?.h ?? 0,
|
||||
transitionDelay: `${delay}ms`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-face={face}
|
||||
className={cn(
|
||||
"group relative size-full overflow-hidden rounded-2xl border bg-card",
|
||||
"transition-[background-color,border-color,box-shadow,translate] duration-300 motion-reduce:transition-none",
|
||||
"border-border/70 shadow-card",
|
||||
"data-[face=brief]:hover:-translate-y-0.5 data-[face=brief]:hover:border-brand-200 data-[face=brief]:hover:shadow-card-lg",
|
||||
"data-[face=rail]:hover:border-brand-200 data-[face=rail]:hover:bg-accent/40",
|
||||
"data-[face=open]:border-brand-200 data-[face=open]:shadow-card-lg"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 bg-gradient-to-br from-brand-50 via-card to-card",
|
||||
FACE_MOTION,
|
||||
open ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{plan.visualW && card.visual ? (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{ width: plan.visualW }}
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-y-0 right-0 z-10 flex items-center justify-center p-6",
|
||||
FACE_MOTION,
|
||||
open ? "scale-100 opacity-100" : "scale-95 opacity-0"
|
||||
)}
|
||||
>
|
||||
{card.visual}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
aria-label={card.title}
|
||||
className="absolute inset-0 z-10 cursor-pointer rounded-2xl outline-none focus-visible:inset-ring-[3px] focus-visible:inset-ring-ring/45"
|
||||
/>
|
||||
|
||||
<TeaserFace
|
||||
card={card}
|
||||
width={plan.cardW}
|
||||
visible={face === "brief"}
|
||||
innerRef={teaserRef}
|
||||
/>
|
||||
<RailFace card={card} width={plan.railW} visible={face === "rail"} />
|
||||
<OpenFace
|
||||
card={card}
|
||||
index={index}
|
||||
total={total}
|
||||
plan={plan}
|
||||
visible={open}
|
||||
panelId={panelId}
|
||||
innerRef={copyRef}
|
||||
/>
|
||||
<CardToggleMark face={face} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Card grid that unpacks in place. Clicking a card grows it to the full height
|
||||
* of the grid it was part of, while the remaining cards gather into a column of
|
||||
* a third of the width beside it – on the side the open card came *from*, so
|
||||
* the card you picked stays where you clicked it and everything else moves out
|
||||
* of its way. Opening a card out of the rail therefore flips the composition.
|
||||
*
|
||||
* Every card is absolutely positioned from measured geometry, which keeps a
|
||||
* state change to a transform plus a size: the faces are pre-laid out at the
|
||||
* width they end up with, so no copy re-wraps mid-flight. Under two columns the
|
||||
* split has no room, and the open card grows in place instead.
|
||||
*/
|
||||
function ExpandingCardGrid({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: ExpandingCard[]
|
||||
className?: string
|
||||
}) {
|
||||
const [open, setOpen] = React.useState<{ id: string; side: Side } | null>(
|
||||
null
|
||||
)
|
||||
const { hostRef, teaserRefs, copyRefs, metrics } = useGridMetrics(
|
||||
items.length
|
||||
)
|
||||
const uid = React.useId()
|
||||
|
||||
const active = open ? items.findIndex((item) => item.id === open.id) : -1
|
||||
const plan = planLayout({
|
||||
count: items.length,
|
||||
width: metrics.width,
|
||||
teaserHeights: metrics.teaser,
|
||||
copyHeights: metrics.copy,
|
||||
active,
|
||||
side: open?.side ?? "left",
|
||||
})
|
||||
|
||||
/* transitions only after the measured geometry has been painted once, and
|
||||
never for the reflow of a resize – dragging a window edge should track the
|
||||
pointer, not trail half a second behind it */
|
||||
const [ready, setReady] = React.useState(false)
|
||||
React.useEffect(() => setReady(true), [])
|
||||
const painted = React.useRef(0)
|
||||
const resized = painted.current !== metrics.width
|
||||
/* written after the commit, not during the render, so React's double
|
||||
invocation in development cannot swallow the flag */
|
||||
React.useEffect(() => {
|
||||
painted.current = metrics.width
|
||||
})
|
||||
const animate = ready && !resized
|
||||
|
||||
const toggle = (index: number) => {
|
||||
if (index === active) {
|
||||
setOpen(null)
|
||||
return
|
||||
}
|
||||
const frame = plan.frames[index]
|
||||
/* the card keeps its side: whichever half it is sitting in when clicked is
|
||||
the half it opens into, so the rail always forms on the other one */
|
||||
const onRight = frame
|
||||
? frame.x + frame.w / 2 > metrics.width / 2 + 1
|
||||
: false
|
||||
setOpen({ id: items[index].id, side: onRight ? "right" : "left" })
|
||||
}
|
||||
|
||||
let slot = 0
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && active >= 0) {
|
||||
event.stopPropagation()
|
||||
setOpen(null)
|
||||
}
|
||||
}}
|
||||
style={{ height: plan.height || undefined }}
|
||||
className={cn(
|
||||
"relative",
|
||||
animate
|
||||
? "transition-[height] duration-[560ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
||||
: "transition-none",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{items.map((card, index) => {
|
||||
const face: Face =
|
||||
index === active ? "open" : plan.split ? "rail" : "brief"
|
||||
/* the rail arrives as a cascade; everything else moves at once */
|
||||
const delay = face === "rail" ? slot++ * 26 : 0
|
||||
|
||||
return (
|
||||
<GridCard
|
||||
key={card.id}
|
||||
card={card}
|
||||
index={index}
|
||||
total={items.length}
|
||||
face={face}
|
||||
frame={plan.frames[index]}
|
||||
plan={plan}
|
||||
delay={delay}
|
||||
animate={animate}
|
||||
panelId={`${uid}-${card.id}`}
|
||||
onToggle={() => toggle(index)}
|
||||
teaserRef={(node) => {
|
||||
teaserRefs.current[index] = node
|
||||
}}
|
||||
copyRef={(node) => {
|
||||
copyRefs.current[index] = node
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { ExpandingCardGrid }
|
||||
export type { ExpandingCard }
|
||||
@@ -17,6 +17,10 @@ import { InfoStrip } from "@/components/solenos/info-strip"
|
||||
import { NumberedList, StepFlow } from "@/components/solenos/process-steps"
|
||||
import { Checklist } from "@/components/solenos/checklist"
|
||||
import { CtaBanner } from "@/components/solenos/cta-banner"
|
||||
import {
|
||||
ExpandingCardGrid,
|
||||
type ExpandingCard,
|
||||
} from "@/components/solenos/expanding-card-grid"
|
||||
import { FaqAccordion, type FaqItem } from "@/components/solenos/faq-accordion"
|
||||
import { NoticeArt, NoticeBanner } from "@/components/solenos/notice-banner"
|
||||
import { GermanyIcon } from "@/components/solenos/line-icons"
|
||||
@@ -67,32 +71,89 @@ const infoStripItems = [
|
||||
},
|
||||
]
|
||||
|
||||
const operationsScope = [
|
||||
const operationsScope: ExpandingCard[] = [
|
||||
{
|
||||
id: "uvi",
|
||||
media: <img src={icons.uvi} alt="" className="size-12" loading="lazy" />,
|
||||
visual: (
|
||||
<AssetRender
|
||||
render={renders.uviGlassPane}
|
||||
className="flex w-full justify-center"
|
||||
imgClassName="w-full max-w-60"
|
||||
/>
|
||||
),
|
||||
title: "Unterjährige Verbrauchsinformation",
|
||||
text: "Verbrauchsdaten werden automatisch erfasst und die regelmäßigen Verbrauchsinformationen für die Bewohner bereitgestellt.",
|
||||
points: [
|
||||
"monatliche Erzeugung der Verbrauchsinformation",
|
||||
"Bereitstellung im Mieterportal für jede Wohnung",
|
||||
"Nutzerwechsel ohne Ablesetermin vor Ort",
|
||||
],
|
||||
to: "/produkte/uvi",
|
||||
},
|
||||
{
|
||||
id: "heizkostenabrechnung",
|
||||
media: (
|
||||
<img src={icons.billing} alt="" className="size-12" loading="lazy" />
|
||||
),
|
||||
visual: (
|
||||
<AssetRender
|
||||
render={renders.glassMoney}
|
||||
className="flex w-full justify-center"
|
||||
imgClassName="w-full max-w-52"
|
||||
/>
|
||||
),
|
||||
title: "Heizkostenabrechnung",
|
||||
text: "Die erfassten Verbrauchsdaten bilden die Grundlage für die jährliche Heizkostenabrechnung.",
|
||||
points: [
|
||||
"Verteilung der Wärme- und Warmwasserkosten nach HeizkostenV",
|
||||
"Berücksichtigung von Nutzerwechseln und Leerständen",
|
||||
"Einzelabrechnung je Wohnung mit den Pflichtangaben nach § 6a Absatz 3",
|
||||
],
|
||||
to: "/produkte/heizkostenabrechnung",
|
||||
},
|
||||
{
|
||||
id: "rauchwarnmelder",
|
||||
media: (
|
||||
<img src={icons.smokeAlarm} alt="" className="size-12" loading="lazy" />
|
||||
),
|
||||
visual: (
|
||||
<AssetRender
|
||||
render={renders.glassSmokeDetector}
|
||||
className="flex w-full justify-center"
|
||||
imgClassName="w-full max-w-48"
|
||||
/>
|
||||
),
|
||||
title: "Rauchwarnmelder",
|
||||
text: "Rauchwarnmelder können gemeinsam mit der übrigen Gebäudetechnik geplant, installiert und zentral verwaltet werden.",
|
||||
points: [
|
||||
"Rauchwarnmelder inklusive Montage und Eigentumsübertragung",
|
||||
"Bedarfsermittlung je Wohnung aus den Gebäudedaten",
|
||||
"regelmäßige Ferninspektion und Störungsmeldungen",
|
||||
],
|
||||
to: "/produkte/rauchwarnmelder",
|
||||
},
|
||||
{
|
||||
id: "messtechnik",
|
||||
media: <img src={icons.radio} alt="" className="size-12" loading="lazy" />,
|
||||
visual: (
|
||||
<AssetRender
|
||||
render={renders.heatMeter}
|
||||
className="flex w-full justify-center"
|
||||
imgClassName="w-full max-w-52"
|
||||
/>
|
||||
),
|
||||
title: "Fernauslesbare Messtechnik",
|
||||
text: "Wärme- und Wasserverbrauch werden über kompatible Geräte automatisch erfasst.",
|
||||
points: [
|
||||
"Wärmezähler, Heizkostenverteiler und Wasserzähler messen laufend je Wohnung",
|
||||
"Geräte sind fernablesbar, also ohne Zugang zur Wohnung auslesbar",
|
||||
"die Funkschnittstelle folgt einem offenen Standard (OMS)",
|
||||
],
|
||||
to: "/produkte/messtechnik-infrastruktur",
|
||||
},
|
||||
{
|
||||
id: "gateway",
|
||||
media: (
|
||||
<AssetRender
|
||||
render={renders.gateway}
|
||||
@@ -100,15 +161,42 @@ const operationsScope = [
|
||||
imgClassName="w-12"
|
||||
/>
|
||||
),
|
||||
visual: (
|
||||
<AssetRender
|
||||
render={renders.glassGateway}
|
||||
className="flex w-full justify-center"
|
||||
imgClassName="w-full max-w-52"
|
||||
/>
|
||||
),
|
||||
title: "Gateway und Datenübertragung",
|
||||
text: "Die Messdaten werden zentral gesammelt und an SolenOS übertragen.",
|
||||
points: [
|
||||
"ein Gateway je Gebäude empfängt die Funkpakete aller angebundenen Geräte",
|
||||
"Übertragung per Mobilfunk, ohne Internetanschluss im Gebäude",
|
||||
"TLS-verschlüsselte Übertragung an die Plattform",
|
||||
],
|
||||
to: "/produkte/messtechnik-infrastruktur",
|
||||
linkLabel: "Messtechnik und Infrastruktur",
|
||||
},
|
||||
{
|
||||
id: "portale",
|
||||
media: (
|
||||
<img src={icons.portal} alt="" className="size-12" loading="lazy" />
|
||||
),
|
||||
visual: (
|
||||
<AssetRender
|
||||
render={renders.laptopDashboard}
|
||||
className="flex w-full justify-center"
|
||||
imgClassName="w-full"
|
||||
/>
|
||||
),
|
||||
title: "Portale und Verwaltung",
|
||||
text: "Gebäude, Wohnungen, Geräte und Verbrauchsdaten werden zentral verwaltet. Bewohner erhalten Zugriff auf die für sie vorgesehenen Informationen.",
|
||||
points: [
|
||||
"Portalzugang für Eigentümer und Verwaltung",
|
||||
"Bewohner rufen ihre Information im Mieterportal ab",
|
||||
"rollenbasierte Zugriffsrechte",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -443,21 +531,7 @@ export default function HomePage() {
|
||||
title="Alles, was für den laufenden Messbetrieb benötigt wird"
|
||||
lead="SolenOS verbindet Messtechnik, Datenerfassung und Software zu einem durchgängigen System. Statt einzelne Komponenten selbst zusammenzustellen und miteinander zu verbinden, erhalten Sie eine Lösung, bei der Bestellung, Hardware, Konfiguration und laufender Betrieb zusammenspielen."
|
||||
/>
|
||||
<div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{operationsScope.map((item) => (
|
||||
<Card key={item.title} className="p-6 gap-0">
|
||||
<CardContent className="flex h-full flex-col items-start gap-3 p-0">
|
||||
{item.media}
|
||||
<h3 className="text-base font-bold text-navy">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{item.text}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<ExpandingCardGrid items={operationsScope} className="mt-12" />
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
|
||||
Reference in New Issue
Block a user