save
Deploy Static Site / deploy (push) Successful in 10m9s

This commit is contained in:
Lars Nolden
2026-08-14 15:57:41 +02:00
parent 4f250eb1c2
commit 44fe30a781
7 changed files with 779 additions and 16 deletions
+4
View File
@@ -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 }
+90 -16
View File
@@ -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>