- Neue Mitteilung
+ Aktualisierte Mitteilung
diff --git a/src/app/robots.ts b/src/app/robots.ts
index 37259c1..a338069 100644
--- a/src/app/robots.ts
+++ b/src/app/robots.ts
@@ -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,
};
}
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index 0167d81..10ddcc6 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -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,
})),
diff --git a/src/components/marketing/structured-data.tsx b/src/components/marketing/structured-data.tsx
new file mode 100644
index 0000000..ab919f6
--- /dev/null
+++ b/src/components/marketing/structured-data.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/lib/marketing/features.ts b/src/lib/marketing/features.ts
index 901b19d..151ee4b 100644
--- a/src/lib/marketing/features.ts
+++ b/src/lib/marketing/features.ts
@@ -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.",
},
],
},
diff --git a/src/lib/marketing/seo.ts b/src/lib/marketing/seo.ts
new file mode 100644
index 0000000..3078385
--- /dev/null
+++ b/src/lib/marketing/seo.ts
@@ -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(/ 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");
}
diff --git a/tests/marketing-seo-http.cjs b/tests/marketing-seo-http.cjs
new file mode 100644
index 0000000..a7910e0
--- /dev/null
+++ b/tests/marketing-seo-http.cjs
@@ -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(/' };
+ 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(/