Produktseite ausgebaut: Unterseiten und Bildschirmfotos aus der App

Neue öffentliche Routen unter src/app/(marketing): Übersicht /funktionen, sechs Detailseiten
(planung, baustelle, zeiterfassung, berichte, abrechnung, lotse), /sicherheit und /preise,
gemeinsames Gerüst mit Kopf- und Fußzeile.

scripts/marketing-shots.ts nimmt die Bilder direkt aus der laufenden App auf (Playwright über
den installierten Chrome, Session wie im Smoke-Test, ohne Passworteingabe); 14 Aufnahmen in
public/marketing ersetzen die gezeichneten Vorschauen.

Plantafel: „Vorschläge" hängt jetzt als Fußzeile in der Auftragskarte statt frei zwischen zwei
Karten zu stehen (neue Prop footer an OrderCard, Schlüssel unplanned.suggestionsFor).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 13:35:04 +02:00
co-authored by Claude Opus 5
parent 31343d329a
commit 4815a957b0
32 changed files with 951 additions and 188 deletions
+102
View File
@@ -0,0 +1,102 @@
/**
* Screenshots der laufenden App für die Produktseite (`public/marketing/*.png`).
*
* Meldet sich OHNE Passworteingabe an: die Session wird wie in `scripts/smoke-auth.ts` gebaut
* (finalizeIdentityLogin + next-auth/jwt) und als Cookie in den Browser gesetzt. Gezeigt werden
* ausschließlich Demo-Daten des Mandanten `demo`.
*
* npm run dev # Dev-Server muss laufen
* npx tsx scripts/marketing-shots.ts # BASE=http://localhost:3000
*/
import "dotenv/config";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type BrowserContext } from "playwright-core";
import { encode } from "next-auth/jwt";
import { prisma } from "../src/server/db";
import { finalizeIdentityLogin } from "../src/server/auth";
const BASE = process.env.BASE ?? "http://localhost:3000";
const COOKIE = "authjs.session-token";
const OUT = path.join(process.cwd(), "public", "marketing");
async function sessionCookie(email: string, tenant: string) {
const identity = await prisma.identity.findUniqueOrThrow({ where: { email } });
const user = await finalizeIdentityLogin(identity.id, tenant);
if (!user) throw new Error(`keine Mitgliedschaft für ${email}`);
const token = {
sub: user.id, name: user.name, email: user.email, userId: user.id, identityId: user.identityId,
tenantId: user.tenantId, tenantSlug: user.tenantSlug, activeMembershipId: user.activeMembershipId,
memberships: user.memberships, roles: user.roles, permissions: user.permissions,
isPlatformAdmin: user.isPlatformAdmin, mfaEnrolled: user.mfaEnrolled,
};
return encode({ token, secret: process.env.AUTH_SECRET!, salt: COOKIE, maxAge: 60 * 60 });
}
type Shot = { name: string; path: string; wait?: string; click?: string; fullPage?: boolean };
async function shoot(ctx: BrowserContext, shots: Shot[], label: string) {
const page = await ctx.newPage();
for (const s of shots) {
await page.goto(`${BASE}${s.path}`, { waitUntil: "domcontentloaded", timeout: 120_000 });
if (s.wait) await page.waitForSelector(s.wait, { timeout: 60_000 }).catch(() => undefined);
if (s.click) await page.click(s.click, { timeout: 15_000 }).catch(() => undefined);
// Dev-Overlay von Next.js gehört nicht ins Produktbild
await page.addStyleTag({ content: "nextjs-portal, [data-nextjs-toast], #__next-build-watcher { display: none !important; }" });
await page.waitForTimeout(1200);
await page.screenshot({ path: path.join(OUT, `${s.name}.png`), fullPage: s.fullPage ?? false });
console.log(`✔ ${label}: ${s.name}.png (${s.path})`);
}
await page.close();
}
async function main() {
await mkdir(OUT, { recursive: true });
const tenant = await prisma.tenant.findUniqueOrThrow({ where: { slug: "demo" }, select: { id: true } });
const order = await prisma.workOrder.findFirstOrThrow({
where: { tenantId: tenant.id, status: "in_progress", deletedAt: null },
select: { id: true },
});
const browser = await chromium.launch({ channel: "chrome", headless: true });
const value = await sessionCookie("admin@demo.example", "demo");
const techValue = await sessionCookie("monteur@demo.example", "demo");
const cookie = { name: COOKIE, value, domain: "localhost", path: "/", httpOnly: true, secure: false, sameSite: "Lax" as const };
const desktop = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, locale: "de-DE" });
await desktop.addCookies([cookie]);
await shoot(desktop, [
{ name: "plantafel", path: "/planning", wait: "text=Plantafel" },
{ name: "live-lage", path: "/planning/live", wait: "text=Live-Lage", click: "text=Kacheln" },
{ name: "auftrag", path: `/work-orders/${order.id}`, wait: "text=Status" },
{ name: "abrechnung", path: "/billing", wait: "text=Abrechnung" },
{ name: "dashboard", path: "/dashboard", wait: "text=Dashboard" },
{ name: "auftraege", path: "/work-orders", wait: "text=Aufträge" },
{ name: "berichte", path: "/reports", wait: "text=Berichte" },
{ name: "zeitfreigabe", path: "/work-orders/time-approvals", wait: "text=Zeiten" },
{ name: "audit", path: "/settings/audit", wait: "text=Audit" },
{ name: "teams", path: "/teams", wait: "text=Teams" },
], "Desktop");
await desktop.close();
const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, locale: "de-DE", isMobile: true, hasTouch: true });
await mobile.addCookies([{ ...cookie, value: techValue }]);
await shoot(mobile, [
{ name: "mobil-heute", path: "/m", wait: "text=Heute" },
{ name: "mobil-auftrag", path: `/m/orders/${order.id}`, wait: "text=Auftrag" },
{ name: "mobil-lotse", path: "/m/lotse", wait: "text=Lotse" },
{ name: "mobil-zeiten", path: "/m/time", wait: "text=Meine Zeiten" },
], "Mobil");
await mobile.close();
await browser.close();
console.log(`\nFertig — Dateien in public/marketing/`);
}
main()
.then(() => prisma.$disconnect())
.then(() => process.exit(0))
.catch(async (err) => {
console.error(err instanceof Error ? err.message : err);
await prisma.$disconnect().catch(() => undefined);
process.exit(1);
});