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>
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 377 KiB |
|
After Width: | Height: | Size: 483 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 517 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 345 KiB |
@@ -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);
|
||||
});
|
||||
@@ -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.<slug>`, 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<Metadata> {
|
||||
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 (
|
||||
<>
|
||||
<PageHero eyebrow={t("nav.features")} title={t(`pages.${slug}.title`)} lead={t(`pages.${slug}.lead`)} />
|
||||
|
||||
<section aria-label={t(`pages.${slug}.title`)} className="mx-auto max-w-6xl px-4 pb-12 sm:px-6">
|
||||
<div className={`grid gap-8 ${first.kind === "mobile" ? "lg:grid-cols-[1fr_0.6fr]" : "lg:grid-cols-[0.9fr_1.1fr]"} lg:items-center`}>
|
||||
<PointList points={points} />
|
||||
<ShotFrame shot={first} caption={t(`pages.${slug}.shot1`)} priority />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="detail-cards" className="border-y bg-card">
|
||||
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
|
||||
<h2 id="detail-cards" className="sr-only">
|
||||
{t(`pages.${slug}.title`)}
|
||||
</h2>
|
||||
<ul className="grid gap-4 md:grid-cols-3">
|
||||
{cards.map((c) => (
|
||||
<li key={c.title} className="rounded-xl border bg-background p-5">
|
||||
<h3 className="font-heading text-[16px] font-semibold">{c.title}</h3>
|
||||
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{c.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{second && (
|
||||
<section aria-label={t(`pages.${slug}.shot2`)} className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
|
||||
<ShotFrame shot={second} caption={t(`pages.${slug}.shot2`)} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<CtaBand />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<Metadata> {
|
||||
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<FeatureSlug, LucideIcon> = {
|
||||
planung: CalendarRange,
|
||||
baustelle: WifiOff,
|
||||
zeiterfassung: Timer,
|
||||
berichte: FileSignature,
|
||||
abrechnung: Receipt,
|
||||
lotse: Bot,
|
||||
};
|
||||
const DETAIL_FEATURE: Record<FeatureSlug, string> = {
|
||||
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 (
|
||||
<>
|
||||
<PageHero title={t("overview.title")} lead={t("overview.lead")} />
|
||||
|
||||
<section aria-label={t("overview.title")} className="mx-auto max-w-6xl px-4 pb-4 sm:px-6">
|
||||
<ul className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{FEATURE_SLUGS.map((slug) => {
|
||||
const Icon = DETAIL_ICON[slug];
|
||||
return (
|
||||
<li key={slug} className="shadow-card rounded-xl border bg-card p-5">
|
||||
<span className="flex size-10 items-center justify-center rounded-lg bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<h2 className="mt-3 font-heading text-[17px] font-semibold">{t(`pages.${slug}.title`)}</h2>
|
||||
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{t(`pages.${slug}.lead`)}</p>
|
||||
<Link href={`/funktionen/${slug}`} className="mt-3 inline-flex min-h-11 items-center gap-1.5 text-[14px] font-semibold text-[var(--ui-primary)]">
|
||||
{t("overview.more")}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="more-title" className="mx-auto max-w-6xl px-4 py-10 sm:px-6">
|
||||
<h2 id="more-title" className="font-heading text-[22px] font-semibold">{t("features.title")}</h2>
|
||||
<ul className="mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{MORE.map(({ key, icon: Icon }) => (
|
||||
<li key={key} className="rounded-xl border bg-card p-5">
|
||||
<span className="flex size-10 items-center justify-center rounded-lg bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<h3 className="mt-3 font-heading text-[16px] font-semibold">{t(`features.${key}.title`)}</h3>
|
||||
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{t(`features.${key}.text`)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-4 text-[13px] text-muted-foreground">
|
||||
{FEATURE_SLUGS.map((slug) => t(`features.${DETAIL_FEATURE[slug]}.title`)).join(" · ")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<CtaBand />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 <MarketingShell>{children}</MarketingShell>;
|
||||
}
|
||||
@@ -11,8 +11,8 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ö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();
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<>
|
||||
<PageHero eyebrow={t("trial.eyebrow")} title={t("pricingPage.title")} lead={t("pricingPage.lead")} />
|
||||
|
||||
<section aria-label={t("pricingPage.title")} className="mx-auto max-w-5xl px-4 pb-10 sm:px-6">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<article className="shadow-card rounded-2xl border border-l-4 border-l-[var(--ui-accent)] bg-card p-6">
|
||||
<h2 className="font-heading text-[20px] font-semibold">{t("pricingPage.trialTitle")}</h2>
|
||||
<p className="mt-2 text-[14.5px] leading-relaxed text-muted-foreground">{t("pricingPage.trialText")}</p>
|
||||
<ul className="mt-4 grid gap-2.5">
|
||||
{trialPoints.map((p) => (
|
||||
<li key={p} className="flex items-start gap-2 text-[14.5px]">
|
||||
<CheckCircle2 className="mt-0.5 size-[18px] shrink-0 text-[var(--ok)]" aria-hidden />
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href="/testen"
|
||||
className="mt-5 inline-flex min-h-12 items-center justify-center gap-2 rounded-lg bg-cta px-5 text-[15px] font-semibold text-cta-foreground hover:brightness-95"
|
||||
>
|
||||
{t("trial.cta")}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</article>
|
||||
|
||||
<article className="shadow-card rounded-2xl border bg-card p-6">
|
||||
<h2 className="font-heading text-[20px] font-semibold">{t("pricingPage.fullTitle")}</h2>
|
||||
<p className="mt-2 text-[14.5px] leading-relaxed text-muted-foreground">{t("pricingPage.fullText")}</p>
|
||||
<ul className="mt-4 grid gap-2.5">
|
||||
{fullPoints.map((p) => (
|
||||
<li key={p} className="flex items-start gap-2 text-[14.5px]">
|
||||
<CheckCircle2 className="mt-0.5 size-[18px] shrink-0 text-[var(--ui-primary)]" aria-hidden />
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<p className="mt-5 flex items-start gap-2 rounded-lg border border-[var(--info)] bg-card px-4 py-3 text-[14px]">
|
||||
<Info className="mt-0.5 size-[18px] shrink-0 text-[var(--info)]" aria-hidden />
|
||||
{t("pricingPage.note")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="pricing-faq" className="border-t bg-card">
|
||||
<div className="mx-auto max-w-4xl px-4 py-12 sm:px-6">
|
||||
<h2 id="pricing-faq" className="font-heading text-[24px] font-semibold">{t("faq.title")}</h2>
|
||||
<div className="mt-5 divide-y rounded-xl border bg-background">
|
||||
{(["q1", "q4", "q5", "q7", "q8"] as const).map((key) => (
|
||||
<details key={key} className="group px-4">
|
||||
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-3 text-[15px] font-semibold">
|
||||
{t(`faq.${key}.q`)}
|
||||
<span aria-hidden className="text-[var(--ui-primary)] transition-transform group-open:rotate-45">+</span>
|
||||
</summary>
|
||||
<p className="pb-4 text-[14.5px] leading-relaxed text-muted-foreground">{t(`faq.${key}.a`)}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<>
|
||||
<PageHero eyebrow={t("security.eyebrow")} title={t("securityPage.title")} lead={t("securityPage.lead")} />
|
||||
|
||||
<section aria-label={t("securityPage.title")} className="mx-auto max-w-6xl px-4 pb-12 sm:px-6">
|
||||
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{ITEMS.map(({ key, icon: Icon }) => (
|
||||
<li key={key} className="shadow-card rounded-xl border bg-card p-5">
|
||||
<span className="flex size-10 items-center justify-center rounded-lg bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<h2 className="mt-3 font-heading text-[16px] font-semibold">{t(`security.${key}.title`)}</h2>
|
||||
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{t(`security.${key}.text`)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="security-detail" className="border-y bg-card">
|
||||
<div className="mx-auto grid max-w-6xl gap-8 px-4 py-12 sm:px-6 lg:grid-cols-[0.9fr_1.1fr] lg:items-center">
|
||||
<div>
|
||||
<h2 id="security-detail" className="font-heading text-[24px] font-semibold">{t("securityPage.detailTitle")}</h2>
|
||||
<dl className="mt-5 grid gap-4">
|
||||
{(["d1", "d2", "d3"] as const).map((k) => (
|
||||
<div key={k}>
|
||||
<dt className="font-heading text-[16px] font-semibold">{t(`securityPage.${k}title`)}</dt>
|
||||
<dd className="mt-1 text-[14px] leading-relaxed text-muted-foreground">{t(`securityPage.${k}text`)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
<ShotFrame shot={{ src: "audit", kind: "desktop" }} caption={t("securityPage.shot1")} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-label={t("securityPage.shot2")} className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
|
||||
<ShotFrame shot={{ src: "teams", kind: "desktop" }} caption={t("securityPage.shot2")} />
|
||||
</section>
|
||||
|
||||
<CtaBand />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<figure className={shot.kind === "mobile" ? "mx-auto w-full max-w-[280px]" : "w-full"}>
|
||||
<div className="shadow-card overflow-hidden rounded-xl border bg-card">
|
||||
<Image src={`/marketing/${shot.src}.png`} alt={caption} width={s.width} height={s.height} sizes={s.sizes} priority={priority} className="h-auto w-full" />
|
||||
</div>
|
||||
<figcaption className="mt-2 text-[12.5px] text-muted-foreground">{caption}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
export function PointList({ points }: { points: string[] }) {
|
||||
return (
|
||||
<ul className="grid gap-2.5">
|
||||
{points.map((p) => (
|
||||
<li key={p} className="flex items-start gap-2 text-[14.5px]">
|
||||
<Check className="mt-0.5 size-[18px] shrink-0 text-[var(--ui-primary)]" aria-hidden />
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHero({ eyebrow, title, lead }: { eyebrow?: string; title: string; lead: string }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 pt-12 pb-8 text-center sm:px-6">
|
||||
{eyebrow && <p className="text-[13px] font-semibold tracking-wide text-[var(--ui-accent)] uppercase">{eyebrow}</p>}
|
||||
<h1 className="mt-2 font-heading text-[30px] leading-tight font-bold text-[var(--ui-primary)] sm:text-[40px]">{title}</h1>
|
||||
<p className="mt-4 text-[16px] leading-relaxed text-muted-foreground">{lead}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function CtaBand() {
|
||||
const t = await getTranslations("marketing.cta");
|
||||
return (
|
||||
<section aria-labelledby="cta-title" className="border-t bg-card">
|
||||
<div className="mx-auto max-w-4xl px-4 py-14 text-center sm:px-6">
|
||||
<h2 id="cta-title" className="font-heading text-[26px] font-semibold text-[var(--ui-primary)]">{t("title")}</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-[15.5px] text-muted-foreground">{t("text")}</p>
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<Link
|
||||
href="/testen"
|
||||
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-lg bg-cta px-5 text-[15px] font-semibold text-cta-foreground hover:brightness-95"
|
||||
>
|
||||
{t("primary")}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
<Link href="/funktionen" className="inline-flex min-h-12 items-center justify-center gap-2 rounded-lg border px-5 text-[15px] font-semibold hover:bg-muted">
|
||||
{t("secondary")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="sticky top-0 z-20 border-b bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-4 px-4 py-3 sm:px-6">
|
||||
<Link href="/" className="flex min-h-11 items-center" aria-label={BRAND.name}>
|
||||
<CraftviaLogo variant="horizontal" height={30} />
|
||||
</Link>
|
||||
<nav aria-label={t("nav.label")} className="ml-auto hidden lg:block">
|
||||
<ul className="flex items-center gap-1">
|
||||
{NAV.map((key) => (
|
||||
<li key={key}>
|
||||
<a href={`#${key}`} className="inline-flex min-h-11 items-center rounded-md px-3 text-[14px] font-medium text-muted-foreground hover:text-foreground">
|
||||
{t(`nav.${key}`)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<Link href="/login" className="inline-flex min-h-11 items-center px-2 text-[14px] font-semibold text-[var(--ui-primary)]">
|
||||
{t("nav.login")}
|
||||
</Link>
|
||||
<Link href="/testen" className="inline-flex min-h-11 items-center rounded-lg bg-cta px-4 text-[14px] font-semibold text-cta-foreground hover:brightness-95">
|
||||
{t("nav.cta")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1">
|
||||
<>
|
||||
{/* Hero */}
|
||||
<section className="border-b bg-card">
|
||||
<div className="mx-auto grid max-w-6xl gap-10 px-4 py-12 sm:px-6 lg:grid-cols-[1.1fr_0.9fr] lg:items-center lg:py-20">
|
||||
@@ -125,7 +95,7 @@ export async function MarketingHome({ contactEmail }: { contactEmail?: string })
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<BoardMockup />
|
||||
<ShotFrame shot={{ src: "plantafel", kind: "desktop" }} caption={t("pages.planung.shot1")} priority />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -177,7 +147,7 @@ export async function MarketingHome({ contactEmail }: { contactEmail?: string })
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<BoardMockup />
|
||||
<ShotFrame shot={{ src: "live-lage", kind: "desktop" }} caption={t("pages.planung.shot2")} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -206,7 +176,7 @@ export async function MarketingHome({ contactEmail }: { contactEmail?: string })
|
||||
<p className="mt-3 text-[15px] leading-relaxed text-muted-foreground">{t("lotse.text")}</p>
|
||||
<p className="mt-4 text-[13.5px] text-muted-foreground">{t("lotse.note")}</p>
|
||||
</div>
|
||||
<LotseMockup />
|
||||
<ShotFrame shot={{ src: "mobil-lotse", kind: "mobile" }} caption={t("pages.lotse.shot1")} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -324,34 +294,6 @@ export async function MarketingHome({ contactEmail }: { contactEmail?: string })
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="border-t bg-card">
|
||||
<div className="mx-auto grid max-w-6xl gap-8 px-4 py-10 sm:px-6 md:grid-cols-[1.4fr_1fr_1fr]">
|
||||
<div>
|
||||
<CraftviaLogo variant="horizontal" height={28} />
|
||||
<p className="mt-3 max-w-xs text-[13.5px] text-muted-foreground">{t("footer.tagline")}</p>
|
||||
</div>
|
||||
<nav aria-labelledby="footer-product">
|
||||
<h2 id="footer-product" className="text-[13px] font-semibold tracking-wide uppercase">{t("footer.product")}</h2>
|
||||
<ul className="mt-2 grid">
|
||||
<li><a href="#features" className="inline-flex min-h-11 items-center text-[14px] text-muted-foreground hover:text-foreground">{t("footer.features")}</a></li>
|
||||
<li><Link href="/testen" className="inline-flex min-h-11 items-center text-[14px] text-muted-foreground hover:text-foreground">{t("footer.trial")}</Link></li>
|
||||
<li><Link href="/login" className="inline-flex min-h-11 items-center text-[14px] text-muted-foreground hover:text-foreground">{t("footer.login")}</Link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<nav aria-labelledby="footer-legal">
|
||||
<h2 id="footer-legal" className="text-[13px] font-semibold tracking-wide uppercase">{t("footer.company")}</h2>
|
||||
<ul className="mt-2 grid">
|
||||
<li><Link href="/testen/nutzungsbedingungen" className="inline-flex min-h-11 items-center text-[14px] text-muted-foreground hover:text-foreground">{t("footer.terms")}</Link></li>
|
||||
<li><Link href="/testen/datenschutz" className="inline-flex min-h-11 items-center text-[14px] text-muted-foreground hover:text-foreground">{t("footer.privacy")}</Link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<p className="border-t px-4 py-4 text-center text-[12.5px] text-muted-foreground sm:px-6">
|
||||
© {new Date().getFullYear()} {BRAND.name} · {BRAND.tagline} · {t("footer.rights")}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<figure aria-label={t("label")} className="shadow-card rounded-2xl border bg-card p-4">
|
||||
<figcaption className="mb-3 flex items-center justify-between">
|
||||
<span className="font-heading text-[15px] font-semibold">{t("board")}</span>
|
||||
<span className="rounded-full border px-2 py-0.5 text-[11px] font-semibold text-muted-foreground">{t("today")}</span>
|
||||
</figcaption>
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => (
|
||||
<div key={row.crew} className="rounded-xl border p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-[13px] font-semibold">{row.crew}</span>
|
||||
<span className="text-[11px] font-semibold" style={{ color: row.tone }}>
|
||||
{row.load}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 w-full rounded-full bg-muted" role="presentation">
|
||||
<div className={BAR} style={{ width: `${Math.min(row.percent, 100)}%`, background: row.tone }} />
|
||||
</div>
|
||||
<ul className="mt-2 flex flex-wrap gap-1.5">
|
||||
{row.jobs.map((job) => (
|
||||
<li key={job} className="rounded-md border bg-background px-2 py-1 text-[11.5px]">
|
||||
{job}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 flex items-center gap-1.5 text-[11.5px] text-muted-foreground">
|
||||
<MapPin className="size-3.5" aria-hidden />
|
||||
{t("live")}
|
||||
</p>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
export async function FieldMockup() {
|
||||
const t = await getTranslations("marketing.mobile");
|
||||
const steps = [
|
||||
@@ -78,28 +34,3 @@ export async function FieldMockup() {
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
export async function LotseMockup() {
|
||||
const t = await getTranslations("marketing.lotse");
|
||||
return (
|
||||
<div className="shadow-card space-y-3 rounded-2xl border bg-card p-4">
|
||||
<p className="ml-auto max-w-[85%] rounded-2xl rounded-br-sm bg-[var(--ui-primary)] px-3 py-2 text-[13.5px] text-white">{t("chatUser")}</p>
|
||||
<p className="max-w-[90%] rounded-2xl rounded-bl-sm border bg-background px-3 py-2 text-[13.5px]">{t("chatAssistant")}</p>
|
||||
<div className="rounded-xl border border-l-4 border-l-[var(--ui-accent)] bg-background p-3">
|
||||
<p className="text-[12px] font-semibold text-muted-foreground uppercase">{t("cardTitle")}</p>
|
||||
<ul className="mt-1.5 space-y-1 text-[13.5px]">
|
||||
{[t("cardLine1"), t("cardLine2"), t("cardLine3")].map((line) => (
|
||||
<li key={line} className="flex items-start gap-2">
|
||||
<Check className="mt-0.5 size-4 shrink-0 text-[var(--ok)]" aria-hidden />
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className="rounded-lg bg-cta px-3 py-1.5 text-[13px] font-semibold text-cta-foreground">{t("cardConfirm")}</span>
|
||||
<span className="rounded-lg border px-3 py-1.5 text-[13px] font-semibold">{t("cardDiscard")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="sticky top-0 z-20 border-b bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-4 px-4 py-3 sm:px-6">
|
||||
<Link href="/" className="flex min-h-11 items-center" aria-label={BRAND.name}>
|
||||
<CraftviaLogo variant="horizontal" height={30} />
|
||||
</Link>
|
||||
<nav aria-label={t("nav.label")} className="ml-auto hidden lg:block">
|
||||
<ul className="flex items-center gap-1">
|
||||
{NAV.map((item) => (
|
||||
<li key={item.key}>
|
||||
<Link href={item.href} className="inline-flex min-h-11 items-center rounded-md px-3 text-[14px] font-medium text-muted-foreground hover:text-foreground">
|
||||
{t(`nav.${item.key}`)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<Link href="/login" className="inline-flex min-h-11 items-center px-2 text-[14px] font-semibold text-[var(--ui-primary)]">
|
||||
{t("nav.login")}
|
||||
</Link>
|
||||
<Link href="/testen" className="inline-flex min-h-11 items-center rounded-lg bg-cta px-4 text-[14px] font-semibold text-cta-foreground hover:brightness-95">
|
||||
{t("nav.cta")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1">{children}</main>
|
||||
|
||||
<footer className="border-t bg-card">
|
||||
<div className="mx-auto grid max-w-6xl gap-8 px-4 py-10 sm:px-6 md:grid-cols-[1.4fr_1fr_1fr]">
|
||||
<div>
|
||||
<CraftviaLogo variant="horizontal" height={28} />
|
||||
<p className="mt-3 max-w-xs text-[13.5px] text-muted-foreground">{t("footer.tagline")}</p>
|
||||
</div>
|
||||
<nav aria-labelledby="footer-product">
|
||||
<h2 id="footer-product" className="text-[13px] font-semibold tracking-wide uppercase">{t("footer.product")}</h2>
|
||||
<ul className="mt-2 grid">
|
||||
{NAV.map((item) => (
|
||||
<li key={item.key}>
|
||||
<Link href={item.href} className="inline-flex min-h-11 items-center text-[14px] text-muted-foreground hover:text-foreground">
|
||||
{t(`nav.${item.key}`)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<Link href="/testen" className="inline-flex min-h-11 items-center text-[14px] text-muted-foreground hover:text-foreground">{t("footer.trial")}</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<nav aria-labelledby="footer-legal">
|
||||
<h2 id="footer-legal" className="text-[13px] font-semibold tracking-wide uppercase">{t("footer.company")}</h2>
|
||||
<ul className="mt-2 grid">
|
||||
<li><Link href="/login" className="inline-flex min-h-11 items-center text-[14px] text-muted-foreground hover:text-foreground">{t("footer.login")}</Link></li>
|
||||
<li><Link href="/testen/nutzungsbedingungen" className="inline-flex min-h-11 items-center text-[14px] text-muted-foreground hover:text-foreground">{t("footer.terms")}</Link></li>
|
||||
<li><Link href="/testen/datenschutz" className="inline-flex min-h-11 items-center text-[14px] text-muted-foreground hover:text-foreground">{t("footer.privacy")}</Link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<p className="border-t px-4 py-4 text-center text-[12.5px] text-muted-foreground sm:px-6">
|
||||
© {new Date().getFullYear()} {BRAND.name} · {BRAND.tagline} · {t("footer.rights")}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -432,14 +432,27 @@ export function PlanningBoardView({
|
||||
{board.unplanned.length === 0 && <p className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">{t("unplanned.empty")}</p>}
|
||||
<ul className="space-y-3">
|
||||
{board.unplanned.map((o) => (
|
||||
<li key={o.id} className="space-y-1.5">
|
||||
<OrderCard order={o} {...cardProps} unplanned />
|
||||
{canSchedule && (
|
||||
<button type="button" aria-expanded={panelFor === o.id} onClick={() => setPanelFor(panelFor === o.id ? null : o.id)} className={`${buttonCls("ghost")} w-full`}>
|
||||
<li key={o.id}>
|
||||
<OrderCard
|
||||
order={o}
|
||||
{...cardProps}
|
||||
unplanned
|
||||
footer={
|
||||
canSchedule && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={panelFor === o.id}
|
||||
aria-label={t("unplanned.suggestionsFor", { number: o.number })}
|
||||
onClick={() => setPanelFor(panelFor === o.id ? null : o.id)}
|
||||
className={`${buttonCls("ghost")} w-full justify-between`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Lightbulb className="size-4" aria-hidden />
|
||||
{t("unplanned.suggestions")}
|
||||
</span>
|
||||
<ChevronDown className={cn("size-4 transition-transform", panelFor === o.id && "rotate-180")} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
{panelFor === o.id && (
|
||||
<RecommendationsPanel
|
||||
workOrderId={o.id}
|
||||
@@ -451,6 +464,10 @@ export function PlanningBoardView({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -559,6 +576,7 @@ function OrderCard({
|
||||
tw,
|
||||
unplanned,
|
||||
compact,
|
||||
footer,
|
||||
}: {
|
||||
order: BoardOrder;
|
||||
canSchedule: boolean;
|
||||
@@ -569,6 +587,8 @@ function OrderCard({
|
||||
tw: ReturnType<typeof useTranslations<"workOrders">>;
|
||||
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({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{footer && <div className="mt-1.5 border-t pt-1">{footer}</div>}
|
||||
{movable && (
|
||||
<button type="button" onClick={() => onOpen(order, null, null, null)} className="mt-1 inline-flex min-h-11 w-full items-center justify-center gap-1 rounded-md border text-[11px] font-semibold hover:bg-muted">
|
||||
<CalendarPlus className="size-3.5" aria-hidden />
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Aufbau der öffentlichen Funktionsseiten (`/funktionen/<slug>`). Texte liegen in
|
||||
* `messages/<locale>/marketing.json` unter `pages.<slug>`, Bildschirmfotos in `public/marketing/`
|
||||
* (erzeugt von `scripts/marketing-shots.ts`).
|
||||
*/
|
||||
|
||||
export const FEATURE_SLUGS = ["planung", "baustelle", "zeiterfassung", "berichte", "abrechnung", "lotse"] as const;
|
||||
export type FeatureSlug = (typeof FEATURE_SLUGS)[number];
|
||||
|
||||
export type Shot = { src: string; kind: "desktop" | "mobile" };
|
||||
|
||||
export type FeaturePage = {
|
||||
/** Reihenfolge der Bildschirmfotos; Beschriftungen kommen aus `pages.<slug>.shot<n>` */
|
||||
shots: Shot[];
|
||||
points: number;
|
||||
cards: number;
|
||||
};
|
||||
|
||||
const desktop = (src: string): Shot => ({ src, kind: "desktop" });
|
||||
const mobile = (src: string): Shot => ({ src, kind: "mobile" });
|
||||
|
||||
export const FEATURE_PAGES: Record<FeatureSlug, FeaturePage> = {
|
||||
planung: { shots: [desktop("plantafel"), desktop("live-lage")], points: 5, cards: 3 },
|
||||
baustelle: { shots: [mobile("mobil-heute"), mobile("mobil-auftrag")], points: 5, cards: 3 },
|
||||
zeiterfassung: { shots: [mobile("mobil-zeiten"), desktop("zeitfreigabe")], points: 5, cards: 3 },
|
||||
berichte: { shots: [desktop("berichte"), desktop("auftrag")], points: 5, cards: 3 },
|
||||
abrechnung: { shots: [desktop("abrechnung")], points: 5, cards: 3 },
|
||||
lotse: { shots: [mobile("mobil-lotse")], points: 5, cards: 3 },
|
||||
};
|
||||
|
||||
export const isFeatureSlug = (v: string): v is FeatureSlug => (FEATURE_SLUGS as readonly string[]).includes(v);
|
||||
@@ -12,7 +12,7 @@ import { NextResponse, type NextRequest } from "next/server";
|
||||
// L15 Testphase: `/testen` (Wizard, Bestätigung, Nutzungsbedingungen, Datenschutz) ist öffentlich —
|
||||
// abgesichert über Rate-Limit je IP/E-Mail, Honeypot, Double-Opt-in und Enumeration-Neutralität.
|
||||
// Startseite ist die öffentliche Produktseite; angemeldete Nutzer leitet page.tsx weiter.
|
||||
const PUBLIC_PATHS = ["/", "/login", "/api/auth", "/forgot-password", "/reset", "/invite", "/verify-email", "/platform/login", "/api/platform-auth", "/testen"];
|
||||
const PUBLIC_PATHS = ["/", "/funktionen", "/sicherheit", "/preise", "/login", "/api/auth", "/forgot-password", "/reset", "/invite", "/verify-email", "/platform/login", "/api/platform-auth", "/testen"];
|
||||
|
||||
// Plattform-Bereich (getrennte Session/Login): diese Routen werden über das
|
||||
// Plattform-Cookie gegatet und leiten anonyme Besucher auf /platform/login —
|
||||
|
||||