diff --git a/assets/js/i18n-wd-portal.js b/assets/js/i18n-wd-portal.js
new file mode 100644
index 00000000..7503efce
--- /dev/null
+++ b/assets/js/i18n-wd-portal.js
@@ -0,0 +1,189 @@
+/* =====================================================================
+ i18n-wd-portal.js — Kundenportal (Masterplan S.12), fünf Sprachen.
+
+ Anders als die Verwaltung ist das Portal mehrsprachig: hier landen
+ Kunden, und die können Franzosen oder Portugiesen sein. Die Sprache
+ folgt derselben Auswahl wie auf der öffentlichen Seite.
+ ===================================================================== */
+window.I18N_WD = {
+ seiten_titel: {
+ de: "Kundenportal — Dogfather Webdesign",
+ "de-CH": "Kundeportal — Dogfather Webdesign",
+ en: "Client portal — Dogfather Web Design",
+ fr: "Espace client — Dogfather Web Design",
+ pt: "Portal do cliente — Dogfather Web Design",
+ },
+
+ /* ---------- Anmeldung ---------- */
+ po_login_h1: { de: "Dein Projektraum", "de-CH": "Din Projektruum", en: "Your project space", fr: "Ton espace projet", pt: "O teu espaço de projeto" },
+ po_login_text: {
+ de: "Hier siehst du deinen Projektstand, alle Dateien und offene Punkte. Zugangsdaten bekommst du von Dogfather, sobald dein Projekt startet.",
+ "de-CH": "Do gsehsch din Projektstand, alli Datei und offni Pünkt. Zuegangsdate überchunnsch vom Dogfather, sobald dis Projekt startet.",
+ en: "Here you see your project status, all files and open points. You receive your access details from Dogfather once your project starts.",
+ fr: "Ici tu vois l'état de ton projet, tous les fichiers et les points ouverts. Tes accès te sont remis par Dogfather au démarrage du projet.",
+ pt: "Aqui vês o estado do teu projeto, todos os ficheiros e pontos em aberto. Recebes os teus acessos do Dogfather quando o projeto arrancar.",
+ },
+ po_email: { de: "E-Mail", "de-CH": "E-Mail", en: "E-mail", fr: "E-mail", pt: "E-mail" },
+ po_passwort: { de: "Passwort", "de-CH": "Passwort", en: "Password", fr: "Mot de passe", pt: "Palavra-passe" },
+ po_anmelden: { de: "Anmelden", "de-CH": "Aamälde", en: "Sign in", fr: "Se connecter", pt: "Entrar" },
+ po_anmelden_laeuft: { de: "Wird geprüft…", "de-CH": "Wird prüeft…", en: "Checking…", fr: "Vérification…", pt: "A verificar…" },
+ po_abmelden: { de: "Abmelden", "de-CH": "Abmälde", en: "Sign out", fr: "Se déconnecter", pt: "Sair" },
+ po_login_leer: {
+ de: "Bitte E-Mail und Passwort eingeben.",
+ "de-CH": "Bitte E-Mail und Passwort iigäh.",
+ en: "Please enter e-mail and password.",
+ fr: "Merci de saisir l'e-mail et le mot de passe.",
+ pt: "Introduz o e-mail e a palavra-passe.",
+ },
+ po_kein_zugang: {
+ de: "Noch keinen Zugang? Der Projektraum wird freigeschaltet, sobald dein Projekt oder deine Betreuung beginnt.",
+ "de-CH": "No kein Zuegang? Dr Projektruum wird freigschalte, sobald dis Projekt oder dini Betreuig aafoht.",
+ en: "No access yet? The project space is unlocked once your project or support plan begins.",
+ fr: "Pas encore d'accès ? L'espace projet est activé dès le démarrage de ton projet ou de ton suivi.",
+ pt: "Ainda sem acesso? O espaço de projeto é ativado quando o teu projeto ou acompanhamento começar.",
+ },
+
+ /* ---------- Passwort setzen ---------- */
+ po_setz_h1: { de: "Passwort festlegen", "de-CH": "Passwort festleggä", en: "Set your password", fr: "Définir ton mot de passe", pt: "Definir a palavra-passe" },
+ po_setz_text: {
+ de: "Wähl ein Passwort für deinen Projektraum. Ein kurzer Satz ist sicherer und leichter zu merken als ein kryptisches Wort.",
+ "de-CH": "Wähl es Passwort für din Projektruum. En churze Satz isch sicherer und liechter z merke als es kryptischs Wort.",
+ en: "Choose a password for your project space. A short sentence is safer and easier to remember than a cryptic word.",
+ fr: "Choisis un mot de passe pour ton espace projet. Une courte phrase est plus sûre et plus facile à retenir qu'un mot cryptique.",
+ pt: "Escolhe uma palavra-passe para o teu espaço. Uma frase curta é mais segura e fácil de lembrar do que uma palavra críptica.",
+ },
+ po_setz_knopf: { de: "Passwort speichern", "de-CH": "Passwort speichere", en: "Save password", fr: "Enregistrer le mot de passe", pt: "Guardar palavra-passe" },
+ po_setz_fertig: {
+ de: "Passwort gespeichert. Du kannst dich jetzt anmelden.",
+ "de-CH": "Passwort gspeicheret. Du chasch dich jetzt aamälde.",
+ en: "Password saved. You can sign in now.",
+ fr: "Mot de passe enregistré. Tu peux te connecter.",
+ pt: "Palavra-passe guardada. Já podes entrar.",
+ },
+
+ /* ---------- Übersicht ---------- */
+ po_hallo: { de: "Hallo", "de-CH": "Hoi", en: "Hello", fr: "Bonjour", pt: "Olá" },
+ po_projekte: { de: "Deine Projekte", "de-CH": "Dini Projekt", en: "Your projects", fr: "Tes projets", pt: "Os teus projetos" },
+ po_keine_projekte: {
+ de: "Hier erscheint dein Projekt, sobald es angelegt ist.",
+ "de-CH": "Do erschiint dis Projekt, sobald es aagleit isch.",
+ en: "Your project appears here as soon as it is set up.",
+ fr: "Ton projet apparaîtra ici dès sa création.",
+ pt: "O teu projeto aparece aqui assim que for criado.",
+ },
+ po_betreuung: { de: "Laufende Betreuung", "de-CH": "Laufendi Betreuig", en: "Ongoing support", fr: "Suivi en cours", pt: "Acompanhamento em curso" },
+ po_offene_zahlungen: { de: "Offene Zahlungen", "de-CH": "Offni Zahlige", en: "Open payments", fr: "Paiements en attente", pt: "Pagamentos em aberto" },
+ po_jetzt_bezahlen: { de: "Jetzt bezahlen", "de-CH": "Jetzt zahle", en: "Pay now", fr: "Payer maintenant", pt: "Pagar agora" },
+ po_naechste_zahlung: { de: "Nächste Zahlung", "de-CH": "Nöchsti Zahlig", en: "Next payment", fr: "Prochain paiement", pt: "Próximo pagamento" },
+
+ /* ---------- Projektstatus ---------- */
+ po_status: { de: "Stand", "de-CH": "Stand", en: "Status", fr: "État", pt: "Estado" },
+ po_st_briefing: { de: "Briefing", "de-CH": "Briefing", en: "Briefing", fr: "Briefing", pt: "Briefing" },
+ po_st_angebot: { de: "Angebot", "de-CH": "Aagebot", en: "Quote", fr: "Devis", pt: "Orçamento" },
+ po_st_design: { de: "Design", "de-CH": "Design", en: "Design", fr: "Design", pt: "Design" },
+ po_st_entwicklung: { de: "Entwicklung", "de-CH": "Entwicklig", en: "Development", fr: "Développement", pt: "Desenvolvimento" },
+ po_st_tests: { de: "Tests", "de-CH": "Teschts", en: "Testing", fr: "Tests", pt: "Testes" },
+ po_st_abnahme: { de: "Abnahme", "de-CH": "Abnahm", en: "Acceptance", fr: "Validation", pt: "Aceitação" },
+ po_st_uebergeben: { de: "Übergeben", "de-CH": "Übergäh", en: "Handed over", fr: "Remis", pt: "Entregue" },
+ po_st_pausiert: { de: "Pausiert", "de-CH": "Pausiert", en: "Paused", fr: "En pause", pt: "Em pausa" },
+ po_st_abgebrochen: { de: "Abgebrochen", "de-CH": "Abbroche", en: "Cancelled", fr: "Annulé", pt: "Cancelado" },
+
+ po_naechster_schritt: { de: "Nächster Schritt", "de-CH": "Nöchste Schritt", en: "Next step", fr: "Prochaine étape", pt: "Próximo passo" },
+ po_wartet_kunde: {
+ de: "Wir warten auf dich",
+ "de-CH": "Mir warted uf dich",
+ en: "Waiting for you",
+ fr: "En attente de ta part",
+ pt: "À espera de ti",
+ },
+ po_wartet_dogfather: {
+ de: "Dogfather ist dran",
+ "de-CH": "Dr Dogfather isch dra",
+ en: "Dogfather is working on it",
+ fr: "Dogfather s'en occupe",
+ pt: "O Dogfather está a tratar",
+ },
+ po_richttermin: { de: "Richttermin", "de-CH": "Richttermin", en: "Target date", fr: "Date cible", pt: "Data prevista" },
+ po_oeffnen: { de: "Öffnen", "de-CH": "Ufmache", en: "Open", fr: "Ouvrir", pt: "Abrir" },
+ po_zurueck: { de: "Zurück zur Übersicht", "de-CH": "Zrugg zur Übersicht", en: "Back to overview", fr: "Retour à l'aperçu", pt: "Voltar à visão geral" },
+
+ /* ---------- Projektdetail ---------- */
+ po_dateien: { de: "Dateien", "de-CH": "Datei", en: "Files", fr: "Fichiers", pt: "Ficheiros" },
+ po_keine_dateien: { de: "Noch keine Dateien.", "de-CH": "No kei Datei.", en: "No files yet.", fr: "Aucun fichier pour l'instant.", pt: "Ainda sem ficheiros." },
+ po_version: { de: "Fassung", "de-CH": "Fassig", en: "Version", fr: "Version", pt: "Versão" },
+ po_nachrichten: { de: "Nachrichten", "de-CH": "Nachrichte", en: "Messages", fr: "Messages", pt: "Mensagens" },
+ po_keine_nachrichten: { de: "Noch keine Nachrichten.", "de-CH": "No kei Nachrichte.", en: "No messages yet.", fr: "Aucun message pour l'instant.", pt: "Ainda sem mensagens." },
+ po_nachricht_platzhalter: {
+ de: "Frage oder Rückmeldung schreiben…",
+ "de-CH": "Frog oder Rückmäldig schriibe…",
+ en: "Write a question or some feedback…",
+ fr: "Écris une question ou un retour…",
+ pt: "Escreve uma pergunta ou um comentário…",
+ },
+ po_senden: { de: "Senden", "de-CH": "Schicke", en: "Send", fr: "Envoyer", pt: "Enviar" },
+
+ /* ---------- Änderungswünsche ---------- */
+ po_ideen: { de: "Ideen & Änderungswünsche", "de-CH": "Ideä & Änderigswünsch", en: "Ideas & change requests", fr: "Idées & demandes de modification", pt: "Ideias & pedidos de alteração" },
+ po_ideen_text: {
+ de: "Trag hier ein, was dir noch einfällt. Ich prüfe Aufwand und Preis und melde mich — du entscheidest danach in Ruhe.",
+ "de-CH": "Trag do ii, was dir no iifallt. Ich prüefe Ufwand und Priis und mälde mich — du entscheidsch dernoch i Rueh.",
+ en: "Note down whatever comes to mind. I review effort and price and get back to you — you then decide at your own pace.",
+ fr: "Note ici tout ce qui te vient. J'évalue l'effort et le prix et je reviens vers toi — tu décides ensuite tranquillement.",
+ pt: "Aponta aqui o que te ocorrer. Avalio esforço e preço e volto a contactar-te — depois decides com calma.",
+ },
+ po_idee_titel: { de: "Kurz gesagt", "de-CH": "Churz gseit", en: "In short", fr: "En bref", pt: "Em resumo" },
+ po_idee_text: { de: "Beschreibung", "de-CH": "Beschriibig", en: "Description", fr: "Description", pt: "Descrição" },
+ po_idee_senden: { de: "Idee einreichen", "de-CH": "Idee iireiche", en: "Submit idea", fr: "Envoyer l'idée", pt: "Enviar ideia" },
+ po_idee_leer: {
+ de: "Bitte gib der Idee einen Titel und beschreib sie kurz.",
+ "de-CH": "Bitte gib dr Idee en Titel und beschriib si churz.",
+ en: "Please give the idea a title and describe it briefly.",
+ fr: "Merci de donner un titre à l'idée et de la décrire brièvement.",
+ pt: "Dá um título à ideia e descreve-a brevemente.",
+ },
+ po_keine_ideen: { de: "Noch keine Ideen eingereicht.", "de-CH": "No kei Ideä iigreicht.", en: "No ideas submitted yet.", fr: "Aucune idée soumise pour l'instant.", pt: "Ainda sem ideias submetidas." },
+
+ po_ae_eingereicht: { de: "Eingereicht", "de-CH": "Iigreicht", en: "Submitted", fr: "Soumise", pt: "Submetida" },
+ po_ae_geprueft: { de: "Wird geprüft", "de-CH": "Wird prüeft", en: "Under review", fr: "En cours d'examen", pt: "Em análise" },
+ po_ae_angeboten: { de: "Angebot liegt vor", "de-CH": "Aagebot ligt vor", en: "Quote available", fr: "Devis disponible", pt: "Orçamento disponível" },
+ po_ae_angenommen: { de: "Angenommen", "de-CH": "Aagno", en: "Accepted", fr: "Acceptée", pt: "Aceite" },
+ po_ae_abgelehnt: { de: "Abgelehnt", "de-CH": "Abglehnt", en: "Declined", fr: "Refusée", pt: "Recusada" },
+ po_ae_umgesetzt: { de: "Umgesetzt", "de-CH": "Umgsetzt", en: "Implemented", fr: "Réalisée", pt: "Implementada" },
+
+ po_zusatzpreis: { de: "Zusätzlich", "de-CH": "Zuesätzlich", en: "Additional", fr: "En supplément", pt: "Adicional" },
+ po_zusatzdauer: { de: "Zusätzliche Dauer", "de-CH": "Zuesätzlichi Duur", en: "Additional time", fr: "Délai supplémentaire", pt: "Tempo adicional" },
+ po_annehmen: { de: "Annehmen", "de-CH": "Aanäh", en: "Accept", fr: "Accepter", pt: "Aceitar" },
+ po_ablehnen: { de: "Ablehnen", "de-CH": "Ablähne", en: "Decline", fr: "Refuser", pt: "Recusar" },
+ po_annehmen_frage: {
+ de: "Damit nimmst du das Zusatzangebot verbindlich an. Fortfahren?",
+ "de-CH": "Demit nimmsch s Zuesatzaagebot verbindlich aa. Wiitermache?",
+ en: "This accepts the additional quote as binding. Continue?",
+ fr: "Cela accepte le devis complémentaire de manière ferme. Continuer ?",
+ pt: "Isto aceita o orçamento adicional de forma vinculativa. Continuar?",
+ },
+
+ /* ---------- Zahlungen ---------- */
+ po_zahlungen: { de: "Zahlungen", "de-CH": "Zahlige", en: "Payments", fr: "Paiements", pt: "Pagamentos" },
+ po_za_offen: { de: "Offen", "de-CH": "Offe", en: "Open", fr: "En attente", pt: "Em aberto" },
+ po_za_freigegeben: { de: "In Bearbeitung", "de-CH": "I Bearbeitig", en: "Processing", fr: "En cours", pt: "Em processamento" },
+ po_za_bezahlt: { de: "Bezahlt", "de-CH": "Zahlt", en: "Paid", fr: "Payé", pt: "Pago" },
+ po_za_fehlge: { de: "Fehlgeschlagen", "de-CH": "Fählgschlage", en: "Failed", fr: "Échoué", pt: "Falhou" },
+ po_za_erstattet: { de: "Erstattet", "de-CH": "Erstattet", en: "Refunded", fr: "Remboursé", pt: "Reembolsado" },
+ po_keine_zahlungen: { de: "Keine Zahlungen vorgemerkt.", "de-CH": "Kei Zahlige vorgmerkt.", en: "No payments due.", fr: "Aucun paiement prévu.", pt: "Sem pagamentos previstos." },
+
+ /* ---------- Fehler ---------- */
+ po_fehler_login: {
+ de: "E-Mail oder Passwort stimmt nicht.",
+ "de-CH": "E-Mail oder Passwort stimmt nöd.",
+ en: "E-mail or password is incorrect.",
+ fr: "L'e-mail ou le mot de passe est incorrect.",
+ pt: "E-mail ou palavra-passe incorretos.",
+ },
+ po_abgelaufen: {
+ de: "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.",
+ "de-CH": "Dini Sitzig isch abgloffe. Bitte mäld dich nomol aa.",
+ en: "Your session has expired. Please sign in again.",
+ fr: "Ta session a expiré. Merci de te reconnecter.",
+ pt: "A tua sessão expirou. Inicia sessão novamente.",
+ },
+};
diff --git a/assets/js/wd-portal.js b/assets/js/wd-portal.js
new file mode 100644
index 00000000..2d120d99
--- /dev/null
+++ b/assets/js/wd-portal.js
@@ -0,0 +1,456 @@
+/* =====================================================================
+ wd-portal.js — Kundenportal, Oberfläche (Masterplan S.12)
+
+ Vier Ansichten in einer Seite: Anmeldung, Passwort festlegen,
+ Übersicht, Projektdetail.
+
+ Wichtig zum Verständnis: Diese Datei stellt nur DAR. Sie entscheidet
+ nichts darüber, wer was sehen darf — das macht ausschließlich der
+ Server (routes/webdesign-portal.js), weil dieser Code hier im Browser
+ des Kunden läuft und dort beliebig veränderbar ist. Was hier ankommt,
+ ist bereits gefiltert.
+ ===================================================================== */
+(function () {
+ "use strict";
+
+ var TOKEN = "wd-portal-token";
+ var token = null;
+ var s = window.WD.sicher;
+ var meldung = document.getElementById("po-meldung");
+
+ /* Sitzungsspeicher, nicht dauerhaft: derselbe Grundsatz wie überall in
+ diesem Bereich — beim Schließen ist der Zugang weg. */
+ function tokenLesen() { try { return sessionStorage.getItem(TOKEN); } catch (e) { return null; } }
+ function tokenSchreiben(t) {
+ try { if (t) sessionStorage.setItem(TOKEN, t); else sessionStorage.removeItem(TOKEN); } catch (e) { /* egal */ }
+ }
+
+ function api(pfad, optionen) {
+ var opt = optionen || {};
+ opt.headers = Object.assign({ Authorization: "Bearer " + token }, opt.headers || {});
+ return window.WD.api(pfad, opt);
+ }
+
+ function zeige(welche) {
+ ["po-anmeldung", "po-setzen", "po-uebersicht", "po-detail"].forEach(function (id) {
+ document.getElementById(id).hidden = id !== welche;
+ });
+ }
+
+ /* Sitzung abgelaufen: zurück zur Anmeldung mit klarer Ansage. Eine leere
+ Seite würde aussehen wie "du hast keine Projekte" — eine stille
+ Falschaussage. */
+ function behandleFehler(fehler) {
+ if (fehler && fehler.status === 401) {
+ token = null; tokenSchreiben(null);
+ zeige("po-anmeldung");
+ window.WD.meldung(meldung, window.WD.t("po_abgelaufen"), "fehler");
+ return true;
+ }
+ return false;
+ }
+
+ /* ---------------- Anmeldung ---------------- */
+ document.getElementById("po-login-form").addEventListener("submit", async function (e) {
+ e.preventDefault();
+ var feld = document.getElementById("po-feld-passwort");
+ var knopf = e.target.querySelector("button");
+ feld.classList.remove("wd-feld--fehler");
+ window.WD.meldungAus(meldung);
+
+ var email = document.getElementById("po-email").value.trim();
+ var passwort = document.getElementById("po-passwort").value;
+ if (!email || !passwort) {
+ feld.classList.add("wd-feld--fehler");
+ feld.querySelector(".wd-feld-fehler").textContent = window.WD.t("po_login_leer");
+ return;
+ }
+
+ window.WD.knopfSperren(knopf, true, window.WD.t("po_anmelden_laeuft"));
+ try {
+ var antwort = await window.WD.api("/webdesign/portal/anmelden", {
+ method: "POST", body: { email: email, passwort: passwort },
+ });
+ token = antwort.token;
+ tokenSchreiben(token);
+ /* Sprache des Kunden übernehmen, wenn er eine hinterlegt hat —
+ so kommt er in seiner Sprache an, ohne umschalten zu müssen. */
+ if (antwort.kunde && antwort.kunde.sprache) window.WD.setSprache(antwort.kunde.sprache);
+ ladeUebersicht();
+ } catch (fehler) {
+ feld.classList.add("wd-feld--fehler");
+ feld.querySelector(".wd-feld-fehler").textContent = fehler.message || window.WD.t("po_fehler_login");
+ document.getElementById("po-passwort").select();
+ } finally {
+ window.WD.knopfSperren(knopf, false);
+ }
+ });
+
+ document.getElementById("po-abmelden").addEventListener("click", async function () {
+ try { await api("/webdesign/portal/abmelden", { method: "POST", body: {} }); } catch (e) { /* egal */ }
+ token = null; tokenSchreiben(null);
+ location.href = "/webdesign/portal.html";
+ });
+
+ /* ---------------- Passwort setzen (Einladungslink) ---------------- */
+ var einladungsToken = new URLSearchParams(location.search).get("einladung");
+
+ document.getElementById("po-setz-form").addEventListener("submit", async function (e) {
+ e.preventDefault();
+ var feld = document.getElementById("po-feld-neu");
+ var knopf = e.target.querySelector("button");
+ feld.classList.remove("wd-feld--fehler");
+
+ var passwort = document.getElementById("po-neu").value;
+ window.WD.knopfSperren(knopf, true, window.WD.t("po_anmelden_laeuft"));
+ try {
+ await window.WD.api("/webdesign/portal/passwort", {
+ method: "POST", body: { token: einladungsToken, passwort: passwort },
+ });
+ zeige("po-anmeldung");
+ window.WD.meldung(meldung, window.WD.t("po_setz_fertig"), "ok");
+ /* Adresszeile säubern: der Einladungslink ist verbraucht und hat in
+ der Chronik nichts mehr verloren. */
+ history.replaceState(null, "", "/webdesign/portal.html");
+ } catch (fehler) {
+ feld.classList.add("wd-feld--fehler");
+ feld.querySelector(".wd-feld-fehler").textContent = fehler.message;
+ } finally {
+ window.WD.knopfSperren(knopf, false);
+ }
+ });
+
+ /* ---------------- Übersicht ---------------- */
+ var PHASEN = ["briefing", "angebot", "design", "entwicklung", "tests", "abnahme", "uebergeben"];
+
+ function statusText(st) { return window.WD.t("po_st_" + st); }
+
+ function phasenLeiste(status) {
+ var jetzt = PHASEN.indexOf(status);
+ /* Pausiert oder abgebrochen: keine Leiste. Ein Fortschrittsbalken bei
+ einem gestoppten Projekt wäre irreführend. */
+ if (jetzt < 0) return "";
+ return '
";
+
+ try {
+ var d = await api("/webdesign/portal/uebersicht");
+ document.getElementById("po-name").textContent = d.kunde.name || "";
+ window.WD.meldungAus(meldung);
+
+ var html = "";
+
+ /* Offene Zahlungen ganz oben — das ist das Einzige, was den Kunden
+ zum Handeln auffordert. Gold, weil es wirklich wichtig ist. */
+ if (d.offeneZahlungen.length) {
+ html += '
";
+
+ ziel.innerHTML = html;
+
+ /* --- Verknüpfungen --- */
+ ziel.querySelectorAll("[data-ja], [data-nein]").forEach(function (b) {
+ b.addEventListener("click", async function () {
+ var annehmen = !!b.dataset.ja;
+ var aeId = b.dataset.ja || b.dataset.nein;
+ /* Nur beim Annehmen nachfragen — das erzeugt eine Zahlungspflicht.
+ Ablehnen ist folgenlos und braucht keine Hürde. */
+ if (annehmen && !confirm(window.WD.t("po_annehmen_frage"))) return;
+ window.WD.knopfSperren(b, true, "…");
+ try {
+ await api("/webdesign/portal/aenderung/" + encodeURIComponent(aeId) + "/entscheiden",
+ { method: "POST", body: { annehmen: annehmen } });
+ ladeProjekt(id);
+ } catch (f) {
+ if (behandleFehler(f)) return;
+ window.WD.knopfSperren(b, false);
+ window.WD.meldung(meldung, f.message, "fehler");
+ }
+ });
+ });
+
+ ziel.querySelector("#po-idee-senden").addEventListener("click", async function (e) {
+ var titel = ziel.querySelector("#po-idee-titel").value.trim();
+ var beschreibung = ziel.querySelector("#po-idee-text").value.trim();
+ var feld = ziel.querySelector("#po-feld-idee");
+ feld.classList.remove("wd-feld--fehler");
+ if (!titel || !beschreibung) {
+ feld.classList.add("wd-feld--fehler");
+ feld.querySelector(".wd-feld-fehler").textContent = window.WD.t("po_idee_leer");
+ return;
+ }
+ window.WD.knopfSperren(e.target, true, "…");
+ try {
+ await api("/webdesign/portal/aenderung",
+ { method: "POST", body: { projektId: id, titel: titel, beschreibung: beschreibung } });
+ ladeProjekt(id);
+ } catch (f) {
+ if (behandleFehler(f)) return;
+ window.WD.knopfSperren(e.target, false);
+ window.WD.meldung(meldung, f.message, "fehler");
+ }
+ });
+
+ ziel.querySelector("#po-nachricht-senden").addEventListener("click", async function (e) {
+ var feld = ziel.querySelector("#po-nachricht-text");
+ var inhalt = feld.value.trim();
+ if (!inhalt) { feld.focus(); return; }
+ window.WD.knopfSperren(e.target, true, "…");
+ try {
+ await api("/webdesign/portal/nachricht",
+ { method: "POST", body: { projektId: id, text: inhalt } });
+ ladeProjekt(id);
+ } catch (f) {
+ if (behandleFehler(f)) return;
+ window.WD.knopfSperren(e.target, false);
+ window.WD.meldung(meldung, f.message, "fehler");
+ }
+ });
+
+ /* Zahlungsknöpfe: die eigentliche PayPal-Anbindung folgt. Bis dahin
+ ein ehrlicher Hinweis statt eines Knopfes, der nichts tut. */
+ ziel.querySelectorAll("[data-zahlung]").forEach(function (b) {
+ b.addEventListener("click", function () {
+ window.WD.meldung(meldung,
+ "Die Zahlung über PayPal wird gerade eingerichtet. Bitte melde dich kurz bei Dogfather.", "fehler");
+ });
+ });
+
+ } catch (fehler) {
+ if (behandleFehler(fehler)) return;
+ ziel.innerHTML = "";
+ window.WD.meldung(meldung, fehler.message, "fehler");
+ }
+ }
+
+ /* ---------------- Start ---------------- */
+ async function start() {
+ /* Einladungslink hat Vorrang: wer gerade sein Passwort setzen soll,
+ will nicht zuerst eine Anmeldemaske sehen. */
+ if (einladungsToken) {
+ try {
+ var info = await window.WD.api("/webdesign/portal/einladung?token=" + encodeURIComponent(einladungsToken));
+ document.getElementById("po-setz-fuer").textContent = info.email || "";
+ zeige("po-setzen");
+ return;
+ } catch (fehler) {
+ zeige("po-anmeldung");
+ window.WD.meldung(meldung, fehler.message, "fehler");
+ return;
+ }
+ }
+
+ token = tokenLesen();
+ if (!token) { zeige("po-anmeldung"); return; }
+ ladeUebersicht();
+ }
+
+ /* Nach einem Sprachwechsel die per JS gebauten Teile neu zeichnen. */
+ document.addEventListener("wd-sprache-geaendert", function () {
+ if (!token) return;
+ if (aktuellesProjekt) ladeProjekt(aktuellesProjekt);
+ else if (!document.getElementById("po-uebersicht").hidden) ladeUebersicht();
+ });
+
+ if (window.WD) start();
+ else document.addEventListener("wd-bereit", start);
+})();
diff --git a/server/pruefe-webdesign-i18n.mjs b/server/pruefe-webdesign-i18n.mjs
index 58abe0dc..9f2dbb5b 100644
--- a/server/pruefe-webdesign-i18n.mjs
+++ b/server/pruefe-webdesign-i18n.mjs
@@ -42,7 +42,13 @@ for (const datei of seiten) {
einen einzigen Nutzer, der sie braucht. Genau wie der bestehende
interne Bereich des Universe, der ebenfalls nur Deutsch spricht.
Die Tag-Balance wird trotzdem geprüft (siehe unten). */
- const NUR_DEUTSCH = ["verwaltung", "portal"];
+ /* NUR die Verwaltung ist einsprachig. Das Kundenportal stand hier
+ zunaechst auch drin -- das war ein Denkfehler: die Verwaltung nutzen
+ Dogfather und VanVan, das Portal nutzen KUNDEN. Und Kunden koennen
+ Franzosen oder Portugiesen sein. Ein Portal, das die Sprachwahl der
+ oeffentlichen Seite ignoriert, waere genau der Bruch, den ein Kunde
+ sofort bemerkt. */
+ const NUR_DEUTSCH = ["verwaltung"];
const einsprachig = NUR_DEUTSCH.includes(name);
if (!existsSync(i18nDatei) && !einsprachig) {
@@ -87,8 +93,12 @@ for (const datei of seiten) {
if (existsSync(seitenSkript)) quellen.push(readFileSync(seitenSkript, "utf8"));
for (const quelle of quellen) {
for (const m of quelle.matchAll(/WD\.t\("([a-z0-9_]+)"\)/g)) benutzt.add(m[1]);
- // Schlüssel aus Nachschlagetabellen, z.B. { ja: "af3_ja", ... }
- for (const m of quelle.matchAll(/["']((?:af|pk|zg|pf|ue|fq|ab|ls|pr|s\d)[a-z0-9_]*)["']\s*[,}\]]/g)) {
+ /* Schlüssel aus Nachschlagetabellen, z.B. { ja: "af3_ja", ... }.
+ Der Unterstrich im Muster ist wichtig: ohne ihn hielt die Prüfung
+ auch gewöhnliche Wörter wie "abnahme" oder "uebergeben" (aus der
+ Phasenliste im Portal) für Wörterbuch-Schlüssel und meldete sie als
+ fehlend. Alle echten Schlüssel haben die Form praefix_name. */
+ for (const m of quelle.matchAll(/["']([a-z]{2}\d?_[a-z0-9_]+)["']\s*[,}\]]/g)) {
benutzt.add(m[1]);
}
}
@@ -108,6 +118,11 @@ for (const datei of seiten) {
const ohneI18n = [];
for (const m of html.matchAll(/<(h[1-4])\b([^>]*)>([\s\S]*?)<\/\1>/g)) {
if (m[2].includes("data-i18n")) continue;
+ /* Auch übersetzt, wenn die Übersetzung INNEN sitzt — etwa
+
Hallo
,
+ wo ein Teil fest und ein Teil dynamisch ist. Ohne diese Ausnahme
+ meldet die Prüfung eine korrekt übersetzte Überschrift als Fehler. */
+ if (m[3].includes("data-i18n")) continue;
const text = m[3].replace(/<[^>]*>/g, "").trim();
if (!text) continue; // rein dekorativ
if (/^\$\{/.test(text)) continue; // dynamisch aus JS
diff --git a/webdesign/portal.html b/webdesign/portal.html
new file mode 100644
index 00000000..966d7599
--- /dev/null
+++ b/webdesign/portal.html
@@ -0,0 +1,141 @@
+
+
+
+
+
+Kundenportal — Dogfather Webdesign
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Dein Projektraum
+
Hier siehst du deinen Projektstand, alle Dateien und offene Punkte.
+
+
Noch keinen Zugang? Der Projektraum wird freigeschaltet, sobald dein Projekt beginnt.
+
+
+
+
+
Passwort festlegen
+
Wähl ein Passwort für deinen Projektraum. Ein kurzer Satz ist sicherer und leichter zu merken als ein kryptisches Wort.