feat(marketing): add real application screenshots

This commit is contained in:
t.indorf
2026-10-13 13:13:14 +02:00
parent abe73bb9ac
commit d4aa90422f
12 changed files with 307 additions and 177 deletions
+18
View File
@@ -0,0 +1,18 @@
# Produkt-Screenshots
Die Bilder in `public/landing/screenshots/` sind echte Browseraufnahmen der WebApp mit ausschließlich fiktiven Beispieldaten. Sie wurden am 13. Oktober 2026 aufgenommen. Keine Produktionsdaten, echten Kontaktadressen oder Zugangsdaten sind enthalten.
Die Metadaten und Bildbeschreibungen stehen in `src/lib/marketing/screenshots.ts`. Landingpage und Featureseiten verwenden dieselben Dateien. Die Vorschau reserviert den Bildbereich, lädt Bilder bedarfsgerecht über Next Image und bietet eine tastaturbedienbare Vollansicht mit Fensteranpassung, Originalgröße und Scrollbereich.
## Aufnahmen erneuern
1. Eine **leere**, ausschließlich lokale PostgreSQL-Datenbank namens `kita_screenshots_test` erstellen. Andere Datenbanknamen oder Hosts werden vom Fixture-Skript abgewiesen; bestehende Daten werden niemals gelöscht.
2. `DATABASE_URL` nur für diese Sitzung auf diese Datenbank setzen und `npm run db:migrate:deploy` ausführen.
3. Mit derselben URL `npx tsx scripts/seed-screenshots.ts` ausführen. Das Skript legt Kita Sonnenwinkel, sechs fiktive Familien und eine Fachkraft an. Die Zugangsdaten werden nur lokal ausgegeben. Alle E-Mail-Adressen verwenden die nicht zustellbare Domain `example.invalid`.
4. Die App mit einer eigenen lokalen Auth-URL und einem Test-Secret starten. E-Mail-Versand und Analytics deaktivieren (`RESEND_API_KEY`, `NEXT_PUBLIC_UMAMI_SCRIPT_URL` und `NEXT_PUBLIC_UMAMI_WEBSITE_ID` leer; öffentliche Variablen schon beim Build leer setzen). Keine Jobs starten.
5. Über die normale Anmeldung und Navigation die Ansichten aufnehmen. Der Fixture-Stand verwendet Oktober/November 2026. Bei späteren Aufnahmen die Termine, Auswahl und Arbeitswoche passend aktualisieren.
6. Browseraufnahmen als JPEG speichern. Die Elternansichten wurden bei 1600 × 1000, die Arbeitszeitansicht bei 1600 × 1280 aufgenommen. Der Browser kann beim Ausschließen der Scrollbar leicht abweichende tatsächliche Bildmaße liefern; die Metadaten immer aus den **gespeicherten Dateien** lesen und aktualisieren.
7. Notdienst, Kalender, Adressbuch und eigene Abwesenheiten zeigen die Elternansicht. Bei Mitteilungen die erste aktualisierte Meldung öffnen. Für Arbeitszeit mit dem Teamkonto anmelden und zur erfassten Woche vom 5. bis 11. Oktober wechseln.
8. Bilder und Vergrößerung auf Desktop und Mobilgerät prüfen, dann Beispieldatenbank und lokalen Server entfernen.
Die Screenshot-Files sind zusammen unter 500 KiB groß. Das alte Bild `notdienst-calendar-linen.png` wird nicht mehr von der Landingpage verwendet.
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

+92
View File
@@ -0,0 +1,92 @@
/** Synthetic fixture for public product screenshots. Never run against real data. */
import { PrismaClient, UserRole, TerminType, type User, type Child } from "@prisma/client";
import { hash } from "bcryptjs";
const url = new URL(process.env.DATABASE_URL ?? "postgresql://invalid");
if (!['localhost', '127.0.0.1'].includes(url.hostname) ||
url.pathname !== '/kita_screenshots_test') {
throw new Error('Screenshot fixtures require the isolated local kita_screenshots_test database.');
}
const prisma = new PrismaClient();
const date = (value: string) => new Date(`${value}T00:00:00Z`);
async function main() {
if (await prisma.kita.count() || await prisma.user.count()) {
throw new Error('Database must be empty; existing data will never be reset.');
}
const passwordHash = await hash('demo-screenshots-only', 10);
await prisma.$transaction(async (tx) => {
const kita = await tx.kita.create({ data: { name: 'Kita Sonnenwinkel', slug: 'sonnenwinkel-demo' } });
const kitaId = kita.id;
const common = { kitaId, passwordHash, emailVerifiedAt: date('2026-09-01'),
privacyPolicyAcceptedAt: date('2026-09-01'), privacyPolicyVersion: '2026-05-01' };
const admin = await tx.user.create({ data: { ...common, email: 'admin@example.invalid', firstName: 'Mara', lastName: 'Winter', role: UserRole.ADMIN } });
const households = [
['Berger', 'Lena', 'Jonas', 'Emma', 'Leo'],
['Hoffmann', 'Anna', 'Tim', 'Mila'],
['Schneider', 'Clara', 'Ben', 'Paul'],
['Neumann', 'Nora', 'Felix', 'Lotta'],
['Weber', 'Sarah', 'David', 'Finn'],
['Koch', 'Julia', 'Max', 'Ella'],
];
const parents: User[] = [];
const children: Child[] = [];
for (const [lastName, firstName, partner, ...childNames] of households) {
const family = await tx.family.create({ data: { kitaId, name: `Familie ${lastName}` } });
for (const name of [firstName, partner]) {
parents.push(await tx.user.create({ data: { ...common, familyId: family.id, firstName: name, lastName,
email: `${name.toLowerCase()}@example.invalid`, role: UserRole.ELTERN,
directoryOptInAt: date('2026-09-01'), city: 'Musterstadt' } }));
}
for (const name of childNames) {
children.push(await tx.child.create({ data: { kitaId, familyId: family.id, firstName: name, lastName } }));
}
}
await tx.notdienstAvailability.createMany({ data: [2, 4, 6, 10, 12, 17, 20, 25].map(day => ({
kitaId, childId: children[0].id, userId: parents[0].id,
date: date(`2026-11-${String(day).padStart(2, '0')}`),
})) });
await tx.absence.createMany({ data: [
{ kitaId, childId: children[0].id, startDate: date('2026-10-12'), endDate: date('2026-10-14'), reason: 'ILLNESS' },
{ kitaId, childId: children[1].id, startDate: date('2026-10-26'), endDate: date('2026-10-30'), reason: 'VACATION', note: 'Familienurlaub in den Herbstferien' },
] });
const events: [string, TerminType, string, string, boolean][] = [
['Elterncafé', 'ELTERNCAFE', '2026-10-08T13:00:00Z', '2026-10-08T14:30:00Z', false],
['Ausflug in den Stadtwald', 'SONSTIGES', '2026-10-15T07:00:00Z', '2026-10-15T11:00:00Z', false],
['Elternabend', 'ELTERNABEND', '2026-10-20T16:00:00Z', '2026-10-20T18:00:00Z', false],
['Herbstfest im Garten', 'KITA_FEST', '2026-10-23T13:00:00Z', '2026-10-23T16:00:00Z', false],
['Teamtag · Kita geschlossen', 'TEAMTAG', '2026-10-30T00:00:00Z', '2026-10-30T23:59:00Z', true],
['Elterncafé', 'ELTERNCAFE', '2026-11-05T14:00:00Z', '2026-11-05T15:30:00Z', false],
['Laternenfest', 'KITA_FEST', '2026-11-11T15:00:00Z', '2026-11-11T17:00:00Z', false],
['Gartenaktion', 'MITMACH_TAG', '2026-11-14T09:00:00Z', '2026-11-14T12:00:00Z', false],
['Teamtag · Kita geschlossen', 'TEAMTAG', '2026-11-23T00:00:00Z', '2026-11-23T23:59:00Z', true],
];
for (const [title, type, start, end, allDay] of events) {
const event = await tx.termin.create({ data: { kitaId, title, type, status: 'CONFIRMED', startDate: new Date(start), endDate: new Date(end), allDay,
description: title.includes('Herbstfest') ? 'Wir feiern gemeinsam mit allen Familien. Für Snacks und Getränke gibt es eine Mitbringliste.' : 'Gemeinsam planen wir unseren Kita-Alltag.',
createdById: admin.id, approvedById: admin.id, approvedAt: date('2026-10-01'),
requiresRsvp: type === 'KITA_FEST', rsvpMode: 'FAMILY', mitbringselListEnabled: type === 'KITA_FEST' } });
if (type === 'KITA_FEST') {
await tx.familyEventParticipation.create({ data: { kitaId, eventId: event.id, familyId: parents[0].familyId!, status: 'ATTENDING', adultCount: 2 } });
await tx.mitbringselItem.createMany({ data: parents.slice(0, 3).map((parent, index) => ({ kitaId, terminId: event.id, userId: parent.id, content: ['Apfelkuchen', 'Gemüsesticks', 'Eine Kanne Tee'][index] })) });
}
}
for (const [title, content, created, updated] of [
['Herbstfest: Wir freuen uns auf euch!', 'Am **Freitag, 23. Oktober**, feiern wir ab 15 Uhr im Kita-Garten.\n\nBitte tragt euch im Kalender für die Teilnahme und in die Mitbringliste ein. Gemeinsam sorgen wir für ein buntes Buffet.\n\n**Aktualisierung:** Bei Regen feiern wir im Gruppenraum.', '2026-10-09', '2026-10-13'],
['Elternabend am 20. Oktober', 'Wir treffen uns um **18 Uhr** im großen Gruppenraum.\n\nAuf der Agenda stehen der Kita-Alltag, die nächsten Ausflüge und eure Fragen. Wir freuen uns auf den Austausch!', '2026-10-08', '2026-10-08'],
['Gemeinsam unseren Garten vorbereiten', 'Am 14. November machen wir unseren Garten winterfest. Kleine und große Helfer sind willkommen!\n\nHandschuhe und gute Laune reichen. Alle Details findet ihr im Kalender.', '2026-10-06', '2026-10-06'],
]) {
await tx.announcement.create({ data: { kitaId, authorId: admin.id, title, content, audience: 'ALLE',
createdAt: date(created), updatedAt: date(updated), revision: created === updated ? 1 : 2, editedAt: created === updated ? null : date(updated) } });
}
const staff = await tx.user.create({ data: { ...common, email: 'team@example.invalid', firstName: 'Sophie', lastName: 'Lind', role: UserRole.ERZIEHER } });
const educator = await tx.educator.create({ data: { kitaId, firstName: staff.firstName, lastName: staff.lastName } });
const profile = await tx.staffProfile.create({ data: { kitaId, educatorId: educator.id, userId: staff.id,
employmentStart: date('2026-01-01'), trackingStart: date('2026-10-01'), openingMinutes: 120,
workPatterns: { create: { effectiveFrom: date('2026-01-01'), dailyMinutes: [420,420,420,420,420,0,0] } },
leaveAccounts: { create: { year: 2026, entitlementUnits: 60, carryUnits: 0 } } } });
await tx.staffTimeEntry.createMany({ data: [5,6,7,8,9].map(day => ({ profileId: profile.id, date: date(`2026-10-${String(day).padStart(2,'0')}`), startMinute: 480, endMinute: 930, breakMinutes: 30 })) });
}, { timeout: 30000 });
console.log('Synthetic screenshot data ready. Logins: lena@example.invalid / team@example.invalid. Password: demo-screenshots-only.');
}
main().finally(() => prisma.$disconnect());
+10
View File
@@ -1,3 +1,5 @@
import { ScreenshotPreview } from "@/components/marketing/product-screenshots";
import { getProductScreenshot } from "@/lib/marketing/screenshots";
import { StructuredData } from "@/components/marketing/structured-data";
import { featureStructuredData, publicPageMetadata } from "@/lib/marketing/seo";
import type { Metadata } from "next";
@@ -66,6 +68,7 @@ export default async function FeaturePage({ params }: FeaturePageProps) {
}
const Icon = feature.icon;
const screenshot = getProductScreenshot(slug);
return (
<div className="min-h-screen bg-[#f8faf8] text-slate-950">
@@ -169,6 +172,13 @@ export default async function FeaturePage({ params }: FeaturePageProps) {
</div>
</section>
{screenshot && (
<section aria-label={`${feature.shortTitle} in der App`} className="mx-auto w-full max-w-7xl px-5 pt-16 sm:px-6">
<h2 className="mb-6 text-2xl font-semibold tracking-tight">Ein Blick in die Anwendung</h2>
<ScreenshotPreview screenshot={screenshot} />
</section>
)}
<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>
+8 -177
View File
@@ -2,10 +2,8 @@ import Image from "next/image";
import Link from "next/link";
import {
ArrowRight,
Bell,
CheckCircle2,
ClipboardList,
Clock,
DatabaseZap,
FileStack,
Fingerprint,
@@ -15,7 +13,6 @@ import {
MessageSquareText,
MessagesSquare,
PhoneCall,
Search,
Route,
ShieldCheck,
Table2,
@@ -41,6 +38,8 @@ import {
marketingTitle,
publicPageMetadata,
} from "@/lib/marketing/seo";
import { ProductScreenshots, ScreenshotPreview } from "@/components/marketing/product-screenshots";
import { getProductScreenshot } from "@/lib/marketing/screenshots";
import { ContactForm } from "./contact-form";
export const metadata = publicPageMetadata(
@@ -284,14 +283,14 @@ 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, 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.
Entdeckt die sechs Bereiche direkt in der App: vom Notdienst
über Kalender und Mitteilungen bis zur Arbeitszeit. Die Ansichten
zeigen Beispieldaten und lassen sich für einen genaueren Blick
vergrößern.
</p>
</div>
<ProductBrowserMock />
<ProductScreenshots />
</section>
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6 lg:py-24">
@@ -442,7 +441,7 @@ export default function LandingPage() {
</div>
</CardHeader>
<CardContent className="pt-0">
<FeaturePreview slug={feature.slug} />
<ScreenshotPreview screenshot={getProductScreenshot(feature.slug)!} compact />
</CardContent>
</Card>
);
@@ -628,171 +627,3 @@ export default function LandingPage() {
</div>
);
}
function ProductBrowserMock() {
return (
<div className="overflow-hidden rounded-2xl border border-emerald-100 bg-white shadow-[0_24px_70px_rgba(15,23,42,0.12)]">
<div className="flex items-center justify-between border-b border-slate-200 bg-[#fbf8ef] px-4 py-3">
<div className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full bg-red-300" />
<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-3 py-1 text-[11px] font-medium text-slate-500 sm:px-4 sm:text-xs">
Kita-Planer · Notdienst
</div>
<div className="hidden w-16 sm:block" />
</div>
<div className="bg-[#fbf8ef] p-2 sm:p-4">
<Image
src="/landing/notdienst-calendar-linen.png"
alt="Notdienst-Kalender im Kita-Planer mit ausgewählten Tagen im Juni 2026"
width={2262}
height={1640}
sizes="(min-width: 1280px) 1184px, calc(100vw - 40px)"
className="h-auto w-full rounded-xl border border-slate-200 bg-[#fbf8ef]"
/>
</div>
</div>
);
}
function FeaturePreview({ slug }: { slug: string }) {
if (slug === "notdienst-planer") {
return (
<div className="rounded-xl border border-red-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center justify-between text-xs">
<span className="font-semibold text-slate-700">Juni</span>
<span className="rounded-full bg-red-50 px-2 py-1 text-red-700">
12/16
</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}`}
/>
))}
</div>
</div>
);
}
if (slug === "kita-kalender") {
return (
<div className="rounded-xl border border-sky-100 bg-[#fbf8ef] p-3">
{[
["10", "Schließtag", "bg-red-400"],
["18", "Elternabend", "bg-indigo-400"],
["29", "Sommerfest", "bg-emerald-500"],
].map(([date, title, color]) => (
<div
key={title}
className="mb-2 last:mb-0 flex items-center gap-3 rounded-lg bg-white px-3 py-2 text-xs"
>
<span className="font-semibold tabular-nums text-slate-950">
{date}
</span>
<span className={`h-5 w-1 rounded-full ${color}`} />
<span className="text-slate-600">{title}</span>
</div>
))}
</div>
);
}
if (slug === "eltern-adressbuch") {
return (
<div className="rounded-xl border border-emerald-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-400">
<Search className="h-3.5 w-3.5" />
Familie suchen
</div>
{["Meier", "Mustermann"].map((name) => (
<div key={name} className="mb-2 last:mb-0 flex items-center gap-2">
<span className="h-7 w-7 rounded-lg bg-emerald-50" />
<span className="h-2 flex-1 rounded-full bg-slate-200" />
<span className="h-2 w-10 rounded-full bg-emerald-100" />
</div>
))}
</div>
);
}
if (slug === "krankmeldung") {
return (
<div className="rounded-xl border border-amber-100 bg-[#fbf8ef] p-3">
<div className="mb-3 flex items-center gap-2 text-xs font-semibold text-slate-700">
<Clock className="h-4 w-4 text-amber-700" />
Heute fehlen 2 Kinder
</div>
{["Kai · krank", "Mia · Arzttermin"].map((item) => (
<div
key={item}
className="mb-2 last:mb-0 rounded-lg bg-white px-3 py-2 text-xs text-slate-600"
>
{item}
</div>
))}
</div>
);
}
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" />
Aktualisierte Mitteilung
</div>
<div className="space-y-2">
<span className="block h-2.5 w-4/5 rounded-full bg-slate-200" />
<span className="block h-2.5 w-3/5 rounded-full bg-slate-200" />
<span className="inline-flex rounded-full bg-indigo-50 px-2 py-1 text-xs text-indigo-700">
PDF angehängt
</span>
</div>
</div>
);
}
@@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { Maximize2 } from "lucide-react";
import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog";
import { productScreenshots, type ProductScreenshot } from "@/lib/marketing/screenshots";
import { cn } from "@/lib/utils";
export function ScreenshotPreview({
screenshot,
compact = false,
}: {
screenshot: ProductScreenshot;
compact?: boolean;
}) {
const [fullSize, setFullSize] = useState(false);
return (
<Dialog onOpenChange={() => setFullSize(false)}>
<figure>
<DialogTrigger asChild>
<button
type="button"
aria-label={`${screenshot.label}: Screenshot vergrößern`}
className="group block w-full overflow-hidden rounded-xl border border-slate-200 bg-[#f8f5ec] text-left shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700 focus-visible:ring-offset-4"
>
<span className="relative block aspect-[8/5]">
<Image
src={screenshot.src}
alt={screenshot.alt}
fill
sizes={compact
? "(min-width: 1024px) 380px, (min-width: 640px) 50vw, calc(100vw - 64px)"
: "(min-width: 1280px) 1232px, calc(100vw - 48px)"}
className="object-contain"
/>
</span>
<span className="flex items-center justify-between gap-3 border-t border-slate-200 bg-white px-4 py-3 text-xs text-slate-600">
<span>{compact ? "Ansicht mit Beispieldaten" : screenshot.role}</span>
<span className="flex shrink-0 items-center gap-1.5 font-medium text-emerald-800">
<Maximize2 className="h-3.5 w-3.5" aria-hidden="true" />
Vergrößern
</span>
</span>
</button>
</DialogTrigger>
{!compact && (
<figcaption className="mt-4 flex flex-col gap-1 text-sm leading-6 text-slate-600 sm:flex-row sm:justify-between sm:gap-6">
<span>{screenshot.caption}</span>
<span className="shrink-0 text-xs text-slate-500">Echte App-Ansicht mit Beispieldaten</span>
</figcaption>
)}
</figure>
<DialogContent className="w-[calc(100vw-24px)] max-w-[1660px] max-h-[calc(100dvh-24px)] grid-rows-[auto_minmax(0,1fr)] gap-3 rounded-xl p-4 sm:p-6">
<DialogHeader className="pr-6 text-left">
<DialogTitle>{screenshot.label} · {screenshot.role}</DialogTitle>
<DialogDescription>
{fullSize
? "Beispieldaten. Das Bild lässt sich horizontal und vertikal scrollen."
: "Echte App-Ansicht mit Beispieldaten. Für lesbare Details die Originalgröße auswählen."}
</DialogDescription>
<button
type="button"
onClick={() => setFullSize((value) => !value)}
className="mt-2 self-start rounded-lg border border-slate-200 px-3 py-2 text-xs font-medium text-emerald-800 hover:bg-emerald-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700"
>
{fullSize ? "An Fenster anpassen" : "Originalgröße anzeigen"}
</button>
</DialogHeader>
<div
tabIndex={0}
role="region"
aria-label={`Vergrößerter Screenshot: ${screenshot.label}`}
className="min-h-0 max-h-[72dvh] overflow-auto rounded-lg border border-slate-200 bg-[#f8f5ec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700"
>
<Image
src={screenshot.src}
alt={screenshot.alt}
width={screenshot.width}
height={screenshot.height}
sizes={`${screenshot.width}px`}
className={cn("block h-auto", fullSize ? "max-w-none" : "w-full")}
style={fullSize ? { width: screenshot.width } : undefined}
/>
</div>
</DialogContent>
</Dialog>
);
}
export function ProductScreenshots() {
const [selectedSlug, setSelectedSlug] = useState(productScreenshots[0].slug);
const selected = productScreenshots.find((screenshot) => screenshot.slug === selectedSlug)!;
return (
<div id="app-ansichten" className="scroll-mt-24">
<div role="group" aria-label="App-Ansicht auswählen" className="mb-5 flex flex-wrap gap-2">
{productScreenshots.map((screenshot) => (
<button
key={screenshot.slug}
type="button"
aria-pressed={selectedSlug === screenshot.slug}
aria-controls="app-screenshot"
onClick={() => setSelectedSlug(screenshot.slug)}
className={cn(
"rounded-full border px-4 py-2.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700 focus-visible:ring-offset-2",
selectedSlug === screenshot.slug
? "border-slate-950 bg-slate-950 text-white"
: "border-slate-200 bg-white text-slate-600 hover:border-emerald-700 hover:text-emerald-800",
)}
>
{screenshot.label}
</button>
))}
</div>
<div id="app-screenshot">
<ScreenshotPreview key={selected.slug} screenshot={selected} />
</div>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
export type ProductScreenshot = {
slug: string;
label: string;
role: string;
caption: string;
alt: string;
src: string;
width: number;
height: number;
};
/** Real app captures with synthetic data; see docs/product-screenshots.md. */
export const productScreenshots: readonly ProductScreenshot[] = [
{
slug: "notdienst-planer", label: "Notdienst", role: "Elternansicht",
caption: "Verfügbare Tage markieren und Kita-Termine direkt bei der Auswahl sehen.",
alt: "Notdienst-Kalender für November 2026 mit acht ausgewählten Tagen und einem gesperrten Teamtag",
src: "/landing/screenshots/notdienst.jpg", width: 1600, height: 1000,
},
{
slug: "kita-kalender", label: "Kalender", role: "Elternansicht",
caption: "Elternabend, Herbstfest, Ausflüge und Schließtage im gemeinsamen Monatskalender.",
alt: "Monatskalender für Oktober 2026 mit Elterncafé, Wald-Ausflug, Elternabend, Herbstfest und Teamtag",
src: "/landing/screenshots/kalender.jpg", width: 1600, height: 1000,
},
{
slug: "eltern-adressbuch", label: "Adressbuch", role: "Elternansicht",
caption: "Haushalte, Kinder und ausdrücklich freigegebene Kontakte zusammen finden.",
alt: "Internes Adressbuch mit Familien, Kindern und per Opt-in freigegebenen E-Mail-Adressen",
src: "/landing/screenshots/adressbuch.jpg", width: 1585, height: 991,
},
{
slug: "krankmeldung", label: "Abwesenheiten", role: "Elternansicht",
caption: "Laufende Meldungen sehen und zukünftige Abwesenheiten bearbeiten oder zurücknehmen.",
alt: "Abwesenheitsübersicht mit einer laufenden Krankmeldung und einer zukünftigen Urlaubsmeldung samt Bearbeiten- und Zurücknehmen-Schaltflächen",
src: "/landing/screenshots/abwesenheiten.jpg", width: 1600, height: 1000,
},
{
slug: "schwarzes-brett", label: "Mitteilungen", role: "Elternansicht",
caption: "Offizielle Nachrichten lesen – mit formatiertem Inhalt und sichtbarem Aktualisierungshinweis.",
alt: "Geöffnete Herbstfest-Mitteilung mit formatiertem Text und Datum der Aktualisierung vor dem Schwarzen Brett",
src: "/landing/screenshots/mitteilungen.jpg", width: 1600, height: 1000,
},
{
slug: "personal-arbeitszeit", label: "Arbeitszeit", role: "Ansicht für Fachkräfte",
caption: "Erfasste Stunden, Wochensumme, bestätigtes Zeitkonto und verfügbaren Urlaub im Blick behalten.",
alt: "Arbeitszeitübersicht einer Fachkraft mit 35 erfassten Wochenstunden, Zeitkonto und Urlaubsstand",
src: "/landing/screenshots/personal.jpg", width: 1585, height: 1268,
},
];
export function getProductScreenshot(slug: string) {
return productScreenshots.find((screenshot) => screenshot.slug === slug);
}