feat: conversion landing page with automated screenshots
This commit is contained in:
parent
85c5a8381c
commit
82f7ff8b02
8 changed files with 362 additions and 35 deletions
30
frontend/package-lock.json
generated
30
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
BIN
frontend/public/screenshots/hero-light.png
Normal file
BIN
frontend/public/screenshots/hero-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 229 KiB |
BIN
frontend/public/screenshots/workflows-light.png
Normal file
BIN
frontend/public/screenshots/workflows-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
66
frontend/scripts/capture.mjs
Normal file
66
frontend/scripts/capture.mjs
Normal 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();
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue