Update public feature details and align SEO metadata

This commit is contained in:
t.indorf
2026-10-13 09:23:46 +02:00
parent 1472779781
commit abe73bb9ac
15 changed files with 683 additions and 234 deletions
+18
View File
@@ -1,5 +1,23 @@
# Kita-Planer – Funktionsübersicht und aktueller Stand
## Ergänzung vom 13.10.2026: Landingpage und SEO
- Die öffentliche Landingpage beschreibt jetzt sechs Bereiche, einschließlich Personal/Arbeitszeit mit eigener Detailseite `/features/personal-arbeitszeit`. Rollenvergabe, Wiederherstellung des Kontozugangs, Kalender-Rückmeldungen, Mitbringkorrekturen, zukünftige Abwesenheitsänderungen, Notdienst-Erinnerungen/Veröffentlichung und Mitteilungskorrekturen samt Versandübersicht sind in den passenden Texten ergänzt. Die Darstellung behält das vorhandene Design bei und ist auf Desktop sowie bei 390 und 360 Pixeln geprüft; der mobile Logo-Umbruch wurde behoben.
- Veraltete Aussagen zu „1-Klick-Alarmierung“, Raumbuchungen und Lesebestätigungen wurden durch die tatsächlich vorhandenen Abläufe ersetzt. Der persönliche Ungelesen-Status wird nicht als Nachweis einer tatsächlichen Kenntnisnahme oder als Admin-Auswertung beworben.
- `src/lib/marketing/seo.ts` bündelt öffentliche Domain, Titel, Beschreibungen, Canonicals und Vorschaudaten. Öffentliche Seiten besitzen eigene absolute Canonicals und `og:url`; Titel wiederholen den Markennamen nicht. Open Graph und Twitter verwenden passende Beschreibungen und das vorhandene, tatsächlich 1200 × 630 Pixel große Bild. Sein Alternativtext beschreibt die sichtbaren Spielklötze.
- Die Startseite liefert JSON-LD für WebSite, WebPage und SoftwareApplication; Feature-Seiten ergänzen BreadcrumbList und ihre eigene WebPage. Die Daten stammen aus den vorhandenen Produktbeschreibungen, enthalten keine erfundenen Bewertungen, Preise oder Anbieterangaben und werden gegen eingeschleuste HTML-Script-Enden abgesichert. Die Scripts verwenden den vorhandenen CSP-Nonce. Dies ist keine Zusage besonderer Google-Suchergebnisdarstellungen.
- Die Sitemap enthält ausschließlich neun öffentliche Canonical-Seiten: Startseite, sechs Feature-Seiten, Impressum und Datenschutz. Das Änderungsdatum folgt der tatsächlichen Änderung der Marketinginhalte (`marketingLastModified`) und wechselt nicht mehr bei jedem Build. Für rechtliche Seiten wird kein unbekanntes Änderungsdatum erfunden. Bei weiteren Änderungen der Produkttexte ist dieses Datum gezielt fortzuschreiben.
- Indexierung ist im Root-Layout standardmäßig ausgeschaltet; nur die öffentlichen Seiten schalten sie ausdrücklich ein. Private Seiten behalten `noindex, nofollow` und `private, no-store` auch als HTTP-Header. Login, Registrierung und Wiederherstellungs-Anforderungsformular bleiben für Crawler erreichbar, damit sie die `noindex`-Markierung lesen können. Interne und Tokenrouten bleiben zusätzlich in robots.txt ausgeschlossen. robots.txt ist keine Zugriffskontrolle und eine blockierte URL allein garantiert keine Entfernung aus einem Suchindex.
Prüfung: `npm run test:seo` (11 Tests gegen tatsächliche Metadaten-/Sitemap-/JSON-LD-Funktionen) sowie `TEST_SEO_ORIGIN=http://127.0.0.1:PORT npm run test:seo:http` (24 Tests gegen den lokal gestarteten Standalone-Produktionsbuild). Die HTTP-Prüfung umfasst alle neun öffentlichen HTML-Seiten, Social-Crawler-Metadaten direkt im Head, strukturierte Daten und CSP-Nonce, Sitemap, robots.txt, tatsächliche PNG-Bildmaße, private Seiten/Weiterleitungen und unbekannte Feature-URLs mit 404/noindex. Insgesamt 35 Tests erfolgreich, keine übersprungenen Tests. Build und TypeScript erfolgreich; ESLint ohne Fehler, mit fünf bereits bestehenden Warnungen.
Die bestehende Produktions-Landingpage, robots.txt und Sitemap wurden zusätzlich lesend abgeglichen. Die neuen Änderungen wurden lokal geprüft und sind noch nicht veröffentlicht. Für diese SEO-Ergänzung gibt es keine weitere Datenbankmigration; der Branch baut auf der vorherigen Mitteilungs-Etappe mit deren eigener Migration auf. Es wurden keine produktiven Daten verändert, Anfragen versendet oder Analytics-Ereignisse ausgelöst.
Offen: Impressum und Datenschutzhinweise enthalten im bestehenden Seiteninhalt weiterhin Platzhalter für Anbieter, Anschrift, Kontakt, verantwortliche Stelle und Dienstleister. Diese Angaben müssen von der verantwortlichen Person geliefert werden; die Metadaten-Ergänzung ersetzt sie nicht. Search-Console-Zugriff, tatsächlicher Google-Indexierungsstand und reale Nutzungs-/Performance-Messwerte waren nicht Teil der verfügbaren Prüfung.
Grundlagen: [Google zu Canonical-URLs](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls), [Sitemap-Änderungsdatum](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap) und [noindex](https://developers.google.com/search/docs/crawling-indexing/block-indexing); außerdem die mit Next.js 16.4 gelieferten Metadaten- und JSON-LD-Guides.
## Ergänzung vom 13.10.2026: Mitteilungen korrigieren und Versand verwalten
- Unter `/dashboard/admin/news` können Kita-Admins alle eigenen Beiträge nach Titel/Text durchsuchen und in Seiten mit zwölf Meldungen durchblättern. „Bearbeiten & Versand“ öffnet die Korrektur und den Versandstatus. Eltern- und Koordinationskonten erhalten keinen Verwaltungszugriff.
+2
View File
@@ -30,6 +30,8 @@
"test:calendar:integration": "node --test tests/calendar-actions.integration.test.cjs",
"test:notdienst:notifications": "node --test tests/notdienst-notifications.integration.test.cjs",
"test:announcements": "node --test tests/announcements.integration.test.cjs",
"test:seo:http": "node --test tests/marketing-seo-http.cjs",
"test:seo": "node --test tests/marketing-seo.test.cjs",
"test:mail": "node --test tests/mail-transport.test.cjs",
"test:account": "node --test tests/password-management.integration.test.cjs",
"test:roles": "node --test tests/kita-roles.integration.test.cjs tests/notdienst-assignment-view.test.cjs",
+16 -17
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import { publicPageMetadata } from "@/lib/marketing/seo";
import Link from "next/link";
import {
ArrowLeft,
@@ -11,10 +11,11 @@ import {
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
export const metadata: Metadata = {
title: "Datenschutz | Kita-Planer",
description: "Datenschutzhinweise und Platzhalter für Kita-Planer.",
};
export const metadata = publicPageMetadata(
"/datenschutz",
"Datenschutz",
"Datenschutzhinweise zu Konten, Kontaktfreigaben und der Nutzung von Kita-Planer.",
);
const overviewItems = [
{
@@ -77,9 +78,7 @@ export default function DatenschutzPage() {
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
<Icon className="h-5 w-5" />
</div>
<h2 className="font-semibold tracking-tight">
{item.title}
</h2>
<h2 className="font-semibold tracking-tight">{item.title}</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
{item.text}
</p>
@@ -154,9 +153,9 @@ export default function DatenschutzPage() {
<p className="mt-3 leading-7 text-slate-700">
Zugriff auf Daten erhalten nur berechtigte Nutzerinnen und Nutzer
innerhalb der jeweiligen Kita beziehungsweise des jeweiligen
Elternvereins. Für Hosting, E-Mail-Versand oder technische
Dienste können Auftragsverarbeiter eingesetzt werden:
[Hosting-Anbieter], [E-Mail-Anbieter], [weitere Dienstleister].
Elternvereins. Für Hosting, E-Mail-Versand oder technische Dienste
können Auftragsverarbeiter eingesetzt werden: [Hosting-Anbieter],
[E-Mail-Anbieter], [weitere Dienstleister].
</p>
</div>
@@ -180,9 +179,9 @@ export default function DatenschutzPage() {
<p className="mt-3 leading-7 text-slate-700">
Kita-Planer verwendet technisch notwendige Cookies oder
vergleichbare Technologien, um Login-Sitzungen sicher
bereitzustellen. Für die öffentliche Website kann zusätzlich
Umami Analytics eingesetzt werden, um Seitenaufrufe anonymisiert
und ohne Cookies auszuwerten.
bereitzustellen. Für die öffentliche Website kann zusätzlich Umami
Analytics eingesetzt werden, um Seitenaufrufe anonymisiert und
ohne Cookies auszuwerten.
</p>
</div>
@@ -220,9 +219,9 @@ export default function DatenschutzPage() {
</h2>
<p className="mt-3 leading-7 text-slate-700">
Betroffene Personen können sich bei einer
Datenschutzaufsichtsbehörde beschweren. Zuständig ist
insbesondere die Aufsichtsbehörde am Sitz der verantwortlichen
Stelle: [zuständige Datenschutzaufsichtsbehörde].
Datenschutzaufsichtsbehörde beschweren. Zuständig ist insbesondere
die Aufsichtsbehörde am Sitz der verantwortlichen Stelle:
[zuständige Datenschutzaufsichtsbehörde].
</p>
</div>
+25 -34
View File
@@ -1,3 +1,5 @@
import { StructuredData } from "@/components/marketing/structured-data";
import { featureStructuredData, publicPageMetadata } from "@/lib/marketing/seo";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
@@ -48,30 +50,11 @@ export async function generateMetadata({
};
}
return {
title: feature.seoTitle,
description: feature.seoDescription,
openGraph: {
title: feature.seoTitle,
description: feature.seoDescription,
type: "website",
locale: "de_DE",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Ein Kind spielt mit bunten Holzklötzen.",
},
],
},
twitter: {
card: "summary_large_image",
title: feature.seoTitle,
description: feature.seoDescription,
images: ["/og-image.png"],
},
};
return publicPageMetadata(
`/features/${feature.slug}`,
feature.seoTitle,
feature.seoDescription,
);
}
export default async function FeaturePage({ params }: FeaturePageProps) {
@@ -86,22 +69,30 @@ export default async function FeaturePage({ params }: FeaturePageProps) {
return (
<div className="min-h-screen bg-[#f8faf8] text-slate-950">
<StructuredData data={featureStructuredData(feature)} />
<header className="border-b border-slate-200 bg-white/95">
<div className="mx-auto flex h-20 w-full max-w-7xl items-center justify-between px-5 sm:px-6">
<Link href="/" className="flex items-center gap-3 text-slate-950">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-950 text-white">
<Link
href="/"
className="flex shrink-0 items-center gap-2 text-slate-950 sm:gap-3"
>
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-950 text-white sm:h-9 sm:w-9">
<ShieldCheck className="h-5 w-5" />
</span>
<span className="text-base font-semibold tracking-tight">
<span className="whitespace-nowrap text-sm font-semibold tracking-tight sm:text-base">
Kita-Planer
</span>
</Link>
<nav className="flex items-center gap-2">
<Button asChild variant="ghost">
<nav className="flex items-center gap-1 sm:gap-2">
<Button
asChild
variant="ghost"
className="px-3 text-xs sm:px-4 sm:text-sm"
>
<Link href="/login">Login</Link>
</Button>
<Button asChild>
<Button asChild className="px-3 text-xs sm:px-4 sm:text-sm">
<Link href="/#kontakt">Demo anfragen</Link>
</Button>
</nav>
@@ -181,7 +172,7 @@ export default async function FeaturePage({ params }: FeaturePageProps) {
<section className="mx-auto grid w-full max-w-7xl gap-6 px-5 py-20 sm:px-6 lg:grid-cols-2">
<Card className="border-slate-200 bg-white">
<CardHeader>
<CardTitle className="text-2xl tracking-tight">
<CardTitle as="h2" className="text-2xl tracking-tight">
{feature.problemTitle}
</CardTitle>
<CardDescription className="text-base leading-7 text-slate-600">
@@ -192,7 +183,7 @@ export default async function FeaturePage({ params }: FeaturePageProps) {
<Card className="border-slate-200 bg-white">
<CardHeader>
<CardTitle className="text-2xl tracking-tight">
<CardTitle as="h2" className="text-2xl tracking-tight">
{feature.solutionTitle}
</CardTitle>
<CardDescription className="text-base leading-7 text-slate-600">
@@ -220,7 +211,7 @@ export default async function FeaturePage({ params }: FeaturePageProps) {
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
<CheckCircle2 className="h-5 w-5" />
</div>
<CardTitle className="text-xl tracking-tight">
<CardTitle as="h3" className="text-xl tracking-tight">
{benefit.title}
</CardTitle>
<CardDescription className="text-sm leading-6 text-slate-600">
@@ -273,7 +264,7 @@ export default async function FeaturePage({ params }: FeaturePageProps) {
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-slate-100 text-slate-700">
<HelpCircle className="h-5 w-5" />
</div>
<CardTitle className="text-lg leading-snug">
<CardTitle as="h3" className="text-lg leading-snug">
{faq.question}
</CardTitle>
<CardDescription className="text-sm leading-6 text-slate-600">
+9 -10
View File
@@ -1,14 +1,15 @@
import type { Metadata } from "next";
import { publicPageMetadata } from "@/lib/marketing/seo";
import Link from "next/link";
import { ArrowLeft, Building2, Mail, MapPin, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
export const metadata: Metadata = {
title: "Impressum | Kita-Planer",
description: "Impressum und Anbieterkennzeichnung von Kita-Planer.",
};
export const metadata = publicPageMetadata(
"/impressum",
"Impressum",
"Impressum und Anbieterkennzeichnung von Kita-Planer.",
);
const contactItems = [
{
@@ -117,8 +118,8 @@ export default function ImpressumPage() {
Umsatzsteuer-ID
</h2>
<p className="mt-3 leading-7 text-slate-700">
Umsatzsteuer-Identifikationsnummer gemäß § 27a
Umsatzsteuergesetz: -
Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz:
-
</p>
</div>
@@ -126,9 +127,7 @@ export default function ImpressumPage() {
<h2 className="text-xl font-semibold tracking-tight">
Verantwortlich für den Inhalt
</h2>
<p className="mt-3 leading-7 text-slate-700">
Torben Indorf
</p>
<p className="mt-3 leading-7 text-slate-700">Torben Indorf</p>
</div>
<div>
+23 -29
View File
@@ -1,6 +1,12 @@
import { getSession } from "@/lib/auth-utils";
import { headers } from "next/headers";
import type { Metadata } from "next";
import {
getPublicSiteOrigin,
marketingDescription,
marketingTitle,
socialImage,
} from "@/lib/marketing/seo";
import { Manrope } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/sonner";
@@ -13,36 +19,24 @@ const manrope = Manrope({
});
export const metadata: Metadata = {
metadataBase: new URL(
process.env.NEXT_PUBLIC_SITE_URL ?? "https://mein-kitaplaner.de",
),
title: {
default: "Der digitale Kita-Planer für Elternvereine",
template: "%s | Kita-Planer",
},
description:
"Kita-Planer bündelt Dienste, Termine, Notdienste, Abwesenheiten und sichere Kommunikation für Elternvereine an einem Ort.",
metadataBase: new URL(getPublicSiteOrigin()),
title: { default: marketingTitle, template: "%s | Kita-Planer" },
description: marketingDescription,
// Public marketing pages opt in explicitly; account and internal pages stay out.
robots: { index: false, follow: false },
openGraph: {
title: "Der digitale Kita-Planer für Elternvereine",
description:
"Organisiert Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.",
title: marketingTitle,
description: marketingDescription,
type: "website",
locale: "de_DE",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Ein Kind spielt mit bunten Holzklötzen.",
},
],
siteName: "Kita-Planer",
images: [socialImage],
},
twitter: {
card: "summary_large_image",
title: "Der digitale Kita-Planer für Elternvereine",
description:
"Organisiert Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.",
images: ["/og-image.png"],
title: marketingTitle,
description: marketingDescription,
images: [{ url: socialImage.url, alt: socialImage.alt }],
},
};
@@ -51,13 +45,13 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const [requestHeaders, session] = await Promise.all([headers(), getSession()]);
const [requestHeaders, session] = await Promise.all([
headers(),
getSession(),
]);
const nonce = requestHeaders.get("x-nonce") ?? undefined;
return (
<html
lang="de"
className={`${manrope.variable} h-full antialiased`}
>
<html lang="de" className={`${manrope.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">
{children}
<UmamiAnalytics nonce={nonce} enabled={!session?.user?.id} />
+111 -79
View File
@@ -1,4 +1,3 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import {
@@ -35,38 +34,20 @@ import {
CardTitle,
} from "@/components/ui/card";
import { marketingFeatures } from "@/lib/marketing/features";
import { StructuredData } from "@/components/marketing/structured-data";
import {
landingStructuredData,
marketingDescription,
marketingTitle,
publicPageMetadata,
} from "@/lib/marketing/seo";
import { ContactForm } from "./contact-form";
export const metadata: Metadata = {
title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
description:
"Die einfache Plattform für Elterninitiativen, freie Kitas und Kita-Vereine: organisiert Dienste, Termine, Krankmeldungen und offizielle Kommunikation an einem Ort.",
alternates: {
canonical: "/",
},
openGraph: {
title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
description:
"Weniger Chat-Chaos, weniger Excel-Listen, mehr Ruhe im ehrenamtlichen Kita-Alltag.",
type: "website",
locale: "de_DE",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Ein Kind spielt mit bunten Holzklötzen.",
},
],
},
twitter: {
card: "summary_large_image",
title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
description:
"Organisiert Dienste, Termine, Krankmeldungen und offizielle Kommunikation an einem Ort.",
images: ["/og-image.png"],
},
};
export const metadata = publicPageMetadata(
"/",
marketingTitle,
marketingDescription,
);
const heroImage =
"https://images.unsplash.com/photo-1503454537195-1dcabb73ffb9?auto=format&fit=crop&w=2400&q=86";
@@ -74,13 +55,13 @@ const heroImage =
const privacyPoints = [
{
icon: DatabaseZap,
title: "Strikt isolierte Mandanten",
text: "Jede Kita arbeitet technisch streng getrennt pro Kita. Rollen und Datenabfragen bleiben konsequent tenant-isoliert.",
title: "Getrennte Daten je Kita",
text: "Die Daten eurer Kita sind von anderen Einrichtungen getrennt. Zugriff erhalten nur die berechtigten Konten eurer Kita.",
},
{
icon: Fingerprint,
title: "Kontaktfreigabe per Opt-in",
text: "Adressbuchdaten erscheinen nur nach aktivem Opt-In pro Elternteil. Nicht freigegebene Kontaktdaten verlassen den Server nicht.",
text: "Jeder Elternteil entscheidet über die Freigabe der eigenen Kontaktdaten. Im internen Adressbuch erscheinen nur freigegebene Angaben.",
},
{
icon: Trash2,
@@ -90,23 +71,23 @@ const privacyPoints = [
{
icon: LockKeyhole,
title: "Geschützte Anhänge",
text: "Dokumente vom Schwarzen Brett liegen nicht öffentlich und werden nur nach Session- und Kita-Prüfung ausgeliefert.",
text: "Anhänge sind nur nach Anmeldung und für die gewählte Empfängergruppe eurer Kita abrufbar.",
},
{
icon: UserCog,
title: "Rollenrechte pro Kita",
text: "Vorstand, Koordination, Eltern und Team sehen nur die Bereiche, die zu ihrer Aufgabe und Kita-Zugehörigkeit passen.",
text: "Kita-Admins vergeben Rechte für Verwaltung, Notdienst-Koordination, Eltern und Team. Ämter wie Vorstand sind von Zugriffsrechten getrennt.",
},
{
icon: FileStack,
title: "Klare Datenpfade",
text: "Löschungen sind bewusst vorgesehen; Fragen zu Export und Übergabe werden im Setup sauber geklärt.",
title: "Kontozugang selbst verwalten",
text: "Aktivierte Konten können ihr Passwort im Profil ändern oder einen zeitlich begrenzten Wiederherstellungslink anfordern.",
},
];
const proofPoints = [
"Für Elterninitiativen, freie Kitas und Kita-Vereine",
"Dienste, Kalender, Abwesenheiten und offizielle Infos in einem System",
"Dienste, Kalender, Arbeitszeit und offizielle Infos in einem System",
"Gebaut für Vorstände, Eltern, Koordination und Team",
];
@@ -114,13 +95,13 @@ const replacedTools = [
{
icon: MessagesSquare,
title: "WhatsApp für offizielle Infos",
text: "Ankündigungen, Anhänge und Lesestatus gehören in einen verbindlichen Kanal, nicht zwischen spontane Rückfragen.",
text: "Ankündigungen mit Anhängen, sichtbaren Korrekturen und optionalen E-Mails bleiben auch später auffindbar.",
accent: "bg-emerald-50 text-emerald-700 ring-emerald-100",
},
{
icon: Table2,
title: "Excel-Listen für Dienste",
text: "Verfügbarkeiten, Mindesttage und Dienstpläne werden strukturierter als in Tabellen mit Version 17-final-final.",
text: "Verfügbarkeiten, Erinnerungen und veröffentlichte Dienstpläne bleiben nachvollziehbar. Bei Bedarf lässt sich die Einteilung manuell anpassen.",
accent: "bg-lime-50 text-lime-700 ring-lime-100",
},
{
@@ -147,17 +128,22 @@ const audienceRoles = [
{
icon: UserCog,
title: "Vorstand & Admin",
text: "Kita, Familien, Termine, Einladungen und Rechte verwalten, ohne dass alles an einer privaten Ablage hängt.",
text: "Familien einladen, Zugriffsrechte vergeben, Mitteilungen korrigieren und Versandfehler gezielt erneut versuchen.",
},
{
icon: UsersRound,
title: "Eltern",
text: "Termine sehen, Kinder krankmelden, Verfügbarkeiten eintragen und freigegebene Kontakte finden.",
text: "Verfügbarkeiten eintragen, zu Terminen zusagen, Mitbringbeiträge verwalten und zukünftige Abwesenheiten korrigieren.",
},
{
icon: HeartHandshake,
title: "ErzieherInnen & Koordination",
text: "Tagesüberblick, Abwesenheiten, Dienste und relevante Informationen an einem Ort sehen.",
title: "Kita-Team",
text: "Arbeitszeit erfassen, Wochen einreichen und Urlaub oder Freizeitausgleich beantragen. Abwesenheiten und Teammitteilungen bleiben im Blick.",
},
{
icon: HeartHandshake,
title: "Notdienst-Koordination",
text: "Verfügbarkeiten prüfen, Monatspläne veröffentlichen und bei Ausfällen mit Rückmeldungen aus den Haushalten weiterplanen.",
},
];
@@ -190,28 +176,32 @@ const onboardingSteps = [
export default function LandingPage() {
return (
<div className="min-h-screen bg-[#f8faf8] text-slate-950">
<StructuredData data={landingStructuredData(marketingFeatures)} />
<header className="absolute left-0 right-0 top-0 z-20">
<div className="mx-auto flex min-h-20 w-full max-w-7xl items-center justify-between gap-3 px-5 py-4 sm:px-6">
<Link href="/" className="flex items-center gap-3 text-white">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/12 ring-1 ring-white/30">
<Link
href="/"
className="flex shrink-0 items-center gap-2 text-white sm:gap-3"
>
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-white/12 ring-1 ring-white/30 sm:h-9 sm:w-9">
<ShieldCheck className="h-5 w-5" />
</span>
<span className="text-base font-semibold tracking-tight">
<span className="whitespace-nowrap text-sm font-semibold tracking-tight sm:text-base">
Kita-Planer
</span>
</Link>
<nav className="flex flex-wrap items-center justify-end gap-2">
<nav className="flex flex-wrap items-center justify-end gap-1 sm:gap-2">
<Button
asChild
variant="ghost"
className="text-white hover:bg-white/10 hover:text-white"
className="px-3 text-xs text-white hover:bg-white/10 hover:text-white sm:px-4 sm:text-sm"
>
<Link href="/login">Login</Link>
</Button>
<Button
asChild
className="bg-white text-slate-950 shadow-sm hover:bg-white/90"
className="bg-white px-3 text-xs text-slate-950 shadow-sm hover:bg-white/90 sm:px-4 sm:text-sm"
>
<Link href="/#kontakt">Demo anfragen</Link>
</Button>
@@ -245,8 +235,8 @@ export default function LandingPage() {
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-white/82 sm:text-xl">
Für Elterninitiativen, freie Kitas und Kita-Vereine, die
Dienste, Termine, Krankmeldungen und offizielle Kommunikation
ehrenamtlich organisieren.
Dienste, Termine, Abwesenheiten, Arbeitszeit und offizielle
Kommunikation gemeinsam organisieren.
</p>
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
@@ -294,9 +284,10 @@ export default function LandingPage() {
</h2>
</div>
<p className="max-w-2xl text-sm leading-6 text-slate-600 lg:justify-self-end">
Der Notdienst-Planer sammelt Verfügbarkeiten, zeigt offene
Familien und macht aus losen Antworten einen veröffentlichbaren
Monatsplan.
Der Notdienst-Planer sammelt Verfügbarkeiten, erinnert an fehlende
Einträge und macht aus Antworten einen veröffentlichbaren
Monatsplan. Bei manuellen Änderungen bleibt sichtbar, welche
Haushalte für den Tag verfügbar sind.
</p>
</div>
@@ -386,11 +377,14 @@ export default function LandingPage() {
</div>
</section>
<section className="mx-auto w-full max-w-7xl px-5 py-24 sm:px-6">
<section
id="funktionen"
className="mx-auto w-full max-w-7xl px-5 py-24 sm:px-6"
>
<div className="mb-12 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
Die 5 Säulen
Die {marketingFeatures.length} Bereiche
</p>
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
Plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam
@@ -405,15 +399,12 @@ export default function LandingPage() {
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-6">
{marketingFeatures.map((feature, index) => {
{marketingFeatures.map((feature) => {
const Icon = feature.icon;
const isWide = index > 2;
return (
<Card
key={feature.title}
className={`group border-slate-200 bg-white/90 shadow-sm transition duration-200 hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg ${
isWide ? "xl:col-span-3" : "xl:col-span-2"
}`}
className="group border-slate-200 bg-white/90 shadow-sm transition duration-200 hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg xl:col-span-2"
>
<CardHeader className="space-y-5">
<div className="flex items-start justify-between gap-4">
@@ -440,7 +431,10 @@ export default function LandingPage() {
variant="link"
className="mt-4 h-auto p-0 text-slate-950"
>
<Link href={`/features/${feature.slug}`}>
<Link
href={`/features/${feature.slug}`}
aria-label={`Mehr über ${feature.shortTitle} erfahren`}
>
Mehr erfahren
<ArrowRight className="h-4 w-4" />
</Link>
@@ -508,9 +502,9 @@ export default function LandingPage() {
</h2>
<p className="mt-5 max-w-xl text-base leading-7 text-slate-300">
Kita-Vereine verwalten besonders sensible Daten. Deshalb ist
Kita-Planer so gebaut, dass Sichtbarkeit, Rollenrechte,
Löschung und Mandantentrennung nicht vom guten Willen einzelner
Chats abhängen.
Kita-Planer so gebaut, dass Sichtbarkeit, Rollenrechte, Löschung
und Mandantentrennung nicht vom guten Willen einzelner Chats
abhängen.
</p>
</div>
@@ -644,10 +638,10 @@ function ProductBrowserMock() {
<span className="h-3 w-3 rounded-full bg-amber-300" />
<span className="h-3 w-3 rounded-full bg-emerald-300" />
</div>
<div className="rounded-full border border-slate-200 bg-white px-4 py-1 text-xs font-medium text-slate-500">
app.kita-planer.de/dashboard/notdienst
<div className="rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-medium text-slate-500 sm:px-4 sm:text-xs">
Kita-Planer · Notdienst
</div>
<div className="w-16" />
<div className="hidden w-16 sm:block" />
</div>
<div className="bg-[#fbf8ef] p-2 sm:p-4">
@@ -675,14 +669,23 @@ function FeaturePreview({ slug }: { slug: string }) {
</span>
</div>
<div className="grid grid-cols-5 gap-1.5">
{["", "bg-red-100", "", "bg-red-100", "", "bg-red-100", "", "", "bg-red-100", ""].map(
(cell, index) => (
<span
key={index}
className={`h-7 rounded-md border border-red-100 bg-white ${cell}`}
/>
),
)}
{[
"",
"bg-red-100",
"",
"bg-red-100",
"",
"bg-red-100",
"",
"",
"bg-red-100",
"",
].map((cell, index) => (
<span
key={index}
className={`h-7 rounded-md border border-red-100 bg-white ${cell}`}
/>
))}
</div>
</div>
);
@@ -748,11 +751,40 @@ function FeaturePreview({ slug }: { slug: string }) {
);
}
if (slug === "personal-arbeitszeit") {
return (
<div className="rounded-xl border border-teal-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center justify-between gap-2 text-xs">
<span className="font-semibold text-slate-700">
Meine Arbeitswoche
</span>
<span className="rounded-full bg-teal-50 px-2 py-1 text-teal-700">
Eingereicht
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-xs">
{[
["Mo", "7:00 Std."],
["Di", "6:30 Std."],
["Mi", "Urlaub"],
].map(([day, hours]) => (
<div key={day} className="rounded-lg bg-white p-2">
<span className="block text-slate-500">{day}</span>
<span className="mt-1 block font-medium text-slate-700">
{hours}
</span>
</div>
))}
</div>
</div>
);
}
return (
<div className="rounded-xl border border-indigo-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center gap-2 text-xs font-semibold text-slate-700">
<Bell className="h-4 w-4 text-indigo-700" />
Neue Mitteilung
Aktualisierte Mitteilung
</div>
<div className="space-y-2">
<span className="block h-2.5 w-4/5 rounded-full bg-slate-200" />
+8 -10
View File
@@ -1,28 +1,26 @@
import type { MetadataRoute } from "next";
function getSiteUrl() {
return process.env.NEXT_PUBLIC_SITE_URL ?? "https://mein-kitaplaner.de";
}
import { getPublicSiteOrigin, publicUrl } from "@/lib/marketing/seo";
export default function robots(): MetadataRoute.Robots {
const siteUrl = getSiteUrl();
const siteUrl = getPublicSiteOrigin();
return {
rules: {
userAgent: "*",
allow: ["/", "/features/", "/impressum", "/datenschutz"],
// Login/registration/recovery forms stay crawlable so bots can read noindex.
disallow: [
"/api/",
"/dashboard/",
"/dashboard",
"/admin",
"/invite/",
"/alert/",
"/login",
"/register",
"/onboarding/",
"/forbidden",
"/onboarding",
"/neues-passwort/",
],
},
sitemap: `${siteUrl}/sitemap.xml`,
sitemap: publicUrl("/sitemap.xml"),
host: siteUrl,
};
}
+8 -19
View File
@@ -2,36 +2,25 @@ import type { MetadataRoute } from "next";
import { marketingFeatures } from "@/lib/marketing/features";
function getSiteUrl() {
return process.env.NEXT_PUBLIC_SITE_URL ?? "https://mein-kitaplaner.de";
}
import { marketingLastModified, publicUrl } from "@/lib/marketing/seo";
export default function sitemap(): MetadataRoute.Sitemap {
const siteUrl = getSiteUrl();
const lastModified = new Date();
return [
{
url: siteUrl,
lastModified,
changeFrequency: "weekly",
url: publicUrl(),
lastModified: marketingLastModified,
changeFrequency: "monthly",
priority: 1,
},
{ url: publicUrl("/impressum"), changeFrequency: "yearly", priority: 0.2 },
{
url: `${siteUrl}/impressum`,
lastModified,
changeFrequency: "yearly",
priority: 0.2,
},
{
url: `${siteUrl}/datenschutz`,
lastModified,
url: publicUrl("/datenschutz"),
changeFrequency: "yearly",
priority: 0.2,
},
...marketingFeatures.map((feature) => ({
url: `${siteUrl}/features/${feature.slug}`,
lastModified,
url: publicUrl(`/features/${feature.slug}`),
lastModified: marketingLastModified,
changeFrequency: "monthly" as const,
priority: 0.8,
})),
@@ -0,0 +1,13 @@
import { headers } from "next/headers";
import { serializeStructuredData } from "@/lib/marketing/seo";
export async function StructuredData({ data }: { data: unknown }) {
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<script
type="application/ld+json"
nonce={nonce}
dangerouslySetInnerHTML={{ __html: serializeStructuredData(data) }}
/>
);
}
+103 -35
View File
@@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react";
import {
CalendarDays,
Clock3,
Megaphone,
ShieldAlert,
Stethoscope,
@@ -43,7 +44,7 @@ export const marketingFeatures: MarketingFeature[] = [
title: "Fairer Notdienst-Planer",
shortTitle: "Notdienst-Planer",
description:
"Automatische Generierung, gerechte Verteilung und 1-Klick-Alarmierung, wenn morgens Ersatz gebraucht wird.",
"Verfügbarkeiten sammeln, Familien erinnern, Monatspläne veröffentlichen und bei Ausfällen Rückmeldungen erhalten.",
accent: "bg-red-50 text-red-700 ring-red-100",
eyebrow: "Notdienst ohne Excel-Lotterie",
heroTitle: "Faire Notdienstplanung für Elterninitiativen.",
@@ -57,11 +58,11 @@ export const marketingFeatures: MarketingFeature[] = [
"In vielen Elterninitiativen hängt der Notdienst an Tabellen, Bauchgefühl und der Hoffnung, dass irgendwer im Chat rechtzeitig reagiert. Das ist für Vorstände anstrengend und für Eltern oft schwer nachvollziehbar.",
solutionTitle: "Verfügbarkeiten einsammeln, Plan erzeugen, reagieren",
solutionText:
"Kita-Planer bündelt die Verfügbarkeiten der Familien, berücksichtigt Mindesttage pro Kind und unterstützt Koordinatoren dabei, den Monatsplan zu erstellen und bei Ausfällen schneller Ersatz zu finden.",
"Kita-Planer berücksichtigt Verfügbarkeiten und Mindesttage pro Kind, unterstützt die manuelle Einteilung und veröffentlicht den Monatsplan mit E-Mail-Benachrichtigung. Bei Ausfällen können Haushalte über persönliche Links zu- oder absagen.",
benefits: [
{
title: "Verfügbarkeiten pro Familie",
text: "Eltern geben digital an, an welchen Tagen sie unterstützen können. Der Vorstand arbeitet mit strukturierten Daten statt verstreuten Nachrichten.",
text: "Eltern hinterlegen mögliche Tage. Die Planung zählt Geschwister gemeinsam als Haushalt und zeigt, ob die erforderliche Auswahl vollständig ist.",
},
{
title: "Nachvollziehbare Verteilung",
@@ -69,11 +70,19 @@ export const marketingFeatures: MarketingFeature[] = [
},
{
title: "Planung für Koordinatoren",
text: "Koordinatoren sehen den Zielmonat, können Pläne erzeugen und bei Bedarf manuell nachsteuern.",
text: "Koordinatoren erzeugen Planvorschläge und passen sie an. Verfügbare Haushalte stehen bei manuellen Einteilungen zuerst; abweichende Einteilungen werden gekennzeichnet.",
},
{
title: "Schneller reagieren",
text: "Wenn morgens Ersatz nötig ist, hilft eine strukturierte Übersicht deutlich mehr als ein hektischer Chat.",
text: "Persönliche Alarmlinks ermöglichen Zu- oder Absagen. Verantwortliche behalten offene, bestätigte und verspätete Rückmeldungen im Blick.",
},
{
title: "Erinnern und veröffentlichen",
text: "Fehlende Verfügbarkeiten können per E-Mail erinnert werden. Mit der Veröffentlichung erhalten die eingeteilten Haushaltskonten ihre Termine.",
},
{
title: "Dienste gemeinsam tauschen",
text: "Eltern können einen Tausch anfragen. Eine bestätigte Anfrage tauscht die beiden Dienste; offene Anfragen bleiben sichtbar.",
},
],
trustTitle: "Gemacht für sensible Kita-Abläufe",
@@ -103,7 +112,7 @@ export const marketingFeatures: MarketingFeature[] = [
title: "Smarter Kalender",
shortTitle: "Kita-Kalender",
description:
"Feste, Termine, Raumbuchungen und digitale Mitbring-Listen in einem verbindlichen Kalender für alle Familien.",
"Feste, Termine, Rückmeldungen und Mitbringlisten mit gemeinsamen Details in Monats-, Wochen-, Tages- und Agendaansicht.",
accent: "bg-sky-50 text-sky-700 ring-sky-100",
eyebrow: "Termine, Dienste und Mitbringlisten",
heroTitle: "Ein Kita-Kalender, den Eltern wirklich nutzen.",
@@ -111,7 +120,7 @@ export const marketingFeatures: MarketingFeature[] = [
"Alle wichtigen Termine, Dienstzuordnungen und Mitbringlisten an einem Ort, damit niemand nach der richtigen Nachricht im Chat suchen muss.",
seoTitle: "Kita-Kalender für Elternvereine",
seoDescription:
"Ein gemeinsamer Kalender für Elterninitiativen: Termine, Anfragen, Dienste und Mitbringlisten übersichtlich organisieren.",
"Kita-Termine, Zusagen je Kind oder Haushalt und Mitbringlisten organisieren. Gemeinsame Details in Kalender und Agenda für Elterninitiativen.",
problemTitle: "Termine verschwinden zu schnell",
problemText:
"Elternabende, Feste, Schließtage, Dienste und Mitbringlisten landen oft in unterschiedlichen Kanälen. Wer später nachschaut, findet nicht mehr sicher die aktuelle Information.",
@@ -121,19 +130,19 @@ export const marketingFeatures: MarketingFeature[] = [
benefits: [
{
title: "Termine im Überblick",
text: "Feste, Schließtage, Elternabende und andere Ereignisse werden zentral sichtbar.",
text: "Monats-, Wochen-, Tages- und Agendaansicht zeigen die gleichen Termindetails. Über die Suche lassen sich passende Ereignisse finden.",
},
{
title: "Anfragen statt Nebenabsprachen",
text: "Eltern können Terminwünsche einreichen, die von Verantwortlichen geprüft werden.",
title: "Zusagen je Kind oder Haushalt",
text: "Je nach Termin antworten Eltern für einzelne Kinder oder ihren Haushalt. Gemeinsam betreuende Eltern sehen denselben Antwortstand.",
},
{
title: "Mitbringlisten direkt am Termin",
text: "Was gebraucht wird, steht dort, wo Eltern es erwarten: am passenden Termin.",
text: "Familien tragen ihren Beitrag direkt am Termin ein und können eigene Mitbring-Einträge ändern oder zurücknehmen.",
},
{
title: "Dienste sichtbar machen",
text: "Geplante Dienste werden im Kontext der kommenden Termine und Aufgaben angezeigt.",
title: "Termine vorschlagen und prüfen",
text: "Eltern reichen Terminanfragen ein. Berechtigte Personen prüfen sie; Dienste bleiben ebenfalls im Kalender sichtbar.",
},
],
trustTitle: "Verbindlichkeit statt Chat-Suche",
@@ -148,7 +157,7 @@ export const marketingFeatures: MarketingFeature[] = [
{
question: "Sind Mitbringlisten enthalten?",
answer:
"Ja. Termine können mit digitalen Mitbringlisten verbunden werden.",
"Ja. Eltern können Beiträge eintragen und eigene Einträge bearbeiten oder zurücknehmen. Die Liste ist auch über die Agenda erreichbar.",
},
{
question: "Wer sieht welche Termine?",
@@ -163,7 +172,7 @@ export const marketingFeatures: MarketingFeature[] = [
title: "Sicheres Adressbuch",
shortTitle: "Eltern-Adressbuch",
description:
"Haushaltsbasiert, DSGVO-konform gedacht und mit strengem Opt-In: Kontaktdaten werden nur sichtbar, wenn Eltern zustimmen.",
"Familien und Kinder gemeinsam finden. E-Mail, Telefon und Adresse eines Elternteils erscheinen nur nach dessen aktiver Freigabe.",
accent: "bg-emerald-50 text-emerald-700 ring-emerald-100",
eyebrow: "Kontakte nur mit Zustimmung",
heroTitle: "Ein Eltern-Adressbuch mit klarer Freigabe.",
@@ -202,8 +211,7 @@ export const marketingFeatures: MarketingFeature[] = [
faqs: [
{
question: "Sind alle Eltern automatisch sichtbar?",
answer:
"Nein. Kontaktdaten erscheinen nur nach aktiver Freigabe.",
answer: "Nein. Kontaktdaten erscheinen nur nach aktiver Freigabe.",
},
{
question: "Kann eine Familie teilweise sichtbar sein?",
@@ -253,8 +261,8 @@ export const marketingFeatures: MarketingFeature[] = [
text: "Zeiträume und Hinweise sind dokumentiert, statt in einzelnen Chatnachrichten zu verschwinden.",
},
{
title: "Passt zum Familienprofil",
text: "Abwesenheiten sind mit den Kindern und der jeweiligen Kita-Zugehörigkeit verbunden.",
title: "Künftige Meldungen korrigieren",
text: "Eltern können Meldungen für zukünftige Tage selbst bearbeiten oder zurücknehmen. Laufende Meldungen verwaltet die Kita.",
},
],
trustTitle: "Sensible Informationen geschützt behandeln",
@@ -262,9 +270,9 @@ export const marketingFeatures: MarketingFeature[] = [
"Abwesenheiten sind personenbezogene Informationen. Kita-Planer behandelt sie innerhalb des geschützten Kita-Bereichs und nicht als öffentliche Nachricht.",
faqs: [
{
question: "Können Eltern mehrere Tage eintragen?",
question: "Können Eltern Meldungen später ändern?",
answer:
"Ja. Abwesenheiten können für einen Zeitraum erfasst werden.",
"Ja, bei zukünftigen Tagen können Eltern eigene Meldungen bearbeiten oder zurücknehmen. Die Kita verwaltet laufende Meldungen.",
},
{
question: "Sieht das Kita-Team die Meldungen sofort?",
@@ -284,38 +292,38 @@ export const marketingFeatures: MarketingFeature[] = [
title: "Schwarzes Brett",
shortTitle: "Schwarzes Brett",
description:
"Offizielle Ankündigungen mit Dateianhängen, Lesebestätigung und optionalem E-Mail-Push statt verstreuter Chatposts.",
"Ankündigungen mit geschützten Anhängen korrigieren, ältere Beiträge finden und den optionalen E-Mail-Versand je Person nachvollziehen.",
accent: "bg-indigo-50 text-indigo-700 ring-indigo-100",
badge: "Neu",
eyebrow: "Verbindliche Kita-Kommunikation",
heroTitle: "Ein digitales Schwarzes Brett für offizielle Nachrichten.",
heroDescription:
"Vorstände veröffentlichen Ankündigungen, hängen Dokumente an und erreichen Eltern über einen klaren Kanal statt über mehrere Chatgruppen.",
"Kita-Admins veröffentlichen Nachrichten für Eltern, Team oder beide Gruppen. Korrekturen erscheinen sichtbar aktualisiert und wieder ungelesen; ältere Beiträge bleiben auffindbar.",
seoTitle: "Digitales Schwarzes Brett für Kitas",
seoDescription:
"Offizielle Ankündigungen für Elternvereine: Nachrichten, Anhänge, Lesestatus und optionaler E-Mail-Push in einem geschützten System.",
"Kita-Mitteilungen mit Anhängen veröffentlichen und korrigieren. Ungelesene Beiträge erkennen, E-Mail-Status prüfen und Versandfehler erneut versuchen.",
problemTitle: "Wichtige Infos gehen im Chat unter",
problemText:
"Zwischen Fotos, Rückfragen und spontanen Abstimmungen verlieren offizielle Ankündigungen schnell ihre Sichtbarkeit.",
solutionTitle: "Ein Kanal für das, was verbindlich sein soll",
solutionText:
"Kita-Planer trennt offizielle Nachrichten vom Chatrauschen. Ankündigungen können Anhänge enthalten und sind für die passende Kita geschützt abrufbar.",
"Nachrichten und Anhänge bleiben im geschützten Kita-Bereich. Admins können auch ältere Beiträge durchsuchen und korrigieren; der Versandstatus wird getrennt vom Inhalt verwaltet.",
benefits: [
{
title: "Offizielle Ankündigungen",
text: "Vorstände und Admins veröffentlichen zentrale Nachrichten an Eltern oder Team.",
text: "Kita-Admins wählen Eltern, Team oder beide Gruppen. Diese Auswahl bestimmt auch, wer Beitrag und Anhänge in der App sehen darf.",
},
{
title: "Geschützte Anhänge",
text: "PDF, JPG und PNG werden nicht öffentlich ausgeliefert, sondern nach Session und Kita geprüft.",
text: "PDF, JPG und PNG bleiben geschützt: bis zu fünf Dateien, je zehn MB und insgesamt 25 MB.",
},
{
title: "Lesestatus im Blick",
text: "Das System kann sichtbar machen, welche Ankündigungen für Nutzer noch ungelesen sind.",
title: "Korrekturen bleiben sichtbar",
text: "Geänderte Texte erhalten einen Aktualisierungszeitpunkt und erscheinen für Leser wieder ungelesen. Auch ältere Korrekturen rücken nach vorne.",
},
{
title: "Optional per E-Mail",
text: "Wichtige Meldungen können zusätzlich per E-Mail angestoßen werden.",
title: "Versand je Person prüfen",
text: "E-Mails sind optional. Erfolgreiche, ausstehende und fehlgeschlagene Sendungen werden getrennt angezeigt; Fehler können gezielt erneut versucht werden.",
},
],
trustTitle: "Nicht öffentlich, nicht im Chat verloren",
@@ -325,17 +333,77 @@ export const marketingFeatures: MarketingFeature[] = [
{
question: "Welche Dateitypen sind möglich?",
answer:
"Aktuell sind PDF, JPG und PNG vorgesehen.",
"PDF, JPG und PNG sind möglich: bis zu fünf Anhänge, je zehn MB und insgesamt 25 MB.",
},
{
question: "Sind Anhänge öffentlich erreichbar?",
answer:
"Nein. Anhänge werden nur nach Session- und Kita-Prüfung ausgeliefert.",
"Nein. Anhänge sind nur nach Anmeldung und für die gewählte Empfängergruppe eurer Kita abrufbar.",
},
{
question: "Kann eine Ankündigung per E-Mail versendet werden?",
question: "Kann ich Fehler im Text oder beim Mailversand korrigieren?",
answer:
"Ja. Für wichtige Meldungen ist optional ein E-Mail-Push vorgesehen.",
"Ja. Admins können Titel und Text bearbeiten und die aktuelle Fassung ausdrücklich per E-Mail senden. Fehlgeschlagene Sendungen lassen sich unabhängig davon erneut versuchen; bereits erfolgreiche E-Mails werden dabei ausgelassen.",
},
],
},
{
slug: "personal-arbeitszeit",
icon: Clock3,
title: "Personal & Arbeitszeit",
shortTitle: "Personal und Arbeitszeit",
description:
"Arbeitszeit mit Pausen erfassen, Wochen zur Freigabe einreichen und Urlaub, Freizeitausgleich sowie das bestätigte Zeitkonto im Blick behalten.",
accent: "bg-teal-50 text-teal-700 ring-teal-100",
eyebrow: "Arbeitszeit, Urlaub und Zeitkonto",
heroTitle: "Mehr Überblick über die Arbeitszeit im Kita-Team.",
heroDescription:
"Fachkräfte erfassen ihre Arbeitsblöcke und stellen Anträge. Kita-Admins prüfen Wochen, genehmigen Urlaub und führen nachvollziehbare Zeitkonten.",
seoTitle: "Arbeitszeit und Urlaub für Kita-Teams",
seoDescription:
"Kita-Arbeitszeit mit Pausen erfassen, Wochen freigeben und Urlaub oder Freizeitausgleich beantragen. Zeitkonto und Resturlaub nachvollziehbar verwalten.",
problemTitle: "Arbeitszeit und Urlaub landen oft in mehreren Listen",
problemText:
"Einzelne Stundenlisten, Urlaubsanfragen und manuell berechnete Salden machen es schwer, einen gemeinsamen und bestätigten Stand zu behalten.",
solutionTitle: "Erfassung, Antrag und Freigabe gemeinsam organisieren",
solutionText:
"Arbeitsblöcke und Pausen gehören zur jeweiligen Woche. Anträge und Freigaben bleiben mit dem Personalprofil verknüpft; bestätigte Wochen und begründete Korrekturen führen zum Zeitkonto.",
benefits: [
{
title: "Mehrere Arbeitsblöcke je Tag",
text: "Fachkräfte erfassen Beginn, Ende und Pause. Weitere Blöcke lassen sich für denselben ausgewählten Tag direkt ergänzen.",
},
{
title: "Wochen prüfen und freigeben",
text: "Fachkräfte reichen die Woche ein. Admins können sie prüfen, freigeben oder zur Korrektur zurückgeben.",
},
{
title: "Urlaub und Freizeitausgleich",
text: "Ganze und einzelne halbe Urlaubstage sowie Freizeitausgleich können beantragt werden. Nur vorgesehene Arbeitstage verbrauchen Urlaub.",
},
{
title: "Bestätigtes Zeitkonto",
text: "Genehmigte Wochen, Anfangssaldo und wirksame manuelle Korrekturen bilden das bestätigte Konto. Offene Wochen bleiben als vorläufig erkennbar.",
},
],
trustTitle: "Persönliche Zeiten mit klaren Zuständigkeiten",
trustText:
"Fachkräfte sehen ihr eigenes Profil und ihre Zeiten. Kita-Admins verwalten die Personalprofile ihrer Einrichtung; Zugriffsrechte und bestehende Profile bleiben miteinander verbunden.",
faqs: [
{
question: "Sind mehrere Arbeitsblöcke am selben Tag möglich?",
answer:
"Ja. Zum Beispiel lassen sich ein Vormittags- und ein Nachmittagsblock getrennt erfassen, jeweils mit passender Pause.",
},
{
question: "Kann ich einen halben Urlaubstag beantragen?",
answer:
"Ja. Ein halber Urlaubstag bezieht sich auf ein einzelnes Datum. Auch für Freizeitausgleich wird ein Tag ausgewählt.",
},
{
question: "Zählt eine offene Woche bereits zum bestätigten Zeitkonto?",
answer:
"Nein. Erst genehmigte Wochen zählen zusammen mit Anfangssaldo und wirksamen Korrekturen zum bestätigten Konto.",
},
],
},
+166
View File
@@ -0,0 +1,166 @@
import type { Metadata } from "next";
// Update when the public product copy changes, never on each request or build.
export const marketingLastModified = "2026-10-13";
export const marketingTitle =
"Kita-Planer für Elterninitiativen und Kita-Vereine";
export const marketingDescription =
"Organisiert Notdienste, Termine, Abwesenheiten, Arbeitszeit und Kita-Mitteilungen mit klaren Rollen, geschützten Anhängen und optionalen E-Mails.";
export const socialImage = {
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Bunte Spielklötze mit Tiermotiven für den Kita-Alltag.",
};
export function getPublicSiteOrigin() {
const site = new URL(
process.env.NEXT_PUBLIC_SITE_URL?.trim() || "https://mein-kitaplaner.de",
);
if (
!["https:", "http:"].includes(site.protocol) ||
site.username ||
site.password
)
throw new Error(
"NEXT_PUBLIC_SITE_URL muss eine öffentliche HTTP(S)-Adresse ohne Zugangsdaten sein.",
);
return site.origin;
}
export function publicUrl(path = "/") {
if (!path.startsWith("/") || path.startsWith("//"))
throw new Error("Ungültiger öffentlicher Seitenpfad.");
const origin = getPublicSiteOrigin();
const url = new URL(path, origin);
if (url.origin !== origin)
throw new Error("Ungültiger öffentlicher Seitenpfad.");
return url.toString();
}
export function publicPageMetadata(
path: string,
title: string,
description: string,
): Metadata {
return {
title: {
absolute: title.includes("Kita-Planer")
? title
: `${title} | Kita-Planer`,
},
description,
alternates: { canonical: publicUrl(path) },
robots: {
index: true,
follow: true,
googleBot: { index: true, follow: true, "max-image-preview": "large" },
},
openGraph: {
title,
description,
url: publicUrl(path),
siteName: "Kita-Planer",
type: "website",
locale: "de_DE",
images: [socialImage],
},
twitter: {
card: "summary_large_image",
title,
description,
images: [{ url: socialImage.url, alt: socialImage.alt }],
},
};
}
export function websiteStructuredData() {
return {
"@type": "WebSite",
"@id": publicUrl("/#website"),
name: "Kita-Planer",
url: publicUrl(),
inLanguage: "de-DE",
};
}
export function landingStructuredData(
features: { title: string; description: string }[],
) {
return {
"@context": "https://schema.org",
"@graph": [
websiteStructuredData(),
{
"@type": "WebPage",
"@id": publicUrl("/#webpage"),
url: publicUrl(),
name: marketingTitle,
description: marketingDescription,
inLanguage: "de-DE",
isPartOf: { "@id": publicUrl("/#website") },
about: { "@id": publicUrl("/#software") },
},
{
"@type": "SoftwareApplication",
"@id": publicUrl("/#software"),
name: "Kita-Planer",
url: publicUrl(),
description: marketingDescription,
applicationCategory: "BusinessApplication",
operatingSystem: "Webbrowser",
inLanguage: "de-DE",
featureList: features.map(
(feature) => `${feature.title}: ${feature.description}`,
),
},
],
};
}
export function featureStructuredData(feature: {
slug: string;
shortTitle: string;
seoTitle: string;
seoDescription: string;
}) {
const path = `/features/${feature.slug}`;
return {
"@context": "https://schema.org",
"@graph": [
websiteStructuredData(),
{
"@type": "WebPage",
"@id": publicUrl(`${path}#webpage`),
url: publicUrl(path),
name: feature.seoTitle,
description: feature.seoDescription,
inLanguage: "de-DE",
isPartOf: { "@id": publicUrl("/#website") },
breadcrumb: { "@id": publicUrl(`${path}#breadcrumb`) },
},
{
"@type": "BreadcrumbList",
"@id": publicUrl(`${path}#breadcrumb`),
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Kita-Planer",
item: publicUrl(),
},
{
"@type": "ListItem",
position: 2,
name: feature.shortTitle,
item: publicUrl(path),
},
],
},
],
};
}
export function serializeStructuredData(data: unknown) {
return JSON.stringify(data).replace(/</g, "\\u003c");
}
+4 -1
View File
@@ -127,7 +127,10 @@ export async function proxy(request: NextRequest) {
const response = await routeRequest(request, requestHeaders);
response.headers.set("Content-Security-Policy", csp);
response.headers.set("Referrer-Policy", "no-referrer");
if (request.nextUrl.pathname.startsWith("/alert/") || request.nextUrl.pathname.startsWith("/invite/") || request.nextUrl.pathname.startsWith("/register/verify/") || PROTECTED_PREFIX.some(p => request.nextUrl.pathname.startsWith(p))) {
const pathname = request.nextUrl.pathname;
const privatePage = ["/login", "/register", "/passwort-vergessen", "/forbidden"].includes(pathname) ||
["/alert/", "/invite/", "/register/verify/", "/neues-passwort/", ...PROTECTED_PREFIX].some(prefix => pathname.startsWith(prefix));
if (privatePage) {
response.headers.set("Cache-Control", "private, no-store");
response.headers.set("X-Robots-Tag", "noindex, nofollow");
}
+57
View File
@@ -0,0 +1,57 @@
/* eslint-disable @typescript-eslint/no-require-imports -- HTTP tests run directly in Node. */
const { test } = require('node:test');
const assert = require('node:assert/strict');
const origin = process.env.TEST_SEO_ORIGIN;
if (!origin || !['127.0.0.1', 'localhost'].includes(new URL(origin).hostname)) throw new Error('TEST_SEO_ORIGIN muss ausdrücklich auf den lokalen Produktionsbuild zeigen.');
const site = new URL(process.env.TEST_SEO_SITE_URL || 'https://mein-kitaplaner.de').origin;
const paths = ['/', '/features/notdienst-planer', '/features/kita-kalender', '/features/eltern-adressbuch', '/features/krankmeldung', '/features/schwarzes-brett', '/features/personal-arbeitszeit', '/impressum', '/datenschutz'];
const request = (path, ua = 'Googlebot') => fetch(new URL(path, origin), { headers: { 'user-agent': ua }, redirect: 'manual', signal: AbortSignal.timeout(15000) });
const attributes = text => Object.fromEntries([...text.matchAll(/([\w:-]+)="([^"]*)"/g)].map(match => [match[1], match[2]]));
const elements = (html, tag) => [...html.matchAll(new RegExp(`<${tag}\\b[^>]*>`, 'g'))].map(match => attributes(match[0]));
function meta(html, key) { const values = elements(html, 'meta').filter(row => row.name === key || row.property === key); assert.equal(values.length, 1, key); return values[0].content; }
const structured = html => [...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/g)].filter(match => attributes(match[1]).type === 'application/ld+json').map(match => ({ attrs: attributes(match[1]), data: JSON.parse(match[2]) }));
for (const path of paths) test(`SEO im ausgelieferten HTML: ${path}`, async () => {
const response = await request(path); assert.equal(response.status, 200); const html = await response.text();
assert.ok(html.includes('<html lang="de"')); assert.equal((html.match(/<h1\b/g) || []).length, 1);
const canonical = elements(html, 'link').filter(row => row.rel === 'canonical'); assert.equal(canonical.length, 1); assert.equal(new URL(canonical[0].href).href, new URL(path, site).href);
assert.equal((html.match(/<title>/g) || []).length, 1); const title = html.match(/<title>(.*?)<\/title>/)[1]; assert.equal((title.match(/Kita-Planer/g) || []).length, 1);
assert.ok(meta(html, 'description').length > 40); assert.ok(meta(html, 'description').length < 180);
assert.equal(meta(html, 'og:description'), meta(html, 'description')); assert.equal(meta(html, 'twitter:description'), meta(html, 'description'));
assert.equal(new URL(meta(html, 'og:url')).href, new URL(path, site).href);
assert.match(meta(html, 'robots'), /\bindex, follow\b/); assert.doesNotMatch(meta(html, 'robots'), /noindex/);
assert.equal(response.headers.get('x-robots-tag'), null); assert.equal(meta(html, 'og:image'), `${site}/og-image.png`);
if (path === '/' || path.startsWith('/features/')) {
const data = structured(html); assert.equal(data.length, 1); assert.equal(data[0].data['@context'], 'https://schema.org');
assert.ok(data[0].attrs.nonce); assert.ok(response.headers.get('content-security-policy').includes(`'nonce-${data[0].attrs.nonce}'`));
const graph = data[0].data['@graph']; assert.ok(graph.some(row => row['@type'] === (path === '/' ? 'SoftwareApplication' : 'BreadcrumbList')));
}
});
test('Social-Crawler erhält Canonical und Vorschaudaten direkt im HTML-Head', async () => {
const response = await request('/features/personal-arbeitszeit', 'facebookexternalhit/1.1'); assert.equal(response.status, 200);
const html = await response.text(); const head = html.match(/<head>([\s\S]*?)<\/head>/)[1];
assert.equal(meta(head, 'og:url'), `${site}/features/personal-arbeitszeit`); assert.equal(meta(head, 'twitter:card'), 'summary_large_image');
assert.ok(elements(head, 'link').some(row => row.rel === 'canonical'));
});
test('Ausgelieferte Sitemap enthält ausschließlich erreichbare kanonische öffentliche Seiten', async () => {
const response = await request('/sitemap.xml'); assert.equal(response.status, 200); const xml = await response.text();
const urls = [...xml.matchAll(/<loc>(.*?)<\/loc>/g)].map(match => new URL(match[1]).href).sort(); assert.deepEqual(urls, paths.map(path => new URL(path, site).href).sort());
assert.ok(xml.includes('<lastmod>2026-10-13</lastmod>')); assert.ok(!xml.includes('T00:'));
});
test('Robots verwenden dieselbe Domain und halten Loginformulare zum Lesen von noindex offen', async () => {
const response = await request('/robots.txt'); assert.equal(response.status, 200); const text = await response.text();
assert.ok(text.includes(`Sitemap: ${site}/sitemap.xml`)); assert.ok(text.includes('Disallow: /admin')); assert.ok(text.includes('Disallow: /neues-passwort/')); assert.ok(!text.includes('Disallow: /login'));
});
test('Social-Image ist tatsächlich ein PNG mit 1200 × 630 Pixeln', async () => {
const response = await request('/og-image.png'); assert.equal(response.status, 200); assert.match(response.headers.get('content-type'), /image\/png/);
const data = Buffer.from(await response.arrayBuffer()); assert.ok(data.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))); assert.equal(data.readUInt32BE(16), 1200); assert.equal(data.readUInt32BE(20), 630);
});
for (const path of ['/login', '/register', '/passwort-vergessen', '/neues-passwort/invalid', '/dashboard', '/admin', '/onboarding', '/alert/invalid', '/invite/invalid', '/forbidden']) test(`Private Route erhält noindex und no-store: ${path}`, async () => {
const response = await request(path); assert.ok(response.status < 500, `${path}: ${response.status}`); assert.match(response.headers.get('x-robots-tag'), /noindex, nofollow/); assert.match(response.headers.get('cache-control'), /private, no-store/);
if (response.status === 200) assert.match(meta(await response.text(), 'robots'), /noindex/);
});
test('Unbekannte Feature-Seiten antworten mit 404 und noindex', async () => {
const response = await request('/features/nicht-vorhanden'); assert.equal(response.status, 404); assert.ok(elements(await response.text(), 'meta').some(row => row.name === 'robots' && row.content.includes('noindex')));
});
+120
View File
@@ -0,0 +1,120 @@
/* eslint-disable @typescript-eslint/no-require-imports -- Load real TS modules with isolated framework boundaries. */
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm');
const ts = require('typescript');
const { renderToStaticMarkup } = require('react-dom/server');
function loader(overrides = {}) {
const cache = new Map();
function load(file) {
file = path.resolve(file);
if (cache.has(file)) return cache.get(file).exports;
const mod = { exports: {} }; cache.set(file, mod);
const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true },
fileName: file,
}).outputText;
const req = spec => {
if (Object.hasOwn(overrides, spec)) return overrides[spec];
if (spec === 'server-only') return {};
if (spec === '@/lib/prisma') throw new Error('Public SEO modules must not query the database.');
if (spec === 'next/headers') return { headers: async () => new Headers({ 'x-nonce': 'synthetic-seo-test-nonce' }) };
if (spec === 'next/server') return { after() {} };
if (spec === 'next/cache') return { revalidatePath() {} };
if (spec === 'next/navigation') return { redirect(url) { throw new Error(`REDIRECT:${url}`); }, notFound() { throw new Error('NOT_FOUND'); } };
if (spec.startsWith('@/') || spec.startsWith('.')) {
const target = spec.startsWith('@/') ? path.resolve('src', spec.slice(2)) : path.resolve(path.dirname(file), spec);
for (const suffix of ['.ts', '.tsx', '/index.ts']) if (fs.existsSync(target + suffix)) return load(target + suffix);
}
return require(spec);
};
vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { filename: file })(req, mod, mod.exports);
return mod.exports;
}
return load;
}
const savedOrigin = process.env.NEXT_PUBLIC_SITE_URL;
afterEach(() => { if (savedOrigin === undefined) delete process.env.NEXT_PUBLIC_SITE_URL; else process.env.NEXT_PUBLIC_SITE_URL = savedOrigin; });
const load = loader();
const seo = load('src/lib/marketing/seo.ts');
const { marketingFeatures } = load('src/lib/marketing/features.ts');
const sitemap = load('src/app/sitemap.ts').default;
const robots = load('src/app/robots.ts').default;
const validSite = () => { process.env.NEXT_PUBLIC_SITE_URL = 'https://example.invalid/'; };
test('Öffentliche URLs verwenden dieselbe normalisierte Adresse ohne doppelte Slashes', () => {
process.env.NEXT_PUBLIC_SITE_URL = ' https://example.invalid/setup?preview=true#fragment ';
assert.equal(seo.getPublicSiteOrigin(), 'https://example.invalid');
assert.equal(seo.publicUrl('/features/schwarzes-brett'), 'https://example.invalid/features/schwarzes-brett');
assert.equal(robots().sitemap, 'https://example.invalid/sitemap.xml');
});
test('Ohne konfigurierte Adresse wird die etablierte öffentliche Domain verwendet', () => {
delete process.env.NEXT_PUBLIC_SITE_URL; assert.equal(seo.publicUrl(), 'https://mein-kitaplaner.de/');
});
test('Ungültige Adressen, Zugangsdaten und fremde Seitenpfade werden zurückgewiesen', () => {
for (const value of ['javascript:alert(1)','ftp://example.invalid','https://user:secret@example.invalid','not-a-url']) {
process.env.NEXT_PUBLIC_SITE_URL = value; assert.throws(() => seo.getPublicSiteOrigin());
}
validSite(); for (const value of ['https://evil.invalid','//evil.invalid','/\\evil.invalid']) assert.throws(() => seo.publicUrl(value));
});
test('Öffentliche Metadaten haben eigene Canonicals, passende soziale URLs und Indexierung', async () => {
validSite(); const page = load('src/app/features/[slug]/page.tsx');
const titles = new Set(); const descriptions = new Set();
for (const feature of marketingFeatures) {
const metadata = await page.generateMetadata({ params: Promise.resolve({ slug: feature.slug }) });
assert.equal(metadata.alternates.canonical, seo.publicUrl(`/features/${feature.slug}`));
assert.equal(metadata.openGraph.url, metadata.alternates.canonical);
assert.equal(metadata.openGraph.description, metadata.description); assert.equal(metadata.twitter.description, metadata.description);
assert.equal(metadata.robots.index, true); assert.equal(metadata.robots.googleBot.index, true);
assert.equal(metadata.openGraph.images[0].width, 1200); assert.equal(metadata.openGraph.images[0].height, 630);
assert.ok(!metadata.openGraph.images[0].alt.includes('Kind spielt'));
assert.ok(!titles.has(metadata.title.absolute)); titles.add(metadata.title.absolute);
assert.ok(!descriptions.has(metadata.description)); descriptions.add(metadata.description);
}
});
test('Startseite und rechtliche Seiten wiederholen den Markennamen im Titel nicht', () => {
validSite(); const metadata = seo.publicPageMetadata('/', seo.marketingTitle, seo.marketingDescription);
assert.equal((metadata.title.absolute.match(/Kita-Planer/g) || []).length, 1);
for (const slug of ['impressum','datenschutz']) {
const value = load(`src/app/${slug}/page.tsx`).metadata;
assert.equal((value.title.absolute.match(/Kita-Planer/g) || []).length, 1);
assert.equal(value.alternates.canonical, seo.publicUrl(`/${slug}`)); assert.equal(value.robots.index, true);
}
});
test('Sitemap enthält genau die öffentlichen Seiten und den neuen Personalbereich', () => {
validSite(); const result = sitemap(); assert.equal(result.length, 9);
assert.equal(new Set(result.map(item => item.url)).size, result.length);
assert.deepEqual(result.map(item => item.url).sort(), ['/', '/impressum', '/datenschutz', ...marketingFeatures.map(feature => `/features/${feature.slug}`)].map(seo.publicUrl).sort());
assert.ok(result.some(item => item.url.endsWith('/features/personal-arbeitszeit')));
assert.ok(result.every(item => !/dashboard|login|register|passwort/.test(item.url)));
});
test('Änderungsdatum bleibt bei erneuter Generierung gleich; Rechtsseiten haben kein erfundenes Datum', () => {
validSite(); assert.deepEqual(sitemap(), sitemap());
for (const row of sitemap()) if (/impressum|datenschutz/.test(row.url)) assert.equal(row.lastModified, undefined); else assert.equal(row.lastModified, seo.marketingLastModified);
});
test('Robots blockieren interne und Tokenrouten; öffentliche Loginformulare erlauben noindex-Prüfung', () => {
validSite(); const result = robots(); assert.ok(result.rules.allow.includes('/features/'));
for (const value of ['/api/','/admin','/dashboard','/invite/','/alert/','/onboarding','/neues-passwort/']) assert.ok(result.rules.disallow.includes(value));
for (const value of ['/login','/register','/passwort-vergessen']) assert.ok(!result.rules.disallow.includes(value));
});
test('JSON-LD beschreibt vorhandene Funktionen ohne erfundene Bewertungen oder Preise', () => {
validSite(); const graph = seo.landingStructuredData(marketingFeatures); const software = graph['@graph'].find(row => row['@type'] === 'SoftwareApplication');
assert.equal(software.featureList.length, 6); assert.ok(software.featureList.some(text => text.startsWith('Personal & Arbeitszeit')));
assert.equal(software.offers, undefined); assert.equal(software.aggregateRating, undefined);
for (const feature of marketingFeatures) {
const data = seo.featureStructuredData(feature); const breadcrumb = data['@graph'].find(row => row['@type'] === 'BreadcrumbList');
assert.equal(breadcrumb.itemListElement[1].item, seo.publicUrl(`/features/${feature.slug}`));
}
});
test('Strukturierte Daten erhalten CSP-Nonce und lassen kein Script-Ende einschleusen', async () => {
const value = { title: '</script><script>alert(1)</script>' };
assert.ok(!seo.serializeStructuredData(value).includes('<')); assert.deepEqual(JSON.parse(seo.serializeStructuredData(value)), value);
const component = load('src/components/marketing/structured-data.tsx').StructuredData;
const html = renderToStaticMarkup(await component({ data: value }));
assert.ok(html.includes('nonce="synthetic-seo-test-nonce"')); assert.equal((html.match(/<script/g) || []).length, 1);
});
test('Landingtitel und veröffentlichte Funktionsbeschreibungen vermeiden veraltete Zusagen', () => {
assert.equal(marketingFeatures.length, 6); assert.equal(new Set(marketingFeatures.map(feature => feature.slug)).size, 6);
const text = JSON.stringify(marketingFeatures); assert.ok(!/1-Klick-Alarmierung|Raumbuchungen|Lesebestätigung/.test(text));
});