Kundenportal: Oberflaeche (Masterplan S.12)

Vier Ansichten in einer Seite: Anmeldung, Passwort festlegen ueber den
Einladungslink, Uebersicht, Projektdetail. 71 Textbausteine in fuenf
Sprachen.

Korrektur an einer frueheren Entscheidung: Ich hatte das Portal als "nur
Deutsch" eingestuft wie die Verwaltung. Denkfehler -- die Verwaltung
nutzen Dogfather und VanVan, das PORTAL nutzen Kunden, und die koennen
Franzosen oder Portugiesen sein. Jetzt fuenfsprachig, und die Sprache des
Kunden wird bei der Anmeldung automatisch uebernommen.

Gestaltungsentscheidungen:
* Eine Phasenleiste zeigt auf einen Blick, wo das Projekt steht. Das ist
  die haeufigste Frage ueberhaupt und der Grund, warum Kunden anrufen.
  Bei pausierten oder abgebrochenen Projekten wird KEINE Leiste gezeigt --
  ein Fortschrittsbalken waere dort irrefuehrend.
* Eine Marke sagt, wer gerade am Zug ist ("Wir warten auf dich" /
  "Dogfather ist dran"). Das beendet die haeufigste Unklarheit im
  Projektverlauf.
* Offene Zahlungen stehen ganz oben und in Gold -- das Einzige, was den
  Kunden wirklich zum Handeln auffordert.
* Rueckfrage nur beim ANNEHMEN eines Zusatzangebots, nicht beim Ablehnen.
  Annehmen erzeugt eine Zahlungspflicht, Ablehnen ist folgenlos.
* Abgelaufene Sitzung fuehrt zur Anmeldung mit klarer Ansage statt zu
  einer leeren Seite, die wie "du hast keine Projekte" aussaehe.
* Der Einladungslink wird nach Gebrauch aus der Adresszeile entfernt --
  er ist verbraucht und hat in der Chronik nichts verloren.
* Die Zahlungsknoepfe sagen ehrlich, dass PayPal noch eingerichtet wird,
  statt so zu tun als wuerde etwas passieren.

Pruefskript zweimal geschaerft, beide Male waren es Fehlalarme:
* Die Heuristik fuer Nachschlagetabellen hielt gewoehnliche Woerter wie
  "abnahme" und "uebergeben" fuer Woerterbuch-Schluessel. Jetzt muss ein
  Unterstrich im Namen sein, so wie bei allen echten Schluesseln.
* Eine Ueberschrift gilt jetzt auch als uebersetzt, wenn die Uebersetzung
  INNEN sitzt (fester Teil + dynamischer Teil).

i18n vollstaendig, 45/45 Handy-Abnahme.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-22 21:17:22 +02:00
co-authored by Claude Opus 5
parent 04a1af1253
commit 1b432f456f
4 changed files with 804 additions and 3 deletions
+189
View File
@@ -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 &amp; Änderungswünsche", "de-CH": "Ideä &amp; Änderigswünsch", en: "Ideas &amp; change requests", fr: "Idées &amp; demandes de modification", pt: "Ideias &amp; 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.",
},
};
+456
View File
@@ -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 '<div class="po-phasen" aria-hidden="true">' +
PHASEN.map(function (p, i) {
return '<span class="po-phase' + (i < jetzt ? " po-erledigt" : i === jetzt ? " po-jetzt" : "") + '"></span>';
}).join("") + "</div>" +
'<div class="po-phasen-text">' + s(statusText(status)) + " · " +
(jetzt + 1) + "/" + PHASEN.length + "</div>";
}
function wartetMarke(wartet) {
if (wartet !== "kunde" && wartet !== "dogfather") return "";
return '<span class="po-wartet po-wartet--' + wartet + '">' +
s(window.WD.t(wartet === "kunde" ? "po_wartet_kunde" : "po_wartet_dogfather")) + "</span>";
}
async function ladeUebersicht() {
zeige("po-uebersicht");
var ziel = document.getElementById("po-inhalt");
ziel.innerHTML = '<div class="po-laedt">' + s(window.WD.t("laedt")) + "</div>";
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 += '<div class="wd-hinweis" style="margin-bottom:1.2rem">' +
'<p class="wd-hinweis-titel">' + s(window.WD.t("po_offene_zahlungen")) + "</p>" +
d.offeneZahlungen.map(function (z) {
return "<p><strong>" + s(z.betrag_text) + "</strong> · " + s(z.zweck_text) + "</p>";
}).join("") + "</div>";
}
html += "<h2>" + s(window.WD.t("po_projekte")) + "</h2>";
if (!d.projekte.length) {
html += '<p class="wd-leise">' + s(window.WD.t("po_keine_projekte")) + "</p>";
} else {
html += d.projekte.map(function (p) {
return '<button type="button" class="wd-karte po-projekt" data-projekt="' + s(p.id) + '">' +
'<span class="po-projekt-kopf">' +
'<span class="po-projekt-nr">' + s(p.nummer) + "</span>" +
"<h3>" + s(p.titel) + "</h3>" +
wartetMarke(p.wartet_auf) +
"</span>" +
phasenLeiste(p.status) +
(p.naechster_schritt
? '<p class="wd-klein" style="margin:.7rem 0 .3rem"><strong>' +
s(window.WD.t("po_naechster_schritt")) + ":</strong> " + s(p.naechster_schritt) + "</p>"
: "") +
(p.richttermin
? '<p class="wd-klein wd-leise" style="margin:0">' + s(window.WD.t("po_richttermin")) +
": " + window.WD.datum(p.richttermin) + "</p>"
: "") +
(p.offene_aenderungen || p.ungelesene_nachrichten
? '<p class="wd-klein" style="margin:.5rem 0 0;color:var(--wd-blau)">' +
(p.ungelesene_nachrichten ? p.ungelesene_nachrichten + " " + s(window.WD.t("po_nachrichten")) : "") +
(p.ungelesene_nachrichten && p.offene_aenderungen ? " · " : "") +
(p.offene_aenderungen ? p.offene_aenderungen + " " + s(window.WD.t("po_ideen")) : "") +
"</p>"
: "") +
"</button>";
}).join("");
}
if (d.abos.length) {
html += '<h2 style="margin-top:1.8rem">' + s(window.WD.t("po_betreuung")) + "</h2>" +
'<div class="wd-karte"><ul class="po-liste">' +
d.abos.map(function (a) {
return '<li class="po-eintrag"><span class="po-eintrag-haupt">' +
'<span class="po-eintrag-name">' + s(a.paket) + "</span>" +
'<span class="po-eintrag-neben">' + s(a.preis_text) +
(a.naechste_zahlung ? " · " + s(window.WD.t("po_naechste_zahlung")) + ": " + window.WD.datum(a.naechste_zahlung) : "") +
"</span></span></li>";
}).join("") + "</ul></div>";
}
ziel.innerHTML = html;
ziel.querySelectorAll("[data-projekt]").forEach(function (b) {
b.addEventListener("click", function () { ladeProjekt(b.dataset.projekt); });
});
} catch (fehler) {
if (behandleFehler(fehler)) return;
ziel.innerHTML = "";
window.WD.meldung(meldung, fehler.message, "fehler");
}
}
/* ---------------- Projektdetail ---------------- */
var aktuellesProjekt = null;
document.getElementById("po-zurueck").addEventListener("click", function () {
aktuellesProjekt = null;
ladeUebersicht();
});
async function ladeProjekt(id) {
aktuellesProjekt = id;
zeige("po-detail");
var ziel = document.getElementById("po-detail-inhalt");
ziel.innerHTML = '<div class="po-laedt">' + s(window.WD.t("laedt")) + "</div>";
try {
var d = await api("/webdesign/portal/projekt/" + encodeURIComponent(id));
var p = d.projekt;
var html = "";
html += '<div class="wd-karte" style="margin-bottom:1.2rem">' +
'<span class="po-projekt-nr">' + s(p.nummer) + "</span>" +
"<h2 style=\"margin:.2rem 0 .6rem\">" + s(p.titel) + "</h2>" +
wartetMarke(p.wartet_auf) +
phasenLeiste(p.status) +
(p.naechster_schritt
? '<p style="margin:.8rem 0 0"><strong>' + s(window.WD.t("po_naechster_schritt")) +
":</strong> " + s(p.naechster_schritt) + "</p>"
: "") +
"</div>";
/* --- Zahlungen --- */
html += '<div class="wd-karte" style="margin-bottom:1.2rem"><h3>' + s(window.WD.t("po_zahlungen")) + "</h3>";
if (!d.zahlungen.length) {
html += '<p class="wd-leise wd-klein">' + s(window.WD.t("po_keine_zahlungen")) + "</p>";
} else {
html += '<ul class="po-liste">' + d.zahlungen.map(function (z) {
var offen = z.status === "offen" || z.status === "freigegeben";
return '<li class="po-eintrag"><span class="po-eintrag-haupt">' +
'<span class="po-eintrag-name">' + s(z.betrag_text) + "</span>" +
'<span class="po-eintrag-neben">' + s(z.zweck_text) + "</span></span>" +
'<span class="wd-tag' + (z.status === "bezahlt" ? " wd-tag--ok" : offen ? " wd-tag--gold" : "") + '">' +
s(window.WD.t("po_za_" + (z.status === "fehlge" ? "fehlge" : z.status))) + "</span>" +
(offen ? '<button type="button" class="wd-btn wd-btn--haupt wd-btn--klein" data-zahlung="' +
s(z.id) + '">' + s(window.WD.t("po_jetzt_bezahlen")) + "</button>" : "") +
"</li>";
}).join("") + "</ul>";
}
html += "</div>";
/* --- Dateien --- */
html += '<div class="wd-karte" style="margin-bottom:1.2rem"><h3>' + s(window.WD.t("po_dateien")) + "</h3>";
if (!d.dateien.length) {
html += '<p class="wd-leise wd-klein">' + s(window.WD.t("po_keine_dateien")) + "</p>";
} else {
html += '<ul class="po-liste">' + d.dateien.map(function (f) {
return '<li class="po-eintrag"><span class="po-eintrag-haupt">' +
'<span class="po-eintrag-name">' + s(f.anzeigename) + "</span>" +
'<span class="po-eintrag-neben">' + window.WD.datum(f.erstellt_am) +
(f.version > 1 ? " · " + s(window.WD.t("po_version")) + " " + f.version : "") +
"</span></span></li>";
}).join("") + "</ul>";
}
html += "</div>";
/* --- Ideen & Änderungswünsche --- */
html += '<div class="wd-karte" style="margin-bottom:1.2rem"><h3>' + s(window.WD.t("po_ideen")) + "</h3>" +
'<p class="wd-klein wd-leise">' + s(window.WD.t("po_ideen_text")) + "</p>";
if (d.aenderungen.length) {
html += d.aenderungen.map(function (a) {
var entscheidbar = a.status === "angeboten";
return '<div class="po-idee' + (entscheidbar ? " po-idee--offen" : "") + '">' +
'<div class="po-idee-kopf"><h4>' + s(a.titel) + "</h4>" +
'<span class="wd-tag' + (entscheidbar ? " wd-tag--gold" : a.status === "angenommen" ? " wd-tag--ok" : "") + '">' +
s(window.WD.t("po_ae_" + a.status)) + "</span></div>" +
'<p class="wd-klein" style="margin:0 0 .4rem">' + s(a.beschreibung) + "</p>" +
(a.aufwand_text ? '<p class="wd-klein wd-leise" style="margin:0">' + s(a.aufwand_text) + "</p>" : "") +
(a.zusatzpreis_text
? '<p class="wd-klein" style="margin:.3rem 0 0"><strong>' + s(window.WD.t("po_zusatzpreis")) +
": " + s(a.zusatzpreis_text) + "</strong>" +
(a.zusatzdauer_text ? " · " + s(window.WD.t("po_zusatzdauer")) + ": " + s(a.zusatzdauer_text) : "") +
"</p>"
: "") +
(entscheidbar
? '<div class="wd-btn-reihe" style="margin-top:.8rem">' +
'<button type="button" class="wd-btn wd-btn--haupt wd-btn--klein" data-ja="' + s(a.id) + '">' +
s(window.WD.t("po_annehmen")) + "</button>" +
'<button type="button" class="wd-btn wd-btn--still wd-btn--klein" data-nein="' + s(a.id) + '">' +
s(window.WD.t("po_ablehnen")) + "</button></div>"
: "") +
"</div>";
}).join("");
} else {
html += '<p class="wd-leise wd-klein">' + s(window.WD.t("po_keine_ideen")) + "</p>";
}
html += '<div class="wd-feld" style="margin:1rem 0 .6rem">' +
"<label for=\"po-idee-titel\">" + s(window.WD.t("po_idee_titel")) + "</label>" +
'<input type="text" id="po-idee-titel" maxlength="160" />' +
"</div>" +
'<div class="wd-feld" style="margin-bottom:.6rem" id="po-feld-idee">' +
"<label for=\"po-idee-text\">" + s(window.WD.t("po_idee_text")) + "</label>" +
'<textarea id="po-idee-text" rows="3"></textarea>' +
'<p class="wd-feld-fehler"></p></div>' +
'<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" id="po-idee-senden">' +
s(window.WD.t("po_idee_senden")) + "</button></div>";
/* --- Nachrichten --- */
html += '<div class="wd-karte"><h3>' + s(window.WD.t("po_nachrichten")) + "</h3>";
if (!d.nachrichten.length) {
html += '<p class="wd-leise wd-klein">' + s(window.WD.t("po_keine_nachrichten")) + "</p>";
} else {
html += d.nachrichten.map(function (n) {
var vonMir = n.autor === "kunde";
return '<div class="po-nachricht po-nachricht--' + (vonMir ? "ich" : "du") + '">' +
'<div class="po-nachricht-kopf">' + (vonMir ? "" : "Dogfather · ") +
window.WD.datum(n.erstellt_am) + "</div><p>" + s(n.text) + "</p></div>";
}).join("");
}
html += '<div class="wd-feld" style="margin:1rem 0 .6rem">' +
'<textarea id="po-nachricht-text" rows="2" placeholder="' +
s(window.WD.t("po_nachricht_platzhalter")) + '"></textarea></div>' +
'<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" id="po-nachricht-senden">' +
s(window.WD.t("po_senden")) + "</button></div>";
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);
})();
+18 -3
View File
@@ -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
<h1><span data-i18n="po_hallo">Hallo</span> <span id="name"></span></h1>,
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
+141
View File
@@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="de" data-i18n-titel="seiten_titel">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kundenportal — Dogfather Webdesign</title>
<meta name="robots" content="noindex, nofollow, noarchive" />
<link rel="icon" type="image/png" href="/assets/img/favicon.png" />
<meta name="theme-color" content="#05070b" />
<link rel="manifest" href="/webdesign/app.webmanifest" />
<link rel="apple-touch-icon" href="/assets/img/wd-app-180.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
<link rel="stylesheet" href="/assets/css/main.css?v=20260822wd2" />
<link rel="stylesheet" href="/assets/css/webdesign.css?v=20260822wd2" />
<style>
.po-login { max-width: 440px; margin: clamp(1.5rem,6vh,4rem) auto; }
.po-kopf { display: flex; flex-wrap: wrap; align-items: center; gap: .8rem; margin-bottom: 1.4rem; }
.po-kopf h1 { margin: 0; font-size: clamp(1.4rem,3.4vw,2rem); }
.po-kopf .wd-btn { margin-left: auto; }
/* Projektkarte in der Übersicht */
.po-projekt { display: block; width: 100%; text-align: left; cursor: pointer; margin-bottom: .8rem; }
.po-projekt-kopf { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem; margin-bottom: .5rem; }
.po-projekt-nr { font-family: var(--font-head); font-weight: 800; font-size: .8rem; color: var(--wd-blau); }
.po-projekt h3 { margin: 0; flex: 1; min-width: 0; }
/* Fortschrittsleiste über die Projektphasen.
Zeigt auf einen Blick, wo das Projekt steht — die häufigste Frage
überhaupt, und der Grund, warum Kunden anrufen. */
.po-phasen { display: flex; gap: 3px; margin: .8rem 0 .6rem; }
.po-phase { flex: 1; height: 6px; border-radius: 999px; background: rgba(127,208,232,.13); }
.po-phase.po-erledigt { background: var(--wd-blau-tief); }
.po-phase.po-jetzt { background: var(--wd-blau); box-shadow: 0 0 10px rgba(127,208,232,.6); }
.po-phasen-text { font-size: .82rem; color: var(--wd-text-soft); }
.po-wartet {
display: inline-flex; align-items: center; gap: .35rem;
padding: .25rem .65rem; border-radius: 999px; font-size: .78rem; font-weight: 700;
}
.po-wartet--kunde { background: rgba(232,200,119,.16); color: var(--wd-gold); }
.po-wartet--dogfather { background: rgba(127,208,232,.14); color: var(--wd-blau); }
.po-liste { list-style: none; margin: 0; padding: 0; }
.po-eintrag {
display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .9rem;
padding: .8rem 0; border-bottom: 1px solid var(--wd-rand);
}
.po-eintrag:last-child { border-bottom: none; }
.po-eintrag-haupt { flex: 1; min-width: 0; }
.po-eintrag-name { color: var(--wd-text); font-weight: 600; overflow-wrap: break-word; }
.po-eintrag-neben { font-size: .84rem; color: var(--wd-text-soft); }
.po-nachricht { padding: .8rem 1rem; border-radius: 14px; margin-bottom: .6rem; max-width: 88%; }
.po-nachricht--ich { background: rgba(127,208,232,.1); border: 1px solid var(--wd-rand-stark); margin-left: auto; }
.po-nachricht--du { background: rgba(16,26,41,.7); border: 1px solid var(--wd-rand); }
.po-nachricht-kopf { font-size: .74rem; color: var(--wd-text-soft); margin-bottom: .25rem; }
.po-nachricht p { margin: 0; font-size: .95rem; white-space: pre-wrap; color: var(--wd-text); }
.po-idee { padding: 1rem; border-radius: 14px; border: 1px solid var(--wd-rand); background: rgba(16,26,41,.5); margin-bottom: .7rem; }
.po-idee-kopf { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem; margin-bottom: .4rem; }
.po-idee-kopf h4 { margin: 0; flex: 1; min-width: 0; }
.po-idee--offen { border-color: rgba(232,200,119,.4); background: rgba(232,200,119,.05); }
.po-laedt { text-align: center; padding: 2.5rem 1rem; color: var(--wd-text-soft); }
</style>
</head>
<body class="wd">
<div id="wd-kopf" data-wd-kopf="minimal"></div>
<main id="wd-inhalt">
<section class="wd-abschnitt" style="padding-top:1.6rem">
<div class="wd-container wd-container--schmal">
<div class="wd-meldung" id="po-meldung" hidden></div>
<!-- ============ ANMELDUNG ============ -->
<div id="po-anmeldung" class="wd-karte po-login" hidden>
<h1 style="font-size:1.5rem" data-i18n="po_login_h1">Dein Projektraum</h1>
<p class="wd-klein wd-leise" data-i18n="po_login_text">Hier siehst du deinen Projektstand, alle Dateien und offene Punkte.</p>
<form id="po-login-form" novalidate>
<div class="wd-feld" id="po-feld-email">
<label for="po-email" data-i18n="po_email">E-Mail</label>
<input type="email" id="po-email" autocomplete="username" inputmode="email" />
</div>
<div class="wd-feld" id="po-feld-passwort">
<label for="po-passwort" data-i18n="po_passwort">Passwort</label>
<input type="password" id="po-passwort" autocomplete="current-password" />
<p class="wd-feld-fehler"></p>
</div>
<button type="submit" class="wd-btn wd-btn--haupt wd-btn--breit" data-i18n="po_anmelden">Anmelden</button>
</form>
<p class="wd-klein wd-leise" style="margin:1rem 0 0" data-i18n="po_kein_zugang">Noch keinen Zugang? Der Projektraum wird freigeschaltet, sobald dein Projekt beginnt.</p>
</div>
<!-- ============ PASSWORT SETZEN ============ -->
<div id="po-setzen" class="wd-karte po-login" hidden>
<h1 style="font-size:1.5rem" data-i18n="po_setz_h1">Passwort festlegen</h1>
<p class="wd-klein wd-leise" data-i18n="po_setz_text">Wähl ein Passwort für deinen Projektraum. Ein kurzer Satz ist sicherer und leichter zu merken als ein kryptisches Wort.</p>
<p id="po-setz-fuer" class="wd-klein" style="color:var(--wd-blau)"></p>
<form id="po-setz-form" novalidate>
<div class="wd-feld" id="po-feld-neu">
<label for="po-neu" data-i18n="po_passwort">Passwort</label>
<input type="password" id="po-neu" autocomplete="new-password" />
<p class="wd-feld-fehler"></p>
</div>
<button type="submit" class="wd-btn wd-btn--haupt wd-btn--breit" data-i18n="po_setz_knopf">Passwort speichern</button>
</form>
</div>
<!-- ============ ÜBERSICHT ============ -->
<div id="po-uebersicht" hidden>
<div class="po-kopf">
<h1><span data-i18n="po_hallo">Hallo</span> <span id="po-name">—</span></h1>
<button type="button" class="wd-btn wd-btn--still wd-btn--klein" id="po-abmelden" data-i18n="po_abmelden">Abmelden</button>
</div>
<div id="po-inhalt"><div class="po-laedt" data-i18n="laedt">Wird geladen…</div></div>
</div>
<!-- ============ PROJEKTDETAIL ============ -->
<div id="po-detail" hidden>
<div class="po-kopf">
<button type="button" class="wd-btn wd-btn--still wd-btn--klein" id="po-zurueck" data-i18n="po_zurueck">Zurück zur Übersicht</button>
</div>
<div id="po-detail-inhalt"><div class="po-laedt" data-i18n="laedt">Wird geladen…</div></div>
</div>
</div>
</section>
</main>
<div id="wd-fuss"></div>
<script src="/assets/js/i18n-wd-portal.js?v=20260822wd2"></script>
<script src="/assets/js/wd-core.js?v=20260822wd2"></script>
<script src="/assets/js/wd-portal.js?v=20260822wd2"></script>
</body>
</html>