feat: conversion landing page with automated screenshots

This commit is contained in:
Jonas Miederer 2026-09-12 09:18:59 +02:00
parent 85c5a8381c
commit 82f7ff8b02
8 changed files with 362 additions and 35 deletions

View file

@ -114,6 +114,19 @@ localStorage manager + toggle + inline `ColorSchemeScript` equivalent.
RJSF swap was import-only; SSR smoke test proved all 10 intake labels +
textarea/password/checkbox/slider widgets render. `npm run build` green.
## 0.4 Landing page (conversion structure + screenshot automation)
StartPage rebuilt along the 8-block anatomy (hero with outcome headline,
fact bar, 3 benefits, real product screenshot, 3 steps, trust list, FAQ,
final CTA, footer mini) — one CTA everywhere (`/access`), honest copy
(no fake testimonials/logos/numbers; pricing section omitted, model open).
Centralized hosting in Germany per operator decision (not self-hosted).
`frontend/scripts/capture.mjs` + `npm run screenshots` (Playwright/Chromium
in the frontend container) regenerates `public/screenshots/*.png`; the
workflows shot auto-selects `intake_form` to show the generated RJSF form.
Vite dev `server.watch` polling enabled (Windows bind-mount events often
never reach the Linux watcher → stale modules otherwise).
## 1. Locked decisions (do not re-ask)
* Auth: SMTP magic-link, 15-min single-use tokens (`secrets.token_urlsafe`,

View file

@ -22,6 +22,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"playwright": "^1.63.0",
"typescript": "~5.6.0",
"vite": "^6.0.0"
}
@ -1949,6 +1950,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",

View file

@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"screenshots": "node scripts/capture.mjs"
},
"dependencies": {
"@mantine/core": "^9.6.1",
@ -23,6 +24,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"playwright": "^1.63.0",
"typescript": "~5.6.0",
"vite": "^6.0.0"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -0,0 +1,66 @@
/* Repeatable landing-page screenshots (re-run after UI changes).
*
* Usage (frontend container, dev server running):
* npm run screenshots # light scheme (default)
* npm run screenshots -- --scheme=dark
*
* Captures real app states into public/screenshots/ (committed, so the
* landing page always shows current UI). Authenticated states are out of
* scope (magic-link login is not scriptable without SMTP).
*/
import { mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const outDir = join(root, "public", "screenshots");
const base = process.env.SCREENSHOT_BASE_URL ?? "http://localhost:3000";
const schemeArg = process.argv.find((a) => a.startsWith("--scheme="));
const scheme = schemeArg ? schemeArg.split("=")[1] : "light";
const shots = [
{ name: "hero", path: "/", fullPage: true },
{
name: "workflows",
path: "/app/workflows",
fullPage: false,
selectFlow: "intake_form",
},
];
const browser = await chromium.launch();
try {
mkdirSync(outDir, { recursive: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
colorScheme: scheme === "dark" ? "dark" : "light",
});
await context.addInitScript(
(value) => {
try {
localStorage.setItem("kirche-color-scheme", value);
} catch {
/* private mode */
}
},
scheme === "dark" ? "dark" : "light",
);
const page = await context.newPage();
for (const shot of shots) {
await page.goto(base + shot.path, { waitUntil: "networkidle" });
await page.waitForTimeout(1500);
if (shot.selectFlow) {
// Show the generated RJSF form instead of the default first flow.
await page.locator('[role="combobox"]').first().click();
await page.getByRole("option", { name: shot.selectFlow }).click();
await page.waitForTimeout(2000);
}
const file = join(outDir, `${shot.name}-${scheme}.png`);
await page.screenshot({ path: file, fullPage: shot.fullPage });
console.log(`wrote ${file}`);
}
} finally {
await browser.close();
}

View file

@ -1,71 +1,280 @@
import { Link } from "react-router-dom";
import { Button, Card, Grid, Group, Text, Title } from "@mantine/core";
import {
Accordion,
Anchor,
Badge,
Button,
Card,
Grid,
Group,
Image,
List,
Stack,
Text,
ThemeIcon,
Timeline,
Title,
} from "@mantine/core";
import {
IconCheck,
IconClipboardCheck,
IconFileText,
IconInbox,
IconKey,
IconLock,
IconServer,
IconSparkles,
IconUserCheck,
} from "@tabler/icons-react";
import { useAuth } from "../auth";
const actions = [
const CTA_LABEL = "Zugang anfragen";
const facts = [
{ icon: <IconServer size={20} />, text: "Gehostet in Deutschland" },
{ icon: <IconKey size={20} />, text: "Login ohne Passwort" },
{ icon: <IconClipboardCheck size={20} />, text: "Jeder Schritt protokolliert" },
];
const benefits = [
{
to: "/access",
icon: <IconKey size={28} />,
title: "Zugang anfragen",
text: "Neu hier? Fordern Sie Ihren persönlichen Zugang an.",
icon: <IconFileText size={28} />,
title: "In eigenen Worten",
text: "Mitarbeitende beschreiben Anliegen wie gewohnt — das System führt in verständlichen Schritten durch den Vorgang.",
},
{
to: "/app/workflows",
icon: <IconSparkles size={28} />,
title: "Workflows",
text: "Vorgänge über Formulare starten und verfolgen.",
title: "Regeln automatisch geprüft",
text: "Schwellen, Zuständigkeiten und Abläufe prüft die Software selbst. Routine läuft durch, ohne dass jemand klicken muss.",
},
{
to: "/app/tasks",
icon: <IconInbox size={28} />,
title: "Aufgaben",
text: "Eigene Läufe und offene Freigaben im Blick.",
icon: <IconUserCheck size={28} />,
title: "Mensch nur wo nötig",
text: "Echte Entscheidungen landen mit allen Fakten bei der zuständigen Person — Freigabe oder Ablehnung inklusive Notiz.",
},
];
const steps = [
{
title: "Zugang anfragen & anmelden",
text: "Anfrage stellen, per E-Mail-Link einloggen — ganz ohne Passwort.",
},
{
title: "Formular ausfüllen & starten",
text: "Vorgang wählen, Angaben machen, starten. Der Ablauf folgt automatisch.",
},
{
title: "Ergebnis & Protokoll",
text: "Antwort erhalten, Freigaben erteilen — jeder Schritt bleibt nachvollziehbar.",
},
];
const faqs = [
{
q: "Was kostet Kirche Einfach?",
a: "Das Preismodell steht noch nicht fest. Stellen Sie eine Zugangsfrage — wir melden uns mit allen Details.",
},
{
q: "Was muss bei uns installiert werden?",
a: "Nichts. Kirche Einfach läuft im Browser — auf Büro-PC, Tablet oder Smartphone.",
},
{
q: "Wo liegen unsere Daten?",
a: "Auf Servern in Deutschland. Es gibt keine externen Aufrufe an Drittdienste.",
},
{
q: "Wer sieht welche Daten?",
a: "Jede Gemeinde arbeitet in ihrem eigenen Mandanten. Innerhalb davon steuern Rollen (Mitglied, Verwaltung), wer was sieht und entscheidet.",
},
{
q: "Brauchen wir Passwörter?",
a: "Nein. Die Anmeldung läuft über einen persönlichen Link per E-Mail — gültig für eine Sitzung, nichts zu merken, nichts zu vergessen.",
},
];
export default function StartPage() {
const { me } = useAuth();
return (
<div>
<Card withBorder padding="xl" mb="lg">
<Group gap="sm" mb="xs">
<IconClipboardCheck size={32} />
<Title order={2}>Willkommen</Title>
</Group>
<Text>
Kirche Einfach unterstützt die Verwaltung Ihrer Gemeinde: Vorgänge
starten, Freigaben erteilen, Zugänge verwalten.
<Stack gap="xl">
{/* Hero */}
<Card withBorder padding="xl" radius="lg">
<Badge mb="sm">Für Pfarrbüros und Kirchengemeinden</Badge>
<Title order={1} mb="sm">
Weniger Verwaltung. Mehr Zeit für Menschen.
</Title>
<Text size="lg" c="dimmed" mb="lg" maw={640}>
Kirche Einfach führt Mitarbeitende in eigenen Worten durch Vorgänge,
prüft Regeln automatisch und fragt Menschen nur, wo Urteil nötig
ist — lückenlos protokolliert.
</Text>
<Group>
<Button component={Link} to="/access" size="lg">
{CTA_LABEL}
</Button>
<Button component={Link} to="/login" size="lg" variant="light">
Zum Login
</Button>
</Group>
{me && (
<Text mt="sm">
<Text mt="md">
Angemeldet als <strong>{me.email}</strong> ({me.tenant_id}).
</Text>
)}
</Card>
{/* Facts */}
<Group gap="lg">
{facts.map((f) => (
<Group key={f.text} gap="xs">
<ThemeIcon variant="light" size="lg">
{f.icon}
</ThemeIcon>
<Text size="sm" fw={500}>
{f.text}
</Text>
</Group>
))}
</Group>
{/* Benefits */}
<Grid>
{actions.map((a) => (
<Grid.Col key={a.to} span={{ base: 12, sm: 4 }}>
{benefits.map((b) => (
<Grid.Col key={b.title} span={{ base: 12, sm: 4 }}>
<Card withBorder padding="lg" h="100%">
{a.icon}
<Title order={4} mt="sm">
{a.title}
<ThemeIcon size={48} radius="md" variant="light" mb="sm">
{b.icon}
</ThemeIcon>
<Title order={4} mb="xs">
{b.title}
</Title>
<Text size="sm" c="dimmed" mt="xs" mb="md">
{a.text}
<Text size="sm" c="dimmed">
{b.text}
</Text>
<Button component={Link} to={a.to} variant="light" fullWidth>
Öffnen
</Button>
</Card>
</Grid.Col>
))}
</Grid>
</div>
{/* Product visual */}
<Card withBorder padding="lg" radius="lg">
<Title order={3} mb="xs">
So sieht die Arbeit aus
</Title>
<Text size="sm" c="dimmed" mb="md">
Formulare entstehen automatisch aus den Vorgängen — hier das
Aufnahmeformular mit allen Feldtypen.
</Text>
<Image
src="/screenshots/workflows-light.png"
alt="Workflow-Formular in Kirche Einfach"
radius="md"
bd="1px solid var(--mantine-color-default-border)"
/>
</Card>
{/* Steps */}
<Card withBorder padding="lg" radius="lg">
<Title order={3} mb="md">
In drei Schritten dabei
</Title>
<Timeline bulletSize={28}>
{steps.map((s, i) => (
<Timeline.Item
key={s.title}
bullet={i + 1}
title={s.title}
>
<Text size="sm" c="dimmed">
{s.text}
</Text>
</Timeline.Item>
))}
</Timeline>
</Card>
{/* Trust */}
<Card withBorder padding="lg" radius="lg">
<Title order={3} mb="md">
Darauf können Sie sich verlassen
</Title>
<List
spacing="sm"
icon={
<ThemeIcon size={24} radius="xl" variant="light">
<IconCheck size={14} />
</ThemeIcon>
}
>
<List.Item>
<strong>Datenhoheit:</strong> Betrieb auf Servern in Deutschland,
ohne Aufrufe an Drittdienste.
</List.Item>
<List.Item>
<strong>Klare Zuständigkeit:</strong> Rollen und Mandanten trennen,
wer sieht und wer entscheidet.
</List.Item>
<List.Item>
<strong>Sicher ohne Passwort:</strong> Einmal-Links per E-Mail
statt vergessener Kennwörter.
</List.Item>
<List.Item>
<strong>Nachvollziehbar:</strong> Läufe, Entscheidungen und Notizen
bleiben protokolliert.
</List.Item>
</List>
</Card>
{/* FAQ */}
<div>
<Title order={3} mb="md">
Häufige Fragen
</Title>
<Accordion variant="separated">
{faqs.map((f) => (
<Accordion.Item key={f.q} value={f.q}>
<Accordion.Control>{f.q}</Accordion.Control>
<Accordion.Panel>
<Text size="sm">{f.a}</Text>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</div>
{/* Final CTA */}
<Card withBorder padding="xl" radius="lg">
<Group gap="sm" mb="xs">
<IconInbox size={28} />
<Title order={3}>Bereit für weniger Papierkram?</Title>
</Group>
<Text c="dimmed" mb="md">
Stellen Sie eine Zugangsfrage — wir melden uns mit den nächsten
Schritten.
</Text>
<Button component={Link} to="/access" size="lg">
{CTA_LABEL}
</Button>
</Card>
{/* Footer mini (Kontakt/Impressum liefert der Betreiber) */}
<Group gap="md" mt="md">
<Text size="xs" c="dimmed">
Kirche Einfach
</Text>
<Anchor component={Link} to="/access" size="xs">
Zugang
</Anchor>
<Anchor component={Link} to="/login" size="xs">
Login
</Anchor>
<Text size="xs" c="dimmed">
{/* TODO Betreiber: Kontakt und Impressum ergänzen */}
Kontakt & Impressum folgen
</Text>
<ThemeIcon size="sm" variant="transparent" aria-label="Sicherer Zugang">
<IconLock size={14} />
</ThemeIcon>
</Group>
</Stack>
);
}

View file

@ -7,6 +7,13 @@ export default defineConfig({
plugins: [react()],
server: {
port: 3000,
// Polling: the repo is bind-mounted from a Windows host, whose file
// events often never reach the Linux watcher — without this, edits
// (local or via VS Code) silently keep serving stale modules.
watch: {
usePolling: true,
interval: 1000,
},
proxy: {
"/api": {
target: "http://api:8000",