Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions app/src/app/[tenant]/anleitung/SpurInhalt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/**
* SpurInhalt.tsx — rendert EINE Anleitungs-Spur nach dem festen Muster:
* erster Satz → „So läuft es ab" → „Das sollten Sie wissen" → Nachschlag-Fragen
* → weiterführende Links.
*
* SERVER-KOMPONENTE OHNE JavaScript-Bedarf: Die Seiten müssen ohne JS vollständig
* lesbar sein — das ist der Vorteil gegenüber einer Folienmechanik. Deshalb
* natives details/summary statt Akkordeon-Skript, Listen statt Karussell, keine
* Interaktion, die Zustand braucht.
*
* ÜBERSCHRIFTEN-HIERARCHIE: Der Aufrufer gibt über `ebene` an, auf welcher Stufe
* die Abschnitts-Überschriften liegen — 2 auf der Ein-Spur-Seite (h1 ist dort der
* Spur-Titel), 3 auf der Sammelseite (dort ist h2 der Spur-Titel). Damit
* entstehen in beiden Fällen lückenlose Hierarchien (h1 → h2 → h3), was das
* a11y-Gate der CI erwartet.
*/

import Link from "next/link";
import type { AnleitungLink, AnleitungSpur } from "./anleitung-daten";

/** Tenant-relative Pfade bekommen das Slug-Präfix; `absolut` bleibt unberührt. */
export function anleitungHref(link: AnleitungLink, slug: string): string {
return link.absolut ? link.href : `/${slug}${link.href}`;
}

const LINK_KLASSEN =
"font-medium underline-offset-4 hover:underline rounded-sm " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--pz-brand)]";

/** Ein Sprung-Link in die App — intern über next/link, absolut als normales <a>. */
function SprungLink({ link, slug }: { link: AnleitungLink; slug: string }) {
const href = anleitungHref(link, slug);
const inhalt = (
<>
{link.label} <span aria-hidden>→</span>
</>
);
if (link.absolut) {
return (
<a href={href} className={LINK_KLASSEN} style={{ color: "var(--pz-brand-strong)" }}>
{inhalt}
</a>
);
}
return (
<Link href={href} className={LINK_KLASSEN} style={{ color: "var(--pz-brand-strong)" }}>
{inhalt}
</Link>
);
}

/**
* Abschnitts-Überschrift auf der vom Aufrufer bestimmten Ebene. Bewusst nur h2/h3:
* tiefer wird die Anleitung nicht, und h1 gehört immer der Seite.
*/
function AbschnittTitel({
ebene,
id,
children,
}: {
ebene: 2 | 3;
id: string;
children: React.ReactNode;
}) {
const klassen = ebene === 2 ? "text-xl font-semibold" : "text-lg font-semibold";
if (ebene === 2) {
return (
<h2 id={id} className={klassen} style={{ color: "var(--pz-ink)" }}>
{children}
</h2>
);
}
return (
<h3 id={id} className={klassen} style={{ color: "var(--pz-ink)" }}>
{children}
</h3>
);
}

export default function SpurInhalt({
spur,
slug,
ebene,
}: {
spur: AnleitungSpur;
slug: string;
ebene: 2 | 3;
}) {
return (
<>
<p className="mt-3 text-base leading-relaxed" style={{ color: "var(--pz-body)" }}>
{spur.ersterSatz}
</p>

{/* --- So läuft es ab ------------------------------------------------ */}
<section className="mt-8" aria-labelledby={`${spur.id}-ablauf`}>
<AbschnittTitel ebene={ebene} id={`${spur.id}-ablauf`}>
So läuft es ab
</AbschnittTitel>
{/* Die Nummerierung kommt aus dem <ol> (Screenreader lesen sie mit); die
sichtbare Ziffer ist deshalb dekorativ und wird ausgeblendet. */}
<ol className="mt-4 space-y-4">
{spur.schritte.map((schritt, i) => (
<li key={schritt.titel} className="pz-card flex gap-4 p-5">
<span
aria-hidden
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-semibold tabular-nums"
style={{ backgroundColor: "var(--pz-brand-soft)", color: "var(--pz-brand-strong)" }}
>
{i + 1}
</span>
<div className="min-w-0">
<p className="font-semibold" style={{ color: "var(--pz-ink)" }}>
{schritt.titel}
</p>
<p className="mt-1 text-sm leading-relaxed" style={{ color: "var(--pz-body)" }}>
{schritt.text}
</p>
{schritt.link && (
<p className="mt-2 text-sm">
<SprungLink link={schritt.link} slug={slug} />
</p>
)}
</div>
</li>
))}
</ol>
</section>

{/* --- Das sollten Sie wissen ---------------------------------------- */}
<section className="mt-10" aria-labelledby={`${spur.id}-wissen`}>
<AbschnittTitel ebene={ebene} id={`${spur.id}-wissen`}>
Das sollten Sie wissen
</AbschnittTitel>
<ul className="mt-4 space-y-4">
{spur.wissen.map((h) => (
<li key={h.titel} className="pz-card p-5">
<p className="font-semibold" style={{ color: "var(--pz-ink)" }}>
{h.titel}
</p>
<p className="mt-1 text-sm leading-relaxed" style={{ color: "var(--pz-body)" }}>
{h.text}
</p>
</li>
))}
</ul>
</section>

{/* --- Nachschlag-Fragen (details/summary, ohne JavaScript bedienbar) - */}
<section className="mt-10" aria-labelledby={`${spur.id}-fragen`}>
<AbschnittTitel ebene={ebene} id={`${spur.id}-fragen`}>
Wenn etwas nicht klappt
</AbschnittTitel>
<div className="mt-4 space-y-3">
{spur.fragen.map((q) => (
<details key={q.f} className="pz-card p-4">
<summary
className="cursor-pointer text-sm font-semibold marker:text-[color:var(--pz-brand-strong)]"
style={{ color: "var(--pz-ink)" }}
>
{q.f}
</summary>
<p className="mt-2 text-sm leading-relaxed" style={{ color: "var(--pz-body)" }}>
{q.a}
</p>
</details>
))}
</div>
</section>

{/* --- Weiterlesen: verlinken statt Inhalte doppelt pflegen ----------- */}
{spur.weiter.length > 0 && (
<section className="mt-10" aria-labelledby={`${spur.id}-weiter`}>
<AbschnittTitel ebene={ebene} id={`${spur.id}-weiter`}>
Weiterlesen
</AbschnittTitel>
<ul className="mt-3 space-y-2 text-sm">
{spur.weiter.map((w) => (
<li key={w.href}>
<SprungLink link={w} slug={slug} />
</li>
))}
</ul>
</section>
)}
</>
);
}
137 changes: 137 additions & 0 deletions app/src/app/[tenant]/anleitung/__tests__/anleitung-daten.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* anleitung-daten.test.ts — Struktur-Netz für die Anleitungs-Inhalte.
*
* OHNE DATENBANK, ohne Rendering: reine Datenprüfung. Der Zweck ist nicht,
* Formulierungen festzunageln (die soll der Owner frei ändern können), sondern
* die Zusagen der Struktur zu halten:
*
* - Jede Spur hat das vollständige Muster (erster Satz, Schritte, Wissen,
* Fragen) — eine halb gefüllte Spur wäre in der Oberfläche ein Loch.
* - Anker-Ids sind eindeutig und stabil: die Abholseite und das
* Inhaltsverzeichnis springen darauf.
* - Jede real betriebene Rolle findet einen Abschnitt; die Betreiberrolle und
* die Reserve-Rollen finden BEWUSST keinen.
* - Links sind tenant-relativ (führendes „/") — sonst landet ein Klick auf
* dem falschen Mandanten. Einzige Ausnahme: `absolut` (Präsentations-Deck).
*/

import { describe, it, expect } from "vitest";
import {
AUFGABEN_SPUREN,
BUERGER_SPUR,
EINSTIEG_KARTEN,
ROLLE_ZU_ABSCHNITT,
abschnitteFuerRollen,
type AnleitungLink,
type AnleitungSpur,
} from "../anleitung-daten";

const ALLE_SPUREN: AnleitungSpur[] = [BUERGER_SPUR, ...AUFGABEN_SPUREN];

function alleLinks(spur: AnleitungSpur): AnleitungLink[] {
return [
...spur.schritte.flatMap((s) => (s.link ? [s.link] : [])),
...spur.weiter,
];
}

describe("Anleitungs-Daten: Struktur", () => {
it("jede Spur ist vollständig", () => {
for (const spur of ALLE_SPUREN) {
expect(spur.id, "Anker-Id fehlt").toMatch(/^[a-z][a-z-]*$/);
expect(spur.titel.length, `${spur.id}: Titel`).toBeGreaterThan(0);
expect(spur.kurz.length, `${spur.id}: Kurzfassung`).toBeGreaterThan(0);
expect(spur.ersterSatz.length, `${spur.id}: erster Satz`).toBeGreaterThan(0);
expect(spur.schritte.length, `${spur.id}: Schritte`).toBeGreaterThan(0);
expect(spur.wissen.length, `${spur.id}: „Das sollten Sie wissen"`).toBeGreaterThan(0);
expect(spur.fragen.length, `${spur.id}: Nachschlag-Fragen`).toBeGreaterThan(0);
}
});

it("Anker-Ids sind eindeutig", () => {
const ids = ALLE_SPUREN.map((s) => s.id);
expect(new Set(ids).size).toBe(ids.length);
});

it("Schritt-Titel und Fragen sind je Spur eindeutig (sie dienen als React-key)", () => {
for (const spur of ALLE_SPUREN) {
const titel = spur.schritte.map((s) => s.titel);
expect(new Set(titel).size, `${spur.id}: doppelter Schritt-Titel`).toBe(titel.length);
const fragen = spur.fragen.map((f) => f.f);
expect(new Set(fragen).size, `${spur.id}: doppelte Frage`).toBe(fragen.length);
const wissen = spur.wissen.map((w) => w.titel);
expect(new Set(wissen).size, `${spur.id}: doppelter Hinweis-Titel`).toBe(wissen.length);
}
});

it("höchstens ein Sprung-Link je Schritt und alle Links sind tenant-relativ", () => {
for (const spur of ALLE_SPUREN) {
for (const link of alleLinks(spur)) {
expect(link.label.length, `${spur.id}: Link ohne Beschriftung`).toBeGreaterThan(0);
expect(link.href, `${spur.id}: ${link.label}`).toMatch(/^\//);
// Keine absoluten URLs in den Spuren — die Anleitung verlinkt in die App.
expect(link.href.startsWith("//"), `${spur.id}: ${link.label}`).toBe(false);
}
}
});
});

describe("Anleitungs-Daten: Abholseite", () => {
it("bietet genau die drei Situationen an", () => {
expect(EINSTIEG_KARTEN.map((k) => k.key)).toEqual([
"mitmachen",
"aufgaben",
"vorstellen",
]);
});

it("die Bürger- und die Rollenträger-Karte zeigen auf existierende Routen", () => {
const ziele = EINSTIEG_KARTEN.filter((k) => !k.link.absolut).map((k) => k.link.href);
expect(ziele).toEqual(["/anleitung/mitmachen", "/anleitung/aufgaben"]);
});

it("die Präsentation wird absolut verlinkt (liegt außerhalb des Tenant-Routings)", () => {
const deck = EINSTIEG_KARTEN.find((k) => k.key === "vorstellen");
expect(deck?.link.absolut).toBe(true);
expect(deck?.link.href).toBe("/praesentation");
});
});

describe("Anleitungs-Daten: Rollen-Zuordnung", () => {
it("jede in Betrieb befindliche Rolle findet einen Abschnitt", () => {
for (const rolle of ["verifier", "redakteur", "kommune_admin", "beobachter"]) {
const treffer = abschnitteFuerRollen([rolle]);
expect(treffer.length, `${rolle} ohne Abschnitt`).toBe(1);
expect(AUFGABEN_SPUREN.map((s) => s.id)).toContain(treffer[0].spurId);
}
});

it("Betreiberrolle und Reserve-Rollen bekommen bewusst KEINEN Abschnitt", () => {
for (const rolle of ["super_admin", "ortsteil_admin", "kreis_admin", "land_admin", "user"]) {
expect(abschnitteFuerRollen([rolle]), `${rolle} sollte leer sein`).toEqual([]);
}
});

it("Mehrfachrollen ergeben mehrere Abschnitte, ohne Dopplung und in fester Reihenfolge", () => {
const treffer = abschnitteFuerRollen([
"beobachter",
"verifier",
"verifier",
"unbekannte_rolle",
]);
expect(treffer.map((t) => t.spurId)).toEqual(["verifizierung", "beobachtung"]);
});

it("ohne Rollen (ausgeloggt) gibt es keinen persönlichen Hinweis", () => {
expect(abschnitteFuerRollen([])).toEqual([]);
});

it("jede Zuordnung zeigt auf eine existierende Spur", () => {
for (const [rolle, eintrag] of Object.entries(ROLLE_ZU_ABSCHNITT)) {
expect(
AUFGABEN_SPUREN.some((s) => s.id === eintrag.spurId),
`${rolle} → ${eintrag.spurId} existiert nicht`,
).toBe(true);
}
});
});
Loading
Loading