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
+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);
}