Update public feature details and align SEO metadata
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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')));
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user