diff --git a/messages/de/marketing.json b/messages/de/marketing.json index 68cb1d5..bf14540 100644 --- a/messages/de/marketing.json +++ b/messages/de/marketing.json @@ -23,20 +23,7 @@ "ctaSecondary": "Funktionen ansehen", "bullet1": "In wenigen Minuten eingerichtet, ohne Installation", "bullet2": "Offline nutzbar — Eingaben gehen nie verloren", - "bullet3": "Keine Zahlungsdaten für den Test nötig", - "mockup": { - "label": "Beispielansicht der Plantafel", - "board": "Plantafel", - "today": "Heute", - "crewNorth": "Team Nord", - "crewSouth": "Team Süd", - "job1": "Heizungswartung", - "job2": "Thermostatventile", - "job3": "Rückstauklappe", - "load": "7,5 h / 8 h", - "conflict": "Überbucht", - "live": "In Arbeit" - } + "bullet3": "Keine Zahlungsdaten für den Test nötig" }, "pillars": { "title": "Was Craftvia zusammenhält", @@ -267,5 +254,149 @@ "privacy": "Datenschutz", "imprint": "Impressum", "rights": "Alle Rechte vorbehalten." + }, + "pages": { + "planung": { + "title": "Planung und Plantafel", + "lead": "Die Woche im Blick: Aufträge auf Kolonnen verteilen, Auslastung sehen, Konflikte erkennen, bevor der Kunde anruft.", + "p1": "Aufträge per Maus auf Team und Tag ziehen — oder über „Einplanen …“ ganz ohne Maus", + "p2": "Auslastung je Kolonne und Tag in Prozent, gemessen am Kolonnentag", + "p3": "Meldung bei Überbuchung, Überschneidung und unvollständiger Kolonne", + "p4": "Ungeplante Aufträge in einer eigenen Spalte, mit Vorschlägen direkt an der Karte", + "p5": "Live-Lage zeigt, wer unterwegs ist, arbeitet oder Pause macht", + "shot1": "Plantafel mit fünf Tagen, Auslastung je Kolonne und markierten Konflikten", + "shot2": "Live-Lage als Kachelansicht: Kolonnen mit Status, Auftrag und Ort", + "c1title": "Kolonnen statt Einzelpersonen", + "c1text": "Ein Team fährt zusammen. Die Kapazität ist der Kolonnentag, nicht die Summe der Personenstunden.", + "c2title": "Früher fertig, sinnvoll genutzt", + "c2text": "Endet ein Einsatz früher, schlägt Craftvia Aufträge in der Nähe vor — vorziehen entscheidet der Mensch.", + "c3title": "Verzug sichtbar machen", + "c3text": "Läuft ein Einsatz aus dem Ruder, markiert die Tafel den Folgeauftrag als gefährdet." + }, + "baustelle": { + "title": "Auf der Baustelle", + "lead": "Die mobile Ansicht führt durch den Einsatz: anfahren, arbeiten, dokumentieren, unterschreiben lassen — auch ohne Netz.", + "p1": "Große Flächen und klare Schritte, bedienbar mit Arbeitshandschuhen", + "p2": "Checkliste, Pflichtfotos und Material direkt am Auftrag", + "p3": "Offline nutzbar: Eingaben werden gespeichert und automatisch nachgesendet", + "p4": "Unterschrift des Kunden auf dem Display, PDF geht anschließend raus", + "p5": "Notdiensteinsätze in Minuten anlegen und später vervollständigen", + "shot1": "Tagesansicht des Monteurs mit laufender Zeiterfassung", + "shot2": "Auftrag mobil: Status, Checkliste, Material und Fotos an einer Stelle", + "c1title": "Kein Zettel mehr", + "c1text": "Was auf der Baustelle erfasst wird, steht sofort im Büro zur Verfügung.", + "c2title": "Pflichtangaben vor dem Abschluss", + "c2text": "Fehlende Fotos oder Unterschriften fallen vor Ort auf, nicht Tage später.", + "c3title": "Sync ohne Nachdenken", + "c3text": "Die App überträgt selbstständig, sobald wieder Verbindung besteht." + }, + "zeiterfassung": { + "title": "Zeiterfassung", + "lead": "Start, Pause, Ende mit einem Tipp. Fahrzeit und Arbeitszeit getrennt, Nachträge nachvollziehbar.", + "p1": "Laufende Uhr auf jeder Seite sichtbar, „Für heute beenden“ mit einem Tipp", + "p2": "Automatischer Wechsel, wenn auf einem anderen Auftrag gestartet wird", + "p3": "Zeiten nachtragen für die letzten Tage — mit Begründung", + "p4": "Freigabe durch Teamleitung oder Backoffice; offene Einträge zählen nicht für die Abrechnung", + "p5": "Backoffice kann Zeiten für Mitarbeitende erfassen, protokolliert im Audit", + "shot1": "Meine Zeiten: Tagesübersicht mit Fahrt- und Arbeitszeit", + "shot2": "Freigabe im Büro: offene Zeiteinträge prüfen und bestätigen", + "c1title": "Ehrliche Stunden", + "c1text": "Erfasst wird beim Arbeiten, nicht abends aus dem Gedächtnis.", + "c2title": "Korrekturen bleiben sichtbar", + "c2text": "Jeder Nachtrag trägt Grund und Freigabe — sauber für Lohn und Nachkalkulation.", + "c3title": "Fahrzeit sauber getrennt", + "c3text": "Anfahrten zählen eigenständig und landen als Position in der Abrechnung." + }, + "berichte": { + "title": "Berichte und Unterschrift", + "lead": "Der Leistungsnachweis entsteht auf der Baustelle — geprüft, unterschrieben und als PDF beim Kunden.", + "p1": "Bericht direkt am Auftrag, mit Vorlage je Auftragsart", + "p2": "Pflichtangaben und Pflichtfotos werden vor dem Einreichen geprüft", + "p3": "Unterschrift auf dem Display, alternativ „Kunde nicht angetroffen“ mit Grund", + "p4": "Freigabe im Büro, danach automatischer Versand als PDF", + "p5": "Jede Version bleibt erhalten, ältere werden als ersetzt gekennzeichnet", + "shot1": "Berichte im Büro: Status von eingereicht bis freigegeben", + "shot2": "Auftragsdetail mit Zeiten, Material, Fotos und Bericht", + "c1title": "Ein Nachweis, keine Sammlung", + "c1text": "Bericht, Fotos, Zeiten und Material hängen am selben Auftrag.", + "c2title": "Nachvollziehbar", + "c2text": "Wer eingereicht, geprüft und freigegeben hat, steht im Protokoll.", + "c3title": "Direkt beim Kunden", + "c3text": "Nach der Freigabe geht das PDF automatisch per Mail raus." + }, + "abrechnung": { + "title": "Abrechnungsübersicht", + "lead": "Fertige Aufträge und erreichte Meilensteine laufen gesammelt auf — mit Zeiten, Material und Anfahrten für die Buchhaltung.", + "p1": "Abgeschlossene Aufträge, Meilensteine und freigegebene Tagesberichte an einer Stelle", + "p2": "Nur freigegebene Zeiten zählen; offene Einträge werden getrennt ausgewiesen", + "p3": "Anfahrten aus der Zeiterfassung, je Kolonne und Tag gezählt", + "p4": "Abrechnungsblatt als PDF, „Abgerechnet“ mit optionaler Rechnungsnummer", + "p5": "Stornieren nur mit Begründung; abgerechnete Positionen bleiben eindeutig zugeordnet", + "shot1": "Abrechnungsübersicht mit offenen und abgerechneten Einträgen", + "c1title": "Keine doppelte Abrechnung", + "c1text": "Jede Zeit und jedes Material gehört zu genau einem abgerechneten Abschnitt.", + "c2title": "Buchhaltung bleibt Buchhaltung", + "c2text": "Craftvia liefert die Grundlage — Preise, Steuern und Rechnungen macht Ihr System.", + "c3title": "Meilensteine statt Endabrechnung", + "c3text": "Bei langen Projekten wird abgerechnet, was erreicht ist." + }, + "lotse": { + "title": "Lotse — der Assistent", + "lead": "Sagen, was war: Der Lotse ordnet zu, fragt nach, was fehlt, und schlägt Buchungen vor. Gespeichert wird erst nach Bestätigung.", + "p1": "Schreiben oder sprechen wie im Chat, auch mitten im Einsatz", + "p2": "Ordnet den Auftrag selbst zu — aus dem Kontext, der laufenden Uhr oder dem Tagesplan", + "p3": "Fragt fehlende Angaben nach, statt zu raten", + "p4": "Zeigt Aktionskarten für Status, Zeiten, Material und Notizen", + "p5": "Rechte, Pflichtangaben und Freigaben gelten unverändert", + "shot1": "Lotse-Chat auf dem Telefon: Eingabe per Text oder Sprache", + "c1title": "Bestätigen statt tippen", + "c1text": "Der Monteur prüft eine Karte und tippt einmal — statt vier Formulare auszufüllen.", + "c2title": "Nichts passiert heimlich", + "c2text": "Ohne Bestätigung wird nichts gebucht. Jede Buchung landet im Protokoll.", + "c3title": "Auch als Nachschlagewerk", + "c3text": "Fragen wie „Wo ist der Zugangscode?“ beantwortet der Lotse aus dem Auftrag." + } + }, + "overview": { + "title": "Funktionen im Überblick", + "lead": "Craftvia deckt den ganzen Einsatz ab — vom eingehenden Auftrag bis zur Grundlage für die Rechnung. Jedes Modul lässt sich einzeln freischalten.", + "more": "Mehr erfahren" + }, + "securityPage": { + "title": "Sicherheit und Datenschutz", + "lead": "Craftvia ist mandantenfähig gebaut. Jeder Betrieb sieht ausschließlich seine eigenen Daten — abgesichert bis in die Datenbank, nicht nur in der Oberfläche.", + "shot1": "Audit-Protokoll: jede schreibende Aktion mit Zeitpunkt, Person und Änderung", + "shot2": "Teams und Rollen: wer welche Aufträge sieht, entscheidet die Zuweisung", + "detailTitle": "Was das im Betrieb bedeutet", + "d1title": "Feldrollen sehen nur ihre Einsätze", + "d1text": "Monteure sehen die Aufträge ihrer Kolonne, nicht die Kundenliste des Betriebs.", + "d2title": "Rechte gelten am Server", + "d2text": "Jede Änderung wird serverseitig geprüft. Ausgeblendete Schaltflächen sind Komfort, keine Absicherung.", + "d3title": "Nachvollziehbar statt vertrauensselig", + "d3text": "Das Protokoll hält fest, wer was wann geändert hat — auch für die Buchhaltung." + }, + "pricingPage": { + "title": "Testphase und Preise", + "lead": "Starten Sie mit einer Testphase und entscheiden Sie danach in Ruhe. Der Preis der Vollversion richtet sich nach der Betriebsgröße.", + "trialTitle": "Testphase", + "trialText": "Voller Funktionsumfang, Zeitraum selbst gewählt, keine Zahlungsdaten nötig. Nach Ablauf bleiben die Daten lesbar und lassen sich exportieren.", + "t1": "Bis zu 30 Tage, Enddatum frei wählbar", + "t2": "Mit Beispieldaten oder leer starten", + "t3": "Erinnerung vor Ablauf, Verlängerung jederzeit", + "fullTitle": "Vollversion", + "fullText": "Übernahme ohne Datenumzug: Der Testmandant wird umgestellt, alles bleibt erhalten.", + "f1": "Alle Module, unbegrenzte Aufträge", + "f2": "Preis nach Anzahl der Nutzer und Kolonnen", + "f3": "Angebot nach einem kurzen Gespräch", + "note": "Konkrete Preise nennen wir erst, wenn sie feststehen — hier steht bewusst keine Zahl, die später nicht stimmt." + }, + "cta": { + "title": "Craftvia im eigenen Betrieb ausprobieren", + "text": "Testzugang einrichten, Beispieldaten laden und die erste Woche planen — in wenigen Minuten.", + "primary": "Kostenlos testen", + "secondary": "Alle Funktionen ansehen" + }, + "shotFrame": { + "label": "Bildschirmfoto aus Craftvia" } } diff --git a/messages/de/planning.json b/messages/de/planning.json index 9c558f3..7d86b2a 100644 --- a/messages/de/planning.json +++ b/messages/de/planning.json @@ -89,7 +89,8 @@ "noTeam": "ohne Team", "noDate": "ohne Termin", "noLocation": "ohne Ortsangabe", - "suggestions": "Vorschläge" + "suggestions": "Vorschläge", + "suggestionsFor": "Vorschläge für {number}" }, "dialog": { "title": "Auftrag einplanen", diff --git a/messages/en/marketing.json b/messages/en/marketing.json index 935f717..a7fe7d9 100644 --- a/messages/en/marketing.json +++ b/messages/en/marketing.json @@ -23,20 +23,7 @@ "ctaSecondary": "See the features", "bullet1": "Set up in minutes, nothing to install", "bullet2": "Works offline — entries are never lost", - "bullet3": "No payment details for the trial", - "mockup": { - "label": "Example of the scheduling board", - "board": "Scheduling board", - "today": "Today", - "crewNorth": "Crew North", - "crewSouth": "Crew South", - "job1": "Heating service", - "job2": "Thermostat valves", - "job3": "Backflow valve", - "load": "7.5 h / 8 h", - "conflict": "Overbooked", - "live": "Working" - } + "bullet3": "No payment details for the trial" }, "pillars": { "title": "What holds Craftvia together", @@ -267,5 +254,149 @@ "privacy": "Privacy", "imprint": "Imprint", "rights": "All rights reserved." + }, + "pages": { + "planung": { + "title": "Scheduling board", + "lead": "The week at a glance: distribute jobs across crews, see utilisation, spot conflicts before the customer calls.", + "p1": "Drag jobs onto a crew and a day — or schedule them entirely without a mouse", + "p2": "Utilisation per crew and day in percent, measured against the crew day", + "p3": "Warnings for overbooking, overlap and incomplete crews", + "p4": "Unplanned jobs in their own column, with suggestions attached to the card", + "p5": "The live view shows who is en route, working or on a break", + "shot1": "Scheduling board across five days with utilisation and marked conflicts", + "shot2": "Live situation as tiles: crews with status, job and place", + "c1title": "Crews, not individuals", + "c1text": "A crew travels together. Capacity is the crew day, not the sum of person-hours.", + "c2title": "Finished early, used well", + "c2text": "When a job ends early, Craftvia suggests nearby work — a person decides.", + "c3title": "Delays made visible", + "c3text": "If a job overruns, the board marks the following one as at risk." + }, + "baustelle": { + "title": "On site", + "lead": "The mobile view guides through the job: drive, work, document, collect the signature — even without a connection.", + "p1": "Large targets and clear steps, usable with work gloves", + "p2": "Checklist, required photos and material right on the job", + "p3": "Works offline: entries are stored and sent automatically", + "p4": "The customer signs on screen, the PDF goes out afterwards", + "p5": "Create call-out jobs in minutes and complete them later", + "shot1": "The technician's day view with the running timer", + "shot2": "A job on the phone: status, checklist, material and photos in one place", + "c1title": "No more paper", + "c1text": "What is captured on site is immediately available in the office.", + "c2title": "Mandatory fields before sign-off", + "c2text": "Missing photos or signatures surface on site, not days later.", + "c3title": "Sync without thinking", + "c3text": "The app transfers on its own as soon as there is a connection." + }, + "zeiterfassung": { + "title": "Time tracking", + "lead": "Start, pause, stop with one tap. Travel and work time separated, corrections traceable.", + "p1": "The running timer is visible on every page, with a one-tap \"finish for today\"", + "p2": "Automatic switch when work starts on another job", + "p3": "Add times for the last few days — with a reason", + "p4": "Approval by the crew lead or the office; pending entries never count for billing", + "p5": "The office can record times for staff, recorded in the audit trail", + "shot1": "My times: the day with travel and work time", + "shot2": "Approval in the office: review and confirm pending entries", + "c1title": "Honest hours", + "c1text": "Time is recorded while working, not from memory in the evening.", + "c2title": "Corrections stay visible", + "c2text": "Every correction carries a reason and an approval.", + "c3title": "Travel kept separate", + "c3text": "Trips count on their own and appear as a position in billing." + }, + "berichte": { + "title": "Reports and signature", + "lead": "The service report is created on site — checked, signed and sent to the customer as a PDF.", + "p1": "The report sits on the job, with a template per job type", + "p2": "Mandatory fields and photos are checked before submission", + "p3": "Signature on screen, or \"customer not present\" with a reason", + "p4": "Approval in the office, then automatic dispatch as a PDF", + "p5": "Every version is kept; older ones are marked as superseded", + "shot1": "Reports in the office: from submitted to approved", + "shot2": "Job detail with times, material, photos and the report", + "c1title": "One record, not a collection", + "c1text": "Report, photos, times and material belong to the same job.", + "c2title": "Traceable", + "c2text": "Who submitted, checked and approved is in the audit trail.", + "c3title": "Straight to the customer", + "c3text": "After approval the PDF is e-mailed automatically." + }, + "abrechnung": { + "title": "Billing overview", + "lead": "Completed jobs and reached milestones collect in one place — with times, material and trips for accounting.", + "p1": "Completed jobs, milestones and approved daily reports in one list", + "p2": "Only approved times count; pending entries are shown separately", + "p3": "Trips from time tracking, counted per crew and day", + "p4": "Billing sheet as a PDF, \"billed\" with an optional invoice number", + "p5": "Voiding needs a reason; billed positions stay clearly assigned", + "shot1": "Billing overview with open and billed records", + "c1title": "No double billing", + "c1text": "Every hour and every part belongs to exactly one billed section.", + "c2title": "Accounting stays accounting", + "c2text": "Craftvia provides the basis — prices, taxes and invoices stay in your system.", + "c3title": "Milestones instead of one final bill", + "c3text": "On long projects you bill what has been reached." + }, + "lotse": { + "title": "Lotse — the assistant", + "lead": "Say what happened: Lotse maps it, asks what is missing and proposes bookings. Nothing is stored before you confirm.", + "p1": "Write or speak like in a chat, even mid-job", + "p2": "Maps the job itself — from context, the running timer or the day's plan", + "p3": "Asks for missing details instead of guessing", + "p4": "Shows action cards for status, times, material and notes", + "p5": "Permissions, mandatory fields and approvals still apply", + "shot1": "Lotse chat on the phone: input by text or voice", + "c1title": "Confirm instead of typing", + "c1text": "The technician checks a card and taps once — instead of filling four forms.", + "c2title": "Nothing happens quietly", + "c2text": "Without confirmation nothing is booked. Every booking lands in the audit trail.", + "c3title": "A reference too", + "c3text": "Questions like \"where is the access code?\" are answered from the job." + } + }, + "overview": { + "title": "Features at a glance", + "lead": "Craftvia covers the whole job — from the incoming request to the basis for the invoice. Every module can be enabled on its own.", + "more": "Learn more" + }, + "securityPage": { + "title": "Security and privacy", + "lead": "Craftvia is built multi-tenant. Every business sees only its own data — enforced down in the database, not just in the interface.", + "shot1": "Audit trail: every write with time, person and change", + "shot2": "Teams and roles: assignment decides who sees which jobs", + "detailTitle": "What that means day to day", + "d1title": "Field roles see only their jobs", + "d1text": "Technicians see their crew's jobs, not the company's customer list.", + "d2title": "Permissions are enforced on the server", + "d2text": "Every change is checked server-side. Hidden buttons are convenience, not security.", + "d3title": "Traceable, not trusting", + "d3text": "The trail records who changed what and when — useful for accounting too." + }, + "pricingPage": { + "title": "Trial and pricing", + "lead": "Start with a trial and decide afterwards. The price of the full version depends on the size of your business.", + "trialTitle": "Trial", + "trialText": "Full functionality, period of your choice, no payment details. After it ends your data stays readable and exportable.", + "t1": "Up to 30 days, end date of your choice", + "t2": "Start with sample data or empty", + "t3": "Reminder before it ends, extension any time", + "fullTitle": "Full version", + "fullText": "Switch over without migrating data: the trial tenant is converted, everything is kept.", + "f1": "All modules, unlimited jobs", + "f2": "Priced by users and crews", + "f3": "A quote after a short conversation", + "note": "We name concrete prices once they are settled — deliberately no number here that would not hold later." + }, + "cta": { + "title": "Try Craftvia in your own business", + "text": "Set up trial access, load sample data and plan the first week — in minutes.", + "primary": "Start free trial", + "secondary": "See all features" + }, + "shotFrame": { + "label": "Screenshot from Craftvia" } } diff --git a/messages/en/planning.json b/messages/en/planning.json index 365c09b..7c1f1d2 100644 --- a/messages/en/planning.json +++ b/messages/en/planning.json @@ -89,7 +89,8 @@ "noTeam": "no team", "noDate": "no date", "noLocation": "no location", - "suggestions": "Suggestions" + "suggestions": "Suggestions", + "suggestionsFor": "Suggestions for {number}" }, "dialog": { "title": "Schedule order", diff --git a/public/marketing/abrechnung.png b/public/marketing/abrechnung.png new file mode 100644 index 0000000..b6412d6 Binary files /dev/null and b/public/marketing/abrechnung.png differ diff --git a/public/marketing/audit.png b/public/marketing/audit.png new file mode 100644 index 0000000..69000eb Binary files /dev/null and b/public/marketing/audit.png differ diff --git a/public/marketing/auftraege.png b/public/marketing/auftraege.png new file mode 100644 index 0000000..ca1bc8f Binary files /dev/null and b/public/marketing/auftraege.png differ diff --git a/public/marketing/auftrag.png b/public/marketing/auftrag.png new file mode 100644 index 0000000..7a7d0bb Binary files /dev/null and b/public/marketing/auftrag.png differ diff --git a/public/marketing/berichte.png b/public/marketing/berichte.png new file mode 100644 index 0000000..0d54338 Binary files /dev/null and b/public/marketing/berichte.png differ diff --git a/public/marketing/dashboard.png b/public/marketing/dashboard.png new file mode 100644 index 0000000..724426e Binary files /dev/null and b/public/marketing/dashboard.png differ diff --git a/public/marketing/live-lage.png b/public/marketing/live-lage.png new file mode 100644 index 0000000..ef3ca2e Binary files /dev/null and b/public/marketing/live-lage.png differ diff --git a/public/marketing/mobil-auftrag.png b/public/marketing/mobil-auftrag.png new file mode 100644 index 0000000..5e516c2 Binary files /dev/null and b/public/marketing/mobil-auftrag.png differ diff --git a/public/marketing/mobil-heute.png b/public/marketing/mobil-heute.png new file mode 100644 index 0000000..e84534c Binary files /dev/null and b/public/marketing/mobil-heute.png differ diff --git a/public/marketing/mobil-lotse.png b/public/marketing/mobil-lotse.png new file mode 100644 index 0000000..190d382 Binary files /dev/null and b/public/marketing/mobil-lotse.png differ diff --git a/public/marketing/mobil-zeiten.png b/public/marketing/mobil-zeiten.png new file mode 100644 index 0000000..ea268d4 Binary files /dev/null and b/public/marketing/mobil-zeiten.png differ diff --git a/public/marketing/plantafel.png b/public/marketing/plantafel.png new file mode 100644 index 0000000..9b44d36 Binary files /dev/null and b/public/marketing/plantafel.png differ diff --git a/public/marketing/teams.png b/public/marketing/teams.png new file mode 100644 index 0000000..a991b24 Binary files /dev/null and b/public/marketing/teams.png differ diff --git a/public/marketing/zeitfreigabe.png b/public/marketing/zeitfreigabe.png new file mode 100644 index 0000000..a264321 Binary files /dev/null and b/public/marketing/zeitfreigabe.png differ diff --git a/scripts/marketing-shots.ts b/scripts/marketing-shots.ts new file mode 100644 index 0000000..cd8a31b --- /dev/null +++ b/scripts/marketing-shots.ts @@ -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); + }); diff --git a/src/app/(marketing)/funktionen/[slug]/page.tsx b/src/app/(marketing)/funktionen/[slug]/page.tsx new file mode 100644 index 0000000..69ec475 --- /dev/null +++ b/src/app/(marketing)/funktionen/[slug]/page.tsx @@ -0,0 +1,70 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { CtaBand, PageHero, PointList, ShotFrame } from "@/components/marketing/blocks"; +import { FEATURE_PAGES, FEATURE_SLUGS, isFeatureSlug } from "@/lib/marketing/pages"; + +/** Detailseite eines Moduls — Inhalt aus `marketing.pages.`, Bilder aus public/marketing. */ + +type Props = { params: Promise<{ slug: string }> }; + +export function generateStaticParams() { + return FEATURE_SLUGS.map((slug) => ({ slug })); +} + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params; + if (!isFeatureSlug(slug)) return {}; + const t = await getTranslations("marketing"); + return { title: t(`pages.${slug}.title`), description: t(`pages.${slug}.lead`) }; +} + +export default async function FeatureDetailPage({ params }: Props) { + const { slug } = await params; + if (!isFeatureSlug(slug)) notFound(); + const page = FEATURE_PAGES[slug]; + const t = await getTranslations("marketing"); + const points = Array.from({ length: page.points }, (_, i) => t(`pages.${slug}.p${i + 1}`)); + const cards = Array.from({ length: page.cards }, (_, i) => ({ + title: t(`pages.${slug}.c${i + 1}title`), + text: t(`pages.${slug}.c${i + 1}text`), + })); + const [first, second] = page.shots; + + return ( + <> + + +
+
+ + +
+
+ +
+
+

+ {t(`pages.${slug}.title`)} +

+
    + {cards.map((c) => ( +
  • +

    {c.title}

    +

    {c.text}

    +
  • + ))} +
+
+
+ + {second && ( +
+ +
+ )} + + + + ); +} diff --git a/src/app/(marketing)/funktionen/page.tsx b/src/app/(marketing)/funktionen/page.tsx new file mode 100644 index 0000000..2ea342b --- /dev/null +++ b/src/app/(marketing)/funktionen/page.tsx @@ -0,0 +1,90 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { ArrowRight, Bot, CalendarRange, ClipboardList, FileInput, FileSignature, FolderOpen, MapPin, Package, Receipt, Siren, Timer, WifiOff, type LucideIcon } from "lucide-react"; +import { CtaBand, PageHero } from "@/components/marketing/blocks"; +import { FEATURE_SLUGS, type FeatureSlug } from "@/lib/marketing/pages"; + +/** Übersicht aller Funktionen mit Einstieg in die Detailseiten. */ + +export async function generateMetadata(): Promise { + const t = await getTranslations("marketing.overview"); + return { title: t("title"), description: t("lead") }; +} + +/** Detailseite je Modul — die übrigen Module stehen als Kacheln ohne eigene Seite. */ +const DETAIL_ICON: Record = { + planung: CalendarRange, + baustelle: WifiOff, + zeiterfassung: Timer, + berichte: FileSignature, + abrechnung: Receipt, + lotse: Bot, +}; +const DETAIL_FEATURE: Record = { + planung: "board", + baustelle: "offline", + zeiterfassung: "time", + berichte: "reports", + abrechnung: "billing", + lotse: "lotse", +}; +const MORE: { key: string; icon: LucideIcon }[] = [ + { key: "orders", icon: ClipboardList }, + { key: "live", icon: MapPin }, + { key: "material", icon: Package }, + { key: "emergency", icon: Siren }, + { key: "documents", icon: FolderOpen }, + { key: "imports", icon: FileInput }, +]; + +export default async function FeaturesOverviewPage() { + const t = await getTranslations("marketing"); + + return ( + <> + + +
+
    + {FEATURE_SLUGS.map((slug) => { + const Icon = DETAIL_ICON[slug]; + return ( +
  • + + + +

    {t(`pages.${slug}.title`)}

    +

    {t(`pages.${slug}.lead`)}

    + + {t("overview.more")} + + +
  • + ); + })} +
+
+ +
+

{t("features.title")}

+
    + {MORE.map(({ key, icon: Icon }) => ( +
  • + + + +

    {t(`features.${key}.title`)}

    +

    {t(`features.${key}.text`)}

    +
  • + ))} +
+

+ {FEATURE_SLUGS.map((slug) => t(`features.${DETAIL_FEATURE[slug]}.title`)).join(" · ")} +

+
+ + + + ); +} diff --git a/src/app/(marketing)/layout.tsx b/src/app/(marketing)/layout.tsx new file mode 100644 index 0000000..efbc23c --- /dev/null +++ b/src/app/(marketing)/layout.tsx @@ -0,0 +1,6 @@ +import { MarketingShell } from "@/components/marketing/shell"; + +/** Öffentlicher Bereich (Produktseiten) — ohne Session, siehe PUBLIC_PATHS in src/proxy.ts. */ +export default function MarketingLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return {children}; +} diff --git a/src/app/page.tsx b/src/app/(marketing)/page.tsx similarity index 79% rename from src/app/page.tsx rename to src/app/(marketing)/page.tsx index 738f6f4..90f7bf7 100644 --- a/src/app/page.tsx +++ b/src/app/(marketing)/page.tsx @@ -11,8 +11,8 @@ export async function generateMetadata(): Promise { } /** - * Öffentliche Produktseite. Angemeldete Nutzer sehen sie nicht: Feldrollen → /m, - * Backoffice → /dashboard (ARCHITEKTUR §5). `/` ist deshalb in PUBLIC_PATHS (src/proxy.ts). + * Öffentliche Startseite. Angemeldete Nutzer sehen sie nicht: Feldrollen → /m, + * Backoffice → /dashboard (ARCHITEKTUR §5). Kopf- und Fußzeile liefert `(marketing)/layout.tsx`. */ export default async function Home() { const session = await auth(); diff --git a/src/app/(marketing)/preise/page.tsx b/src/app/(marketing)/preise/page.tsx new file mode 100644 index 0000000..b2b2e0a --- /dev/null +++ b/src/app/(marketing)/preise/page.tsx @@ -0,0 +1,83 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { ArrowRight, CheckCircle2, Info } from "lucide-react"; +import { PageHero } from "@/components/marketing/blocks"; + +/** Testphase und Preise — bewusst ohne erfundene Zahlen, solange das Preismodell offen ist. */ + +export async function generateMetadata(): Promise { + const t = await getTranslations("marketing.pricingPage"); + return { title: t("title"), description: t("lead") }; +} + +export default async function PricingPage() { + const t = await getTranslations("marketing"); + const trialPoints = (["t1", "t2", "t3"] as const).map((k) => t(`pricingPage.${k}`)); + const fullPoints = (["f1", "f2", "f3"] as const).map((k) => t(`pricingPage.${k}`)); + + return ( + <> + + +
+
+
+

{t("pricingPage.trialTitle")}

+

{t("pricingPage.trialText")}

+
    + {trialPoints.map((p) => ( +
  • + + {p} +
  • + ))} +
+ + {t("trial.cta")} + + +
+ +
+

{t("pricingPage.fullTitle")}

+

{t("pricingPage.fullText")}

+
    + {fullPoints.map((p) => ( +
  • + + {p} +
  • + ))} +
+
+
+ +

+ + {t("pricingPage.note")} +

+
+ +
+
+

{t("faq.title")}

+
+ {(["q1", "q4", "q5", "q7", "q8"] as const).map((key) => ( +
+ + {t(`faq.${key}.q`)} + + + +

{t(`faq.${key}.a`)}

+
+ ))} +
+
+
+ + ); +} diff --git a/src/app/(marketing)/sicherheit/page.tsx b/src/app/(marketing)/sicherheit/page.tsx new file mode 100644 index 0000000..e158a8a --- /dev/null +++ b/src/app/(marketing)/sicherheit/page.tsx @@ -0,0 +1,67 @@ +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { Download, EyeOff, KeyRound, Layers, ScrollText, UsersRound, type LucideIcon } from "lucide-react"; +import { CtaBand, PageHero, ShotFrame } from "@/components/marketing/blocks"; + +/** Sicherheit und Datenschutz — Mandantentrennung, Rollen, Protokoll, Export. */ + +export async function generateMetadata(): Promise { + const t = await getTranslations("marketing.securityPage"); + return { title: t("title"), description: t("lead") }; +} + +const ITEMS: { key: string; icon: LucideIcon }[] = [ + { key: "item1", icon: Layers }, + { key: "item2", icon: UsersRound }, + { key: "item3", icon: KeyRound }, + { key: "item4", icon: ScrollText }, + { key: "item5", icon: Download }, + { key: "item6", icon: EyeOff }, +]; + +export default async function SecurityPage() { + const t = await getTranslations("marketing"); + + return ( + <> + + +
+
    + {ITEMS.map(({ key, icon: Icon }) => ( +
  • + + + +

    {t(`security.${key}.title`)}

    +

    {t(`security.${key}.text`)}

    +
  • + ))} +
+
+ +
+
+
+

{t("securityPage.detailTitle")}

+
+ {(["d1", "d2", "d3"] as const).map((k) => ( +
+
{t(`securityPage.${k}title`)}
+
{t(`securityPage.${k}text`)}
+
+ ))} +
+
+ +
+
+ +
+ +
+ + + + ); +} diff --git a/src/components/marketing/blocks.tsx b/src/components/marketing/blocks.tsx new file mode 100644 index 0000000..89afb9a --- /dev/null +++ b/src/components/marketing/blocks.tsx @@ -0,0 +1,71 @@ +import Image from "next/image"; +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { ArrowRight, Check } from "lucide-react"; +import type { Shot } from "@/lib/marketing/pages"; + +/** Bausteine der öffentlichen Produktseiten. Bildschirmfotos stammen aus dem Demo-Mandanten. */ + +const SIZE = { + desktop: { width: 2880, height: 1800, sizes: "(min-width: 1024px) 900px, 100vw" }, + mobile: { width: 1170, height: 2532, sizes: "(min-width: 1024px) 320px, 60vw" }, +} as const; + +export function ShotFrame({ shot, caption, priority }: { shot: Shot; caption: string; priority?: boolean }) { + const s = SIZE[shot.kind]; + return ( +
+
+ {caption} +
+
{caption}
+
+ ); +} + +export function PointList({ points }: { points: string[] }) { + return ( +
    + {points.map((p) => ( +
  • + + {p} +
  • + ))} +
+ ); +} + +export function PageHero({ eyebrow, title, lead }: { eyebrow?: string; title: string; lead: string }) { + return ( +
+ {eyebrow &&

{eyebrow}

} +

{title}

+

{lead}

+
+ ); +} + +export async function CtaBand() { + const t = await getTranslations("marketing.cta"); + return ( +
+
+

{t("title")}

+

{t("text")}

+
+ + {t("primary")} + + + + {t("secondary")} + +
+
+
+ ); +} diff --git a/src/components/marketing/home.tsx b/src/components/marketing/home.tsx index 578b170..19f6c91 100644 --- a/src/components/marketing/home.tsx +++ b/src/components/marketing/home.tsx @@ -24,9 +24,8 @@ import { WifiOff, type LucideIcon, } from "lucide-react"; -import { CraftviaLogo } from "@/components/brand/craftvia-logo"; -import { BRAND } from "@/lib/brand"; -import { BoardMockup, FieldMockup, LotseMockup } from "./mockups"; +import { ShotFrame } from "./blocks"; +import { FieldMockup } from "./mockups"; /** * Öffentliche Startseite (Produktseite). Wird von `/` gerendert, solange keine Session besteht; @@ -58,7 +57,6 @@ const SECURITY: { key: string; icon: LucideIcon }[] = [ { key: "item6", icon: EyeOff }, ]; -const NAV = ["features", "planning", "mobile", "security", "pricing", "faq"] as const; const FAQ = ["q1", "q2", "q3", "q4", "q5", "q6", "q7", "q8"] as const; const INDUSTRIES = ["shk", "electric", "cooling", "lifts", "windows", "service"] as const; @@ -71,35 +69,7 @@ export async function MarketingHome({ contactEmail }: { contactEmail?: string }) const t = await getTranslations("marketing"); return ( -
-
-
- - - - -
- - {t("nav.login")} - - - {t("nav.cta")} - -
-
-
- -
+ <> {/* Hero */}
@@ -125,7 +95,7 @@ export async function MarketingHome({ contactEmail }: { contactEmail?: string }) ))}
- +
@@ -177,7 +147,7 @@ export async function MarketingHome({ contactEmail }: { contactEmail?: string }) ))} - + @@ -206,7 +176,7 @@ export async function MarketingHome({ contactEmail }: { contactEmail?: string })

{t("lotse.text")}

{t("lotse.note")}

- + @@ -324,34 +294,6 @@ export async function MarketingHome({ contactEmail }: { contactEmail?: string }) - - -
-
-
- -

{t("footer.tagline")}

-
- - -
-

- © {new Date().getFullYear()} {BRAND.name} · {BRAND.tagline} · {t("footer.rights")} -

-
- + ); } diff --git a/src/components/marketing/mockups.tsx b/src/components/marketing/mockups.tsx index f7325f7..5ae8686 100644 --- a/src/components/marketing/mockups.tsx +++ b/src/components/marketing/mockups.tsx @@ -1,55 +1,11 @@ -import { Check, MapPin, PenLine, Truck, Wrench } from "lucide-react"; +import { Check, PenLine, Truck, Wrench } from "lucide-react"; import { getTranslations } from "next-intl/server"; /** - * Abstrakte Produktvorschauen für die Startseite — bewusst als Markup statt Screenshot: + * Ablaufdarstellung für die Startseite — bewusst als Markup statt Screenshot: * bleibt scharf, mehrsprachig, ohne Bildassets und ohne Daten aus einem echten Mandanten. */ -const BAR = "h-2 rounded-full"; - -export async function BoardMockup() { - const t = await getTranslations("marketing.hero.mockup"); - const rows = [ - { crew: t("crewNorth"), load: t("load"), percent: 94, jobs: [t("job1"), t("job2")], tone: "var(--ok)" }, - { crew: t("crewSouth"), load: t("conflict"), percent: 113, jobs: [t("job3")], tone: "var(--warn)" }, - ]; - return ( -
-
- {t("board")} - {t("today")} -
-
- {rows.map((row) => ( -
-
- {row.crew} - - {row.load} - -
-
-
-
-
    - {row.jobs.map((job) => ( -
  • - {job} -
  • - ))} -
-
- ))} -
-

- - {t("live")} -

-
- ); -} - export async function FieldMockup() { const t = await getTranslations("marketing.mobile"); const steps = [ @@ -78,28 +34,3 @@ export async function FieldMockup() { ); } - -export async function LotseMockup() { - const t = await getTranslations("marketing.lotse"); - return ( -
-

{t("chatUser")}

-

{t("chatAssistant")}

-
-

{t("cardTitle")}

-
    - {[t("cardLine1"), t("cardLine2"), t("cardLine3")].map((line) => ( -
  • - - {line} -
  • - ))} -
-
- {t("cardConfirm")} - {t("cardDiscard")} -
-
-
- ); -} diff --git a/src/components/marketing/shell.tsx b/src/components/marketing/shell.tsx new file mode 100644 index 0000000..87a378d --- /dev/null +++ b/src/components/marketing/shell.tsx @@ -0,0 +1,85 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { CraftviaLogo } from "@/components/brand/craftvia-logo"; +import { BRAND } from "@/lib/brand"; + +/** Gemeinsames Gerüst der öffentlichen Produktseiten: Kopfzeile, Inhalt, Fußzeile. */ + +const NAV = [ + { key: "features", href: "/funktionen" }, + { key: "planning", href: "/funktionen/planung" }, + { key: "mobile", href: "/funktionen/baustelle" }, + { key: "security", href: "/sicherheit" }, + { key: "pricing", href: "/preise" }, +] as const; + +export async function MarketingShell({ children }: { children: React.ReactNode }) { + const t = await getTranslations("marketing"); + return ( +
+
+
+ + + + +
+ + {t("nav.login")} + + + {t("nav.cta")} + +
+
+
+ +
{children}
+ +
+
+
+ +

{t("footer.tagline")}

+
+ + +
+

+ © {new Date().getFullYear()} {BRAND.name} · {BRAND.tagline} · {t("footer.rights")} +

+
+
+ ); +} diff --git a/src/components/planning/planning-board.tsx b/src/components/planning/planning-board.tsx index 6f0e193..1f19d1c 100644 --- a/src/components/planning/planning-board.tsx +++ b/src/components/planning/planning-board.tsx @@ -432,25 +432,42 @@ export function PlanningBoardView({ {board.unplanned.length === 0 &&

{t("unplanned.empty")}

}
    {board.unplanned.map((o) => ( -
  • - - {canSchedule && ( - - )} - {panelFor === o.id && ( - setPanelFor(null)} - onTake={(r: Recommendation) => open(o, r.teamId, r.day, wallClock(new Date(r.suggestedStart), tz))} - onPlanTogether={(nearby, r, required) => - open(nearby, r?.teamId ?? null, r?.day ?? null, r ? wallClock(new Date(new Date(r.suggestedStart).getTime() + required * 60_000), tz) : null) - } - /> - )} +
  • + + + {panelFor === o.id && ( + setPanelFor(null)} + onTake={(r: Recommendation) => open(o, r.teamId, r.day, wallClock(new Date(r.suggestedStart), tz))} + onPlanTogether={(nearby, r, required) => + open(nearby, r?.teamId ?? null, r?.day ?? null, r ? wallClock(new Date(new Date(r.suggestedStart).getTime() + required * 60_000), tz) : null) + } + /> + )} + + ) + } + />
  • ))}
@@ -559,6 +576,7 @@ function OrderCard({ tw, unplanned, compact, + footer, }: { order: BoardOrder; canSchedule: boolean; @@ -569,6 +587,8 @@ function OrderCard({ tw: ReturnType>; unplanned?: boolean; compact?: boolean; + /** an die Karte angehängter Bereich (z. B. „Vorschläge" in der Spalte „Ungeplante Aufträge") */ + footer?: React.ReactNode; }) { const movable = canSchedule && SCHEDULABLE_STATUSES.includes(order.status); const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: `order:${order.id}`, disabled: !movable }); @@ -678,6 +698,7 @@ function OrderCard({ )} + {footer &&
{footer}
} {movable && (