syncova-policies/app/erklaerung/[slug]/page.tsx
Weapie 3633e8a4cf
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 1m33s
Responsiv machen und Barrierefreiheit nach WCAG 2.1 AA herstellen
Rechtsrahmen: § 13 Abs. 3 LBGG SH i. V. m. §§ 3 und 4 BITV 2.0. Eine
Erklaerung "nicht barrierefrei" ersetzt die Herstellungspflicht nicht,
sie dokumentiert den Stand nur voruebergehend.

Farben, alle Werte gegen die WCAG-Formel nachgerechnet:
- Sekundaertext 4.40:1 auf muted-Flaechen -> 4.55:1
- Rahmen von Eingabefeldern 1.24:1 -> 3:1 (WCAG 1.4.11); dekorative
  Trenner bleiben unveraendert
- Badge-Farben scheiterten auf der eigenen 10-Prozent-Flaeche
  (brand 4.20, success 4.18, destructive 3.88) -> jeweils ueber 4.5
- Warnfarbe im Admin 3.11:1 -> amber-700 mit 4.90:1

Fokus war faktisch unsichtbar: Der Ring wurde durchgaengig mit halber
Deckkraft gezeichnet (ring-ring/50), effektiv rund 1.5:1. Jetzt volle
Deckkraft in Markenfarbe, 12 Dateien betroffen.

Weiter: Sprungmarke zum Inhalt auf allen Seiten, Suchfelder beschriftet,
Druck-Schaltflaechen hatten als reine Icon-Buttons gar keinen
zugaenglichen Namen, Liste als echte Liste ausgezeichnet, doppelter Link
je Zeile entfernt, Trefferzahl als Live-Region.

Responsiv: Aussenabstaende und Kopfzeilen ab 320 Pixel, Listenzeilen
brechen um.

Neu nach § 4 BITV 2.0: Seiten fuer Leichte Sprache und Gebaerdensprache,
im Admin pflegbar. Leer gelassen und damit noch nicht online - die
Inhalte kommen vom Amt. In der Erklaerung als bekannte Luecke benannt.

Geprueft mit axe-core (WCAG 2.1 A und AA) ueber alle oeffentlichen Seiten
und den Adminbereich, hell und dunkel, dazu Tastaturbedienung, 200
Prozent Vergroesserung und 320 Pixel Breite: keine Verstoesse, kein
waagerechter Ueberlauf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:44:30 +02:00

183 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Metadata } from "next"
import Link from "next/link"
import { notFound } from "next/navigation"
import { PrismaClient } from "@prisma/client"
import { ArrowLeft, Calendar } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ObjectionNotice } from "@/components/objection-notice"
import { PublicHeader } from "@/components/public-header"
import { SiteFooter } from "@/components/site-footer"
import { PrintPolicy } from "@/components/print-policy"
import { readPolicyDefaults, toApiPolicy } from "@/lib/policy-defaults.server"
import { markdownToHtml } from "@/lib/markdown"
import { formatDate } from "@/lib/format"
import { PrivacyPolicy } from "@/types/privacy-policy"
// Prisma client instance
const prisma = new PrismaClient()
// Inhalt kommt aus der Datenbank und ändert sich mit jeder Pflege im Admin.
export const dynamic = "force-dynamic"
/**
* Lädt eine veröffentlichte Erklärung samt Standardtexten.
*
* Entwürfe (`isPublic = false`) verhalten sich hier wie nicht vorhanden – die
* Seite ist ohne Anmeldung erreichbar und darf sie nicht preisgeben.
*/
async function loadPolicy(slug: string) {
const [policy, defaults] = await Promise.all([
prisma.privacyPolicy.findUnique({ where: { slug } }),
readPolicyDefaults(prisma),
])
if (!policy || !policy.isPublic) return null
return toApiPolicy(policy, defaults)
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const policy = await loadPolicy(slug)
if (!policy) return { title: "Nicht gefunden" }
return {
title: policy.title,
description: `Datenschutzerklärung nach DSGVO: ${policy.title}`,
}
}
/** Ein Abschnitt der Erklärung; leere Abschnitte entfallen ganz. */
async function Section({
title,
content,
}: {
title: string
content?: string | null
}) {
if (!content?.trim()) return null
const html = await markdownToHtml(content)
return (
<section className="space-y-2">
<h3 className="text-sm font-semibold">{title}</h3>
<div
className="markdown-content rounded-lg border bg-muted/30 p-4"
dangerouslySetInnerHTML={{ __html: html }}
/>
</section>
)
}
/** Die Abschnitte in der Reihenfolge, in der sie gelesen werden sollen. */
function sectionsOf(policy: PrivacyPolicy) {
return [
{
heading: "Verantwortlich für die Verarbeitung",
items: [
["Verantwortlicher im Sinne der DSGVO", policy.responsible],
["Kontaktdaten der/des Datenschutzbeauftragten", policy.contactDPO],
] as const,
},
{
heading: "Die Verarbeitung",
items: [
["Bezeichnung der Verarbeitungstätigkeit", policy.processingDescription],
["Zweck der Datenerhebung", policy.processingPurposes],
["Rechtsgrundlage der Verarbeitung", policy.legalBasis],
["Berechtigte Interessen", policy.legitimateInterests],
["Kategorien der verarbeiteten Daten", policy.dataCategories],
["Geplante Empfänger der Daten", policy.recipients],
["Übermittlung in Drittländer", policy.thirdCountryTransfer],
["Speicherdauer", policy.storageDuration],
["Herkunft der Daten", policy.dataSource],
["Automatisierte Entscheidungsfindung", policy.automatedDecision],
] as const,
},
{
heading: "Ihre Rechte",
items: [
["Betroffenen-Rechte", policy.dataSubjectsRights],
["Beschwerderecht bei der Aufsichtsbehörde", policy.complaintRight],
["Widerrufsrecht bei Einwilligung", policy.withdrawalRight],
["Ihre Pflicht zur Bereitstellung der Daten", policy.provisionObligation],
["Folgen, wenn Sie die Daten nicht angeben", policy.provisionConsequences],
] as const,
},
]
}
export default async function PolicyPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const policy = await loadPolicy(slug)
if (!policy) notFound()
return (
<div className="flex min-h-screen flex-col bg-background">
<PublicHeader subtitle="Datenschutz" />
<main id="inhalt" tabIndex={-1} className="mx-auto w-full max-w-3xl flex-1 px-4 py-6 sm:px-6 sm:py-8">
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-4 text-muted-foreground">
<Link href="/">
<ArrowLeft />
Alle Datenschutzerklärungen
</Link>
</Button>
<div className="mb-8 flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">{policy.title}</h1>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1.5">
<Calendar className="size-4" />
Erstellt am {formatDate(policy.createdAt)}
</span>
{policy.updatedAt !== policy.createdAt && (
<span>Aktualisiert am {formatDate(policy.updatedAt)}</span>
)}
<Badge variant="brand">DSGVO</Badge>
</div>
</div>
<PrintPolicy policy={policy} size="sm" />
</div>
{/* Art. 21 Abs. 4 verlangt den Hinweis getrennt von den übrigen
Informationen – deshalb steht er vor den Abschnitten in einem
eigenen, abgesetzten Kasten. */}
<ObjectionNotice content={policy.objectionRight} className="mb-10" />
<div className="space-y-10">
{sectionsOf(policy).map((group) => (
<section key={group.heading} className="space-y-4">
<h2 className="border-b pb-2 text-lg font-semibold tracking-tight">
{group.heading}
</h2>
<div className="space-y-5">
{group.items.map(([title, content]) => (
<Section key={title} title={title} content={content} />
))}
</div>
</section>
))}
</div>
</main>
<SiteFooter />
</div>
)
}