Files
dogfather-universe/supporter.html
T
DogFatherGitandClaude Opus 5 51c3d4402b Audit des Creator Workspace, App-Symbole fuer alle Apps der Domain
AUDIT (Auftrag: vollstaendiger Durchgang, Fehler direkt beheben)

Ausgangslage waren 40 Pruefungen mit 1616 Einzelpunkten, alle gruen.
Acht neue Pruefungen kamen dazu; sie haben gefunden, was die alten nicht
sehen konnten.

Der schwerste Fund: Die Zugangsschranke verglich req.path EXAKT gegen
eine Liste. Express raeumt Punkt-Segmente selbst weg, mehrfache
Schraegstriche aber nicht. Damit kam //workspace/start.html OHNE
Anmeldung mit HTTP 200, und ein Creator bekam ueber
/workspace//personen.html die Verwaltungsseite. Die DATEN waren nie
betroffen (nachgemessen: 404 bzw. 401). Behoben durch Normalisierung
UND eine Umkehr der Logik -- jetzt ist jede .html geschuetzt ausser der
Anmeldeseite, statt nur die in der Liste. Eine vergessene neue Seite
steht damit nicht mehr versehentlich offen.

Weiter behoben:
  * Kaputter/abgebrochener Rumpf ergab 500 in HTML statt 400 in JSON --
    die Oberflaeche ruft ueberall a.json() und lief in einen zweiten
    Fehler; der Knopf hing ohne Meldung.
  * POST /zustand/sichern war der einzige von 60 schreibenden Wegen
    ohne Herkunftspruefung.
  * workspace-sicherung.js gab interne Pfade in Fehlermeldungen nach
    aussen; alle 23 anderen Module antworten neutral.
  * admin_notiz war als einziges von 14 Feldern ohne <label>.
  * Der aktive Filter hatte keinen sichtbaren Fokus (CSS-Spezifitaet
    0,3,0 schlug 0,2,0) -- genau der Knopf, auf dem man steht.
  * h1 -> h3 ohne Zwischenstufe auf zwei Seiten.
  * HSTS ging auch ueber http mit (RFC 6797, 7.2 verbietet das).
  * upgrade-insecure-requests galt auch auf 127.0.0.1 -- dadurch war
    WebKit/Safari ueberhaupt nicht pruefbar, also der Browser, den
    jedes iPhone benutzt.
  * pruef-grosscheck las readdirSync(".") und pruefte aus server/
    gestartet NULL oeffentliche Seiten -- meldete aber "ok".

Neue Pruefungen: struktur, schranke, haerte, alle-wege,
barrierefrei-workspace, breiten, tempo-workspace, browser.
Jede mit Gegenprobe und mit der geprueften Anzahl in der Bedingung.
Vier davon sind beim Bauen durch die eigene Gegenprobe aufgeflogen und
haetten sonst dauerhaft gruen gemeldet, ohne etwas zu messen.

APP-SYMBOLE (Wunsch: alle Apps der Domain, jede anders, ausser
safeaddress)

Zehn Apps, zehn Stile, zehn in OKLCH gerechnete Farben. Zusammen haelt
sie dasselbe Logo, dieselbe Eckenrundung und eine gemeinsame gedeckte
Farbreihe. Beim Bauen wird gemessen, ob sich das Logo vom Grund abhebt
(19 bis 58 Helligkeitsstufen).

Dabei aufgefallen: Das Kundenportal hatte kein eigenes Manifest und
trug Namen und Symbol der Webdesign-Seite. Der Workspace hatte gar
keins und war als App nicht installierbar. Beide haben jetzt eins.

Geaendert wurde AUSSCHLIESSLICH das Symbol. Ein Zwischenstand hatte
auch die Themenfarben gesetzt; das war mehr als bestellt und wurde
zurueckgenommen.

Werkzeuge: tools/logo-freistellen.mjs, tools/app-symbole.mjs,
tools/app-symbole-einbinden.mjs -- alles im Browser gerechnet, kein
Bildprogramm, keine neue Abhaengigkeit.

Gitea und Nextcloud sind bereits live und nachgeprueft.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 11:42:17 +02:00

1144 lines
63 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de" data-theme="dogfather">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mein Supporter-Bereich — DOGFATHER UNIVERSE</title>
<meta name="description" content="Dein persönlicher Supporter-Bereich: Abo-Status, Fortschritt und Treueprämien." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#0b0d10" />
<meta name="robots" content="noindex" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<style>
/* =====================================================================
Supporter-Bereich — komplett neu gestaltet (Nutzer-Feedback
03.08.2026: "die Kisten müssen mega geil sein, wirklich so ultra
ultra geil, so speziell, lass es krachen"). Statt schlichtem Text auf
Karten jetzt: eigenes Gold/Champagner-Farbthema NUR innerhalb dieses
Bereichs (Supporter = etwas Besonderes, bewusst anders als das normale
Babyblau der restlichen Seite), Icon-Medaillons pro Kennzahl,
verbundene Fortschritts-Zeitleiste mit Leuchtpfad, Kollektionskarten
mit Ribbon-Nummer.
Der Trick für das Farbthema: main.css baut praktisch JEDEN Knopf/jede
Karte über die CSS-Variablen --accent/--accent-2 (Aurora-Glow,
Hover-Rahmen, .btn-primary-Füllung, ...). Statt jeden Knopf einzeln
neu zu stylen, wird hier einmalig auf #supp-root umdefiniert — alles
darunter (auch btn-outline/btn-primary/.card:hover) erbt automatisch
Gold statt Babyblau, ganz ohne die Basis-Komponenten anzufassen. */
#supp-root {
--accent: #ffe9a8;
--accent-2: #d4af37;
--accent-contrast: #1a1408;
}
.supp-hinweis { padding: .8rem 1rem; border-radius: 10px; background: rgba(212,175,55,.08); border: 1px solid rgba(212,175,55,.3); color: #ffe9a8; font-size: .9rem; margin-bottom: 1rem; }
.abo-fehler { color: #ff9a9a; font-size: .88rem; margin-top: .5rem; display: none; }
.abo-fehler.zeige { display: block; }
.abo-erfolg { color: #baf7d6; font-size: .88rem; margin-top: .5rem; display: none; }
.abo-erfolg.zeige { display: block; }
/* Supporter-Abstimmungen (20.08.2026) — im selben Gold-Ton wie der Rest
des Supporter-Bereichs, keine eigene Farbwelt. */
.poll-option-btn {
display: block; width: 100%; text-align: left; padding: .8rem 1rem; margin-bottom: .6rem;
border-radius: 10px; border: 1.5px solid var(--border); background: rgba(255,255,255,.03);
color: var(--text); font: inherit; cursor: pointer; transition: border-color .15s ease, background .15s ease;
}
.poll-option-btn:hover { border-color: var(--accent-2); }
.poll-option-btn.gewaehlt { border-color: var(--accent-2); background: rgba(212,175,55,.14); font-weight: 700; }
.poll-result-row { margin-bottom: .8rem; }
.poll-result-row:last-child { margin-bottom: 0; }
.poll-result-head { display: flex; justify-content: space-between; gap: 1rem; font-size: .88rem; margin-bottom: .3rem; }
.poll-result-head.meine-stimme { color: #ffe9a8; font-weight: 800; }
.poll-bar-track { height: 12px; border-radius: 999px; background: rgba(255,255,255,.06); overflow: hidden; border: 1px solid var(--border); }
.poll-bar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--accent-2), var(--accent)); transition: width .4s ease; }
/* =====================================================================
Abschnitts-Kopf + Abmelde-Knopf (21.08.2026, Nutzer-Wunsch: "ich will
da einen richtig geilen abmelde button").
Farbwahl bewusst NICHT Gold: Gold ist auf der ganzen Seite die Farbe
für "das ist deine Belohnung / das willst du" (Prämien, Preis, Abo).
Ausloggen ist das Gegenteil — ein Weggehen. Deshalb ein gedecktes
Rosé/Rot aus derselben Edelstein-Familie wie die Verwaltungsseite:
eindeutig unterscheidbar, aber nicht das grelle Warnrot einer
Fehlermeldung (Projektregel "augenschonend"). Der Glanzstreifen läuft
nur beim Überfahren durch, nicht dauerhaft — ein permanent blinkender
Knopf in der Seitenmitte würde beim Lesen stören.
===================================================================== */
.supp-abschnitt-kopf {
display: flex; align-items: center; justify-content: space-between;
gap: 1rem 1.4rem; flex-wrap: wrap;
/* Nutzer-Hinweis 21.08.2026: "der abstand soll perfekter sein, teilweise
berühren die sich wenn man mit der maus über die kacheln geht."
Ursache war das margin:0 am h2 unten — dadurch lag der Kopf direkt auf
der Karte. Der reine Kasten-Abstand reicht hier aber nicht als Maß:
der Knopf wirft beim Überfahren einen Schatten von rund 32px nach
unten, und die Kacheln heben sich ihrerseits 4px an und leuchten
26px weit nach oben — beides zusammen berührte sich. Nachgemessen:
mit diesem Wert bleiben auch im ungünstigsten Fall (beide Hover-
Zustände gleichzeitig) deutlich sichtbare Luft dazwischen. */
margin: 0 0 1.9rem;
}
/* margin:0 nötig, damit die Überschrift in der Flex-Zeile sauber auf einer
Linie mit dem Knopf sitzt — der Abstand nach unten kommt stattdessen vom
Container oben. */
.supp-abschnitt-kopf h2 { margin: 0; }
.supp-logout {
position: relative; isolation: isolate; overflow: hidden;
display: inline-flex; align-items: center; gap: .55rem;
padding: .72rem 1.35rem; border-radius: 999px;
font-weight: 800; font-size: .92rem; letter-spacing: .01em;
cursor: pointer; white-space: nowrap;
color: #ffd9df;
border: 1.5px solid rgba(255,138,164,.45);
background: linear-gradient(140deg, rgba(74,26,38,.95), rgba(38,20,26,.95));
box-shadow: 0 8px 22px -14px rgba(255,110,140,.8), inset 0 1px 0 rgba(255,255,255,.06);
transition: transform .18s ease, box-shadow .22s ease, border-color .22s ease, color .22s ease;
}
/* Durchlaufender Glanz — startet außerhalb und wandert beim Hover einmal
quer über den Knopf. */
.supp-logout-glanz {
position: absolute; inset: 0; z-index: 1; pointer-events: none;
background: linear-gradient(100deg, transparent 34%, rgba(255,255,255,.42) 50%, transparent 66%);
background-size: 260% 100%;
background-position: 140% 0;
transition: background-position .75s ease;
}
.supp-logout > *:not(.supp-logout-glanz) { position: relative; z-index: 2; }
.supp-logout-icon { font-size: 1.02rem; line-height: 1; }
.supp-logout:hover {
transform: translateY(-2px);
color: #fff;
border-color: rgba(255,158,180,.85);
box-shadow: 0 14px 30px -12px rgba(255,110,140,.75), 0 0 22px rgba(255,110,140,.28), inset 0 1px 0 rgba(255,255,255,.1);
}
.supp-logout:hover .supp-logout-glanz { background-position: -60% 0; }
.supp-logout:active { transform: translateY(0) scale(.985); }
.supp-logout:focus-visible { outline: 2px solid rgba(255,158,180,.9); outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
.supp-logout, .supp-logout-glanz { transition: none; }
.supp-logout:hover { transform: none; }
}
@media (max-width: 520px) {
/* Auf schmalen Handys volle Breite statt gequetscht neben der Überschrift. */
.supp-abschnitt-kopf { align-items: stretch; }
.supp-logout { width: 100%; justify-content: center; }
}
/* Gemeinsamer Karten-Rahmen mit Gold-Schimmerlinie oben — dieselbe
Machart wie .abo-preis-box auf abonnieren.html, damit der Bereich
erkennbar zusammengehört. */
.supp-card {
position: relative; overflow: hidden;
border: 1.5px solid rgba(212,175,55,.35);
background: radial-gradient(circle at 18% 0%, rgba(212,175,55,.1), transparent 55%),
linear-gradient(180deg, rgba(23,27,33,.92), rgba(14,16,20,.96));
}
.supp-card::before {
content: ""; position: absolute; top: 0; left: 6%; right: 6%; height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,214,102,.85), transparent);
animation: supp-goldglanz 4.5s ease-in-out infinite;
}
@keyframes supp-goldglanz { 0%,100% { opacity: .3; } 50% { opacity: 1; } }
/* ---------- Mein Abo: Kennzahlen als eigene Prunk-Kärtchen ----------
Komplett neu (Nutzer-Feedback 03.08.2026: "die Titel sollen auch in
der Kiste sein, und die Kisten sollen viel viel viel viel viel
spezieller aussehen"). Vorher: Icon links, Label+Wert rechts daneben
— bei zweizeiligen Labels ("Aktuelle Kollektion") wirkte das
unausgerichtet, als würde der Titel aus der Karte rausragen. Jetzt:
senkrechter Aufbau (Medaillon oben, Titel darunter, Wert darunter),
jede Karte garantiert in sich geschlossen, plus ein eigener
Gold-Rahmen mit wanderndem Schimmer-Rand (zweischichtiger
Hintergrund-Trick — siehe dokumentierte Lektion: NIE einen ganzen
Ring per transform:rotate() drehen, das verzerrt auf eckigen Karten;
hier läuft stattdessen nur die Hintergrundposition). */
.supp-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 1.1rem; }
.supp-stat {
position: relative; isolation: isolate; overflow: hidden;
display: flex; flex-direction: column; align-items: flex-start; gap: .6rem;
border-radius: 16px; padding: 1.3rem 1.2rem 1.4rem;
border: 1.5px solid transparent;
background:
linear-gradient(rgba(20,23,29,.94), rgba(12,14,18,.96)) padding-box,
linear-gradient(120deg, rgba(212,175,55,.15), rgba(255,233,168,.55), rgba(212,175,55,.15), rgba(139,92,246,.25)) border-box;
background-size: 100% 100%, 280% 100%;
animation: supp-stat-shimmer 7s linear infinite;
box-shadow: 0 14px 30px -18px rgba(0,0,0,.7);
transition: transform .2s ease, box-shadow .2s ease;
}
@keyframes supp-stat-shimmer { to { background-position: 0 0, -280% 0; } }
.supp-stat::after {
content: ""; position: absolute; inset: 0; z-index: -1; border-radius: inherit;
background: radial-gradient(circle at 18% 0%, rgba(212,175,55,.14), transparent 55%);
pointer-events: none;
}
.supp-stat:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px -16px rgba(0,0,0,.75), 0 0 26px rgba(212,175,55,.28);
}
.supp-stat .supp-stat-icon {
flex: none; width: 50px; height: 50px; border-radius: 50%; font-size: 1.4rem;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(circle at 30% 25%, rgba(255,233,168,.45), rgba(212,175,55,.1) 72%);
border: 1.5px solid rgba(212,175,55,.45);
box-shadow: 0 0 16px rgba(212,175,55,.25), inset 0 0 10px rgba(255,255,255,.06);
}
.supp-stat .label {
font-size: .72rem; font-weight: 700; color: #ffe9a8; margin-top: .2rem;
text-transform: uppercase; letter-spacing: .06em; opacity: .85;
}
.supp-stat .value { font-size: 1.2rem; font-weight: 800; line-height: 1.3; }
@media (prefers-reduced-motion: reduce) { .supp-stat { animation: none; } }
/* ---------- Klickbare Kennzahl-Kärtchen -> Detail-Modal (Nutzer-Wunsch
04.08.2026: "wenn man auf diese 2 Kisten drückt, sollen die viel mehr
Infos kriegen ... richtig geil"). Eigener Hinweis-Chip unten in der
Karte macht die Klickbarkeit sichtbar, statt sie erraten zu müssen. */
.supp-stat-clickable { cursor: pointer; }
.supp-stat-clickable:focus-visible { outline: 2px solid #ffe9a8; outline-offset: 3px; }
.supp-stat-clickable .supp-stat-hinweis {
margin-top: .15rem; font-size: .68rem; color: #ffe9a8; opacity: .65;
display: flex; align-items: center; gap: .25rem; font-weight: 600;
}
.supp-stat-clickable:hover .supp-stat-hinweis,
.supp-stat-clickable:focus-visible .supp-stat-hinweis { opacity: 1; }
.supp-stat-clickable .supp-stat-hinweis::after { content: "›"; font-size: .95rem; transition: transform .18s ease; }
.supp-stat-clickable:hover .supp-stat-hinweis::after { transform: translateX(3px); }
/* ---------- Detail-Modal ---------- */
.supp-modal-overlay {
position: fixed; inset: 0; z-index: 999;
background: rgba(6,7,9,.72); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
display: flex; align-items: center; justify-content: center; padding: 4vh 1rem;
animation: supp-modal-fade .18s ease-out;
}
/* KRITISCHER BUGFIX 04.08.2026: ".supp-modal-overlay { display:flex }" hat
dieselbe Spezifität wie die Browser-Standardregel "[hidden]{display:none}"
— Autor-CSS gewinnt bei Gleichstand IMMER gegen die UA-Stylesheet-Regel,
das [hidden]-Attribut wurde dadurch komplett wirkungslos. Ergebnis: das
Modal war die GANZE ZEIT sichtbar (leer, weil #supp-modal-body erst bei
echtem Öffnen befüllt wird) — unabhängig von jedem Klick, auf jedem
Seitenaufruf von supporter.html, in jedem Browser (auch Inkognito).
Exakt derselbe Bug-Typ wie schon einmal bei .btn-tiktok/.abo-btn-discord
auf abonnieren.html — hier aber übersehen, weil meine eigenen Tests nur
das JS-"hidden"-Property geprüft haben (korrekt: true), nie den
TATSÄCHLICH gerenderten CSS-display-Wert. Fix: explizite Ausnahme. */
#supp-modal-overlay[hidden] { display: none; }
@keyframes supp-modal-fade { from { opacity: 0; } to { opacity: 1; } }
.supp-modal {
position: relative; width: min(560px, 100%); max-height: 90vh; overflow-y: auto;
border-radius: 20px; padding: 1.9rem 1.8rem 2rem;
border: 1.5px solid transparent;
background:
linear-gradient(rgba(16,18,23,.98), rgba(9,10,13,.99)) padding-box,
linear-gradient(120deg, rgba(212,175,55,.5), rgba(255,233,168,.9), rgba(139,92,246,.4), rgba(212,175,55,.5)) border-box;
background-size: 100% 100%, 280% 100%;
animation: supp-modal-in .28s cubic-bezier(.2,.9,.25,1), supp-stat-shimmer 8s linear infinite;
box-shadow: 0 30px 70px -20px rgba(0,0,0,.85), 0 0 60px -10px rgba(212,175,55,.25);
}
@keyframes supp-modal-in { from { opacity: 0; transform: translateY(14px) scale(.97); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .supp-modal { animation: none; } .supp-modal-overlay { animation: none; } }
.supp-modal-close {
position: absolute; top: 1rem; right: 1rem; width: 34px; height: 34px; border-radius: 50%;
display: flex; align-items: center; justify-content: center; font-size: 1rem; line-height: 1;
background: rgba(255,255,255,.05); border: 1.5px solid var(--border); color: var(--text);
cursor: pointer; transition: background .15s ease, transform .15s ease, border-color .15s ease;
}
.supp-modal-close:hover { background: rgba(212,175,55,.15); border-color: rgba(212,175,55,.5); transform: rotate(90deg); }
.supp-modal-head { display: flex; align-items: center; gap: .7rem; flex-wrap: wrap; margin-bottom: 1.3rem; padding-right: 2.2rem; }
.supp-modal-icon {
flex: none; width: 46px; height: 46px; border-radius: 50%; font-size: 1.3rem;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(circle at 30% 25%, rgba(255,233,168,.5), rgba(212,175,55,.12) 72%);
border: 1.5px solid rgba(212,175,55,.5); box-shadow: 0 0 18px rgba(212,175,55,.3);
}
.supp-modal-head h3 { margin: 0; font-size: 1.25rem; flex: 1 1 auto; min-width: 140px; }
.supp-modal-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: .9rem 1.1rem; margin-bottom: 1.4rem; }
.supp-modal-label { font-size: .68rem; text-transform: uppercase; letter-spacing: .06em; color: #ffe9a8; opacity: .8; font-weight: 700; }
.supp-modal-value { font-size: 1.02rem; font-weight: 700; margin-top: .15rem; }
.supp-modal-subh { font-size: .82rem; text-transform: uppercase; letter-spacing: .06em; color: #ffe9a8; opacity: .85; margin: 1.5rem 0 .7rem; }
.supp-pay-liste { display: flex; flex-direction: column; gap: .45rem; max-height: 240px; overflow-y: auto; padding-right: .3rem; }
.supp-pay-row {
display: flex; align-items: center; justify-content: space-between; gap: .8rem;
padding: .6rem .8rem; border-radius: 10px; background: rgba(255,255,255,.03); border: 1px solid var(--border);
font-size: .87rem;
}
.supp-pay-datum { color: var(--text-muted); flex: 1 1 auto; }
.supp-pay-betrag { font-weight: 700; }
.supp-pay-status { font-size: .74rem; font-weight: 700; padding: .18rem .55rem; border-radius: 999px; white-space: nowrap; }
.supp-pay-status.status-completed { background: rgba(52,211,153,.15); color: #5eeaa7; }
.supp-pay-status.status-failed { background: rgba(217,74,92,.15); color: #ff9a9a; }
.supp-pay-status.status-refunded, .supp-pay-status.status-reversed { background: rgba(143,217,234,.15); color: #8fd9ea; }
.supp-modal-hero-zahl { font-size: 2.4rem; font-weight: 900; line-height: 1; margin-bottom: 1.1rem; }
.supp-modal-hero-zahl .small { display: block; font-size: .82rem; font-weight: 600; color: var(--text-muted); margin-top: .35rem; }
.supp-progress-bar { height: 12px; border-radius: 999px; background: rgba(255,255,255,.06); overflow: hidden; border: 1px solid var(--border); }
.supp-progress-fill {
height: 100%; border-radius: 999px;
background: linear-gradient(90deg, #d4af37, #ffe9a8, #d4af37);
background-size: 220% 100%; animation: abo-btn-shine 5s ease-in-out infinite;
box-shadow: 0 0 14px rgba(212,175,55,.5);
transition: width .5s ease;
}
.supp-modal-celebrate {
margin-top: 1rem; padding: .8rem 1rem; border-radius: 12px; font-size: .9rem; font-weight: 700;
background: rgba(212,175,55,.1); border: 1px solid rgba(212,175,55,.35); color: #ffe9a8;
}
.supp-modal-meilensteine { display: flex; flex-direction: column; gap: .55rem; }
.supp-modal-meilenstein {
display: flex; align-items: center; gap: .8rem; padding: .65rem .85rem; border-radius: 12px;
background: rgba(255,255,255,.02); border: 1px solid var(--border);
}
.supp-modal-meilenstein.erreicht { border-color: rgba(212,175,55,.5); background: rgba(212,175,55,.06); }
.supp-modal-meilenstein.naechste { border-color: rgba(143,217,234,.5); background: rgba(143,217,234,.06); }
.icon-medaille-mini {
flex: none; width: 36px; height: 36px; border-radius: 50%; font-size: 1.05rem;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(circle at 32% 26%, rgba(255,255,255,.12), transparent 65%);
border: 1.5px solid var(--border);
}
.supp-modal-meilenstein.erreicht .icon-medaille-mini { border-color: #ffe9a8; background: radial-gradient(circle at 32% 26%, rgba(255,233,168,.55), rgba(212,175,55,.12) 70%); }
.supp-modal-meilenstein.naechste .icon-medaille-mini { border-color: #8fd9ea; background: radial-gradient(circle at 32% 26%, rgba(143,217,234,.4), transparent 70%); }
.supp-modal-meilenstein .titel { font-weight: 700; font-size: .92rem; }
.supp-badge { display: inline-flex; align-items: center; gap: .35rem; padding: .32rem .75rem; border-radius: 999px; font-size: .78rem; font-weight: 700; }
.supp-badge.active { background: rgba(52,211,153,.15); color: #5eeaa7; box-shadow: 0 0 14px rgba(52,211,153,.25); }
.supp-badge.pending { background: rgba(212,175,55,.15); color: #ffe9a8; box-shadow: 0 0 14px rgba(212,175,55,.25); }
.supp-badge.cancelled { background: rgba(217,74,92,.15); color: #ff9a9a; }
.supp-badge.none, .supp-badge.suspended, .supp-badge.expired { background: rgba(255,255,255,.08); color: var(--text-muted); }
/* ---------- Fortschritt: verbundener Leuchtpfad mit Medaillons ---------- */
.supp-zeitleiste { position: relative; display: flex; flex-wrap: wrap; gap: 1.4rem 1rem; margin-top: 1.6rem; padding-top: 1rem; }
.supp-meilenstein {
position: relative; flex: 1 1 140px; text-align: center; padding: 1.2rem .7rem .9rem;
border-radius: 14px; border: 1.5px solid var(--border); background: rgba(255,255,255,.02);
transition: transform .18s ease, border-color .18s ease;
}
.supp-meilenstein.erreicht { border-color: rgba(212,175,55,.55); background: rgba(212,175,55,.07); }
.supp-meilenstein.naechste { border-color: rgba(143,217,234,.55); background: rgba(143,217,234,.07); animation: supp-naechste-puls 2.6s ease-in-out infinite; }
@keyframes supp-naechste-puls {
0%, 100% { box-shadow: 0 0 0 rgba(143,217,234,0); }
50% { box-shadow: 0 0 22px rgba(143,217,234,.35); }
}
.supp-meilenstein:hover { transform: translateY(-3px); }
.supp-meilenstein .icon-medaille {
width: 54px; height: 54px; margin: 0 auto .5rem; border-radius: 50%;
display: flex; align-items: center; justify-content: center; font-size: 1.6rem;
background: radial-gradient(circle at 32% 26%, rgba(255,255,255,.12), transparent 65%);
border: 1.5px solid var(--border);
}
.supp-meilenstein.erreicht .icon-medaille { border-color: #ffe9a8; background: radial-gradient(circle at 32% 26%, rgba(255,233,168,.55), rgba(212,175,55,.12) 70%); box-shadow: 0 0 18px rgba(212,175,55,.4); }
.supp-meilenstein.naechste .icon-medaille { border-color: #8fd9ea; background: radial-gradient(circle at 32% 26%, rgba(143,217,234,.4), transparent 70%); }
.supp-meilenstein .monat { font-size: .78rem; color: var(--text-muted); }
.supp-meilenstein .titel { font-weight: 800; font-size: .9rem; margin-top: .2rem; }
.supp-meilenstein .supp-premium-status { margin-top: .3rem; }
/* ---------- Sammlungsarchiv: Kollektionskarten mit Ribbon ---------- */
.supp-kollektion {
position: relative; overflow: hidden; border-radius: 14px; padding: 1.4rem 1.4rem 1.2rem; margin-bottom: 1.2rem;
border: 1.5px solid rgba(212,175,55,.3);
background: radial-gradient(circle at 90% 0%, rgba(212,175,55,.08), transparent 55%), rgba(255,255,255,.02);
}
.supp-kollektion h3 { display: flex; align-items: center; gap: .5rem; font-size: 1.1rem; }
.supp-kollektion h3::before { content: "🏆"; font-size: 1.15rem; }
.supp-premium-row { display: flex; justify-content: space-between; align-items: center; padding: .7rem 0; border-bottom: 1px dashed var(--border); gap: 1rem; flex-wrap: wrap; }
.supp-premium-row:last-child { border-bottom: none; }
.supp-premium-status {
display: inline-block; font-size: .76rem; font-weight: 700; padding: .2rem .55rem; border-radius: 999px;
color: var(--text-muted); background: rgba(255,255,255,.06);
}
.supp-premium-status.status-verfuegbar { background: rgba(212,175,55,.15); color: #ffe9a8; }
.supp-premium-status.status-versandbereit,
.supp-premium-status.status-versendet { background: rgba(143,217,234,.15); color: #8fd9ea; }
.supp-premium-status.status-zugestellt,
.supp-premium-status.status-erhalten { background: rgba(52,211,153,.15); color: #5eeaa7; }
.supp-premium-status.status-problem { background: rgba(217,74,92,.15); color: #ff9a9a; }
.supp-redeem-form { display: none; margin-top: .9rem; padding: 1.1rem; border-radius: 12px; background: rgba(255,255,255,.03); border: 1px solid rgba(212,175,55,.25); }
.supp-redeem-form.offen { display: block; animation: supp-form-in .2s ease-out; }
@keyframes supp-form-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
.supp-redeem-form .abo-field { margin-bottom: .8rem; }
.supp-redeem-form label { display: block; font-size: .82rem; color: var(--text-muted); margin-bottom: .35rem; }
.supp-redeem-form input, .supp-redeem-form select {
width: 100%; box-sizing: border-box; padding: .7rem .9rem; border-radius: 9px;
background: rgba(255,255,255,.04); border: 1.5px solid var(--border); color: var(--text);
transition: border-color .15s ease, box-shadow .15s ease;
}
.supp-redeem-form input:focus, .supp-redeem-form select:focus {
outline: none; border-color: rgba(212,175,55,.7); box-shadow: 0 0 0 3px rgba(212,175,55,.14);
}
.supp-checkbox-row { display: flex; align-items: center; gap: .5rem; margin-bottom: .8rem; font-size: .85rem; color: var(--text-muted); }
.supp-checkbox-row input { width: auto; }
/* ---------- Abo verwalten ---------- */
#supp-root .abo-field label { display: block; font-size: .82rem; font-weight: 600; color: var(--text-muted); margin-bottom: .4rem; }
#supp-root .abo-field input {
transition: border-color .15s ease, box-shadow .15s ease;
}
#supp-root .abo-field input:focus {
outline: none; border-color: rgba(212,175,55,.7); box-shadow: 0 0 0 3px rgba(212,175,55,.14);
}
</style>
</head>
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-supporter.jpg');--page-bg-mobile:url('/assets/img/bg-supporter-mobile.jpg');">
<div id="site-header"></div>
<main>
<section class="hero page-bg">
<div class="hero-banner"><img src="/assets/img/bg-supporter.jpg" alt="Dogfather Supporter-Abo" loading="eager" /></div>
<div class="container">
<span class="eyebrow" data-i18n="su_eyebrow">👑 Supporter-Bereich</span>
<h1 id="supp-begruessung" data-i18n="su_lade">Dein Supporter-Bereich</h1>
<p class="lead worlds" data-i18n="su_lead">Danke, dass du Dogfather dauerhaft unterstützt.</p>
</div>
</section>
<section class="section-tight" id="supp-root" style="display:none;">
<div class="container">
<div id="supp-kuendigungs-hinweis" class="supp-hinweis" style="display:none;"></div>
<!-- Supporter-Abstimmungen (20.08.2026) — nur sichtbar, wenn gerade
eine Abstimmung aktiv ist bzw. noch nicht abgestimmt wurde, siehe
ladePoll() weiter unten. -->
<div class="card supp-card" id="supp-poll-card" style="display:none;margin-bottom:2.4rem;">
<span class="eyebrow" data-i18n="su_poll_eyebrow">🗳️ Aktuelle Abstimmung</span>
<h3 id="poll-frage-text" style="margin:.5rem 0 1.1rem;">–</h3>
<div id="poll-optionen-container"></div>
<button type="button" class="btn btn-primary" id="poll-abstimmen-btn" style="margin-top:.4rem;" data-i18n="su_poll_btn_abstimmen">Abstimmen</button>
<p class="abo-erfolg" id="poll-erfolg" data-i18n="su_poll_danke">Danke, deine Stimme wurde gezählt! 💙</p>
<p class="abo-fehler" id="poll-fehler"></p>
</div>
<!-- Nutzer-Wunsch 21.08.2026: "ich will da einen richtig geilen abmelde
button." Sitzt bewusst HIER oben neben der Überschrift statt wie
vorher ganz unten unter "Abo verwalten" — dort war er als schlichter
Umriss-Knopf kaum zu finden. Der alte wurde entfernt statt zusätzlich
stehen zu lassen: zwei Abmelde-Knöpfe auf einer Seite wären genau die
Dopplung, die schon bei "2 mal registrieren" gestört hat. -->
<div class="supp-abschnitt-kopf">
<h2 data-i18n="su_fenster1_h2">Mein Abo</h2>
<button type="button" class="supp-logout" id="btn-logout">
<span class="supp-logout-glanz" aria-hidden="true"></span>
<span class="supp-logout-icon" aria-hidden="true">⏻</span>
<span class="supp-logout-text" data-i18n="su_btn_logout">Ausloggen</span>
</button>
</div>
<div class="card supp-card">
<div class="supp-stats">
<div class="supp-stat"><span class="supp-stat-icon" aria-hidden="true">🪪</span><div><div class="label" data-i18n="su_stat_status">Status</div><div class="value" id="stat-status">–</div></div></div>
<div class="supp-stat"><span class="supp-stat-icon" aria-hidden="true">💳</span><div><div class="label" data-i18n="su_stat_preis">Monatlicher Preis</div><div class="value" id="stat-preis">–</div></div></div>
<div class="supp-stat supp-stat-clickable" id="stat-card-naechste" tabindex="0" role="button" aria-haspopup="dialog"><span class="supp-stat-icon" aria-hidden="true">📅</span><div><div class="label" data-i18n="su_stat_naechste_zahlung">Nächste Zahlung</div><div class="value" id="stat-naechste">–</div><div class="supp-stat-hinweis" data-i18n="su_stat_details_hinweis">Details ansehen</div></div></div>
<div class="supp-stat supp-stat-clickable" id="stat-card-monate" tabindex="0" role="button" aria-haspopup="dialog"><span class="supp-stat-icon" aria-hidden="true">🗓️</span><div><div class="label" data-i18n="su_stat_monate">Bezahlte Monate gesamt</div><div class="value" id="stat-monate">–</div><div class="supp-stat-hinweis" data-i18n="su_stat_details_hinweis">Details ansehen</div></div></div>
<div class="supp-stat"><span class="supp-stat-icon" aria-hidden="true">🔄</span><div><div class="label" data-i18n="su_stat_zyklus">Aktueller Zyklus</div><div class="value" id="stat-zyklus">–</div></div></div>
</div>
<!-- Kein aktives Abo. Der frühere Knopf "Jetzt abonnieren" führte auf
abonnieren.html — die schickt eingeloggte Leute seit 21.08.2026
aber direkt wieder hierher zurück (siehe pruefeStatus() dort), das
wäre also eine Schleife gewesen. Solange die Bezahlung ohnehin
gesperrt ist, steht hier deshalb derselbe "Coming soon…"-Hinweis
wie auf der Anmeldeseite — ehrlicher und ohne Sackgasse. Sobald
PayPal live ist, wird hieraus wieder ein echter Knopf. -->
<div id="supp-keine-abo" style="display:none;margin-top:1.2rem;">
<p class="small" data-i18n="su_kein_abo_text">Du hast noch kein aktives Abo.</p>
<div class="btn-gold-locked" style="max-width:320px;margin-top:.6rem;cursor:default;">
<span class="btn-gold-locked-lock" aria-hidden="true">🔒</span>
<span class="btn-gold-locked-text" data-i18n="su_kein_abo_soon">Coming soon…</span>
</div>
<p class="small muted" style="margin-top:.7rem;" data-i18n="su_kein_abo_hinweis">Alles hier ist schon für dich vorbereitet — sobald das Abo startet, kannst du es mit einem Klick aktivieren.</p>
</div>
</div>
<h2 style="margin-top:2.4rem;" data-i18n="su_fenster2_h2">Mein aktueller Fortschritt</h2>
<div class="card supp-card">
<p class="small" id="fortschritt-text">–</p>
<div class="supp-zeitleiste" id="supp-zeitleiste"></div>
</div>
<h2 style="margin-top:2.4rem;" data-i18n="su_archiv_h2">Mein Sammlungsarchiv</h2>
<div id="supp-archiv"></div>
<h2 style="margin-top:2.4rem;" data-i18n="su_verwalten_h2">Abo verwalten</h2>
<div class="card supp-card">
<div class="abo-field" style="margin-bottom:1rem;">
<label for="edit-name" data-i18n="su_feld_name">Name / Anzeigename</label>
<input type="text" id="edit-name" style="width:100%;padding:.7rem 1rem;border-radius:10px;background:rgba(255,255,255,.04);border:1.5px solid var(--border);color:var(--text);box-sizing:border-box;" />
</div>
<div class="abo-field" style="margin-bottom:1rem;">
<label for="edit-tiktok" data-i18n="su_feld_tiktok">TikTok-Benutzername</label>
<input type="text" id="edit-tiktok" style="width:100%;padding:.7rem 1rem;border-radius:10px;background:rgba(255,255,255,.04);border:1.5px solid var(--border);color:var(--text);box-sizing:border-box;" />
</div>
<button type="button" class="btn btn-outline" id="btn-save-profile" data-i18n="su_btn_profil_speichern">Speichern</button>
<p class="abo-erfolg" id="profil-erfolg" data-i18n="su_profil_gespeichert">Gespeichert.</p>
<!-- "Ausloggen" ist von hier nach oben neben die Überschrift "Mein Abo"
gewandert (21.08.2026) — siehe dort. -->
<div class="btn-row" style="margin-top:1.6rem;">
<button type="button" class="btn btn-outline" id="btn-cancel" data-i18n="su_btn_kuendigen">Abo kündigen</button>
</div>
<p class="abo-fehler" id="verwalten-fehler"></p>
</div>
</div>
</section>
<section class="section-tight" id="supp-nicht-eingeloggt" style="display:none;">
<div class="container text-center">
<p class="lead" data-i18n="su_nicht_eingeloggt">Du bist nicht eingeloggt.</p>
<a class="btn btn-primary" href="abonnieren.html" data-i18n="su_zum_abo">Zur DogiCrew 🩵</a>
</div>
</section>
<!-- Detail-Modal für die klickbaren Kennzahl-Kärtchen (Zahlungsdetails /
Fortschritts-Reise) — ein einziger Container, Inhalt wird per JS
ausgetauscht (siehe supp-modal-body). -->
<div class="supp-modal-overlay" id="supp-modal-overlay" hidden>
<div class="supp-modal" id="supp-modal" role="dialog" aria-modal="true" aria-labelledby="supp-modal-titel">
<button type="button" class="supp-modal-close" id="supp-modal-close" aria-label="Schließen">✕</button>
<div id="supp-modal-body"></div>
</div>
</div>
</main>
<div id="site-footer"></div>
<script src="assets/js/i18n-supporter.js?v=20260828e"></script>
<script src="assets/js/supporter.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script>
(function () {
const S = window.DogiSupporter;
const REDEEM_LABELS = {
teddy_klein: "Kleiner Hasen-Teddy", teddy_mittel: "Mittelgroßer Hasen-Teddy", teddy_gross: "Großer Hasen-Teddy",
tasse_karte: "Tasse & Autogrammkarte", hoodie: "Exklusiver Hoodie",
};
const STATUS_LABELS = {
active: "Aktiv", pending: "Zahlung ausstehend", suspended: "Zahlung fehlgeschlagen",
cancelled: "Gekündigt (bis Periodenende aktiv)", expired: "Beendet", none: "Kein Abo",
};
const REDEEM_STATUS_LABELS = {
verfuegbar: "Verfügbar", eingeloest: "Eingelöst", wird_geprueft: "Wird geprüft", wird_vorbereitet: "Wird vorbereitet",
versandbereit: "Versandbereit", versendet: "Versendet", zugestellt: "Zugestellt", erhalten: "Erhalten",
problem: "Problem gemeldet", ersatz_wird_vorbereitet: "Ersatz wird vorbereitet",
};
const PAYMENT_STATUS_LABELS = {
completed: "Erfolgreich", failed: "Fehlgeschlagen", refunded: "Erstattet", reversed: "Storniert",
};
// Merkt sich die zuletzt geladenen Dashboard-Daten, damit die Detail-
// Modals (Klick auf "Nächste Zahlung" / "Bezahlte Monate gesamt") ohne
// erneuten API-Aufruf sofort befüllt werden können.
let letzteDaten = null;
function fmtDatum(iso) {
if (!iso) return "–";
try { return new Date(iso).toLocaleDateString("de-DE"); } catch { return iso; }
}
/* Vorschau-Modus (nur für Dogi/VanVan via gate-worker.js-Rollen-Cookie,
siehe main.js dogiVorschauModus): "Als Abonnent" zeigt diese Seite mit
erfundenen Beispieldaten, OHNE echten Login/Abo — sonst müsste einer
der beiden extra ein echtes Abo abschließen, nur um das Design zu
sehen. "Als Nicht-Abonnent" simuliert stattdessen den ausgeloggten
Zustand, auch wenn zufällig ein echtes Test-Token im Speicher liegt.
Nutzer-Feedback 03.08.2026: "ich will in der Vorschau alles sehen wie
es fertig sein wird, nix da stehen haben von Vorschau, das bringt mir
nix." — die Beispieldaten dürfen also nirgends das Wort "Vorschau"
zeigen, sondern müssen aussehen wie ein echtes, fertiges Konto. Name
und TikTok-Handle richten sich deshalb nach der eingeloggten Rolle
(Dogi/VanVan, window.dogiVorschauName() aus main.js). */
function mockDaten() {
const heute = new Date();
const inTagen = (n) => new Date(heute.getTime() + n * 86400000).toISOString();
const istVanvan = window.dogiVorschauModus && document.cookie.includes("dogi_role=vanvan");
const name = window.dogiVorschauName ? window.dogiVorschauName() : "Dogi";
return {
profile: {
displayName: name,
tiktokUsername: istVanvan ? "o.o.vanessa.o.o" : "dogfather0804",
hasShippingAddress: false,
},
subscription: {
status: "active", monthlyPrice: "4,99 €", nextPaymentAt: inTagen(14),
totalPaidMonths: 8, startedAt: inTagen(-7 * 30 - 4),
},
progress: { cycleNumber: 1, completedCycles: 0, currentCycleMonth: 8 },
// 8 monatliche Zahlungen, neueste zuerst — dieselbe Reihenfolge wie
// das Backend sie liefert (ORDER BY paid_at DESC).
payments: Array.from({ length: 8 }, (_, i) => ({
amount: "4.99", currency: "EUR", payment_status: "completed", paid_at: inTagen(-i * 30), is_sandbox: 0,
})),
timeline: {
cycleNumber: 1,
currentCycleMonth: 8,
milestones: [
{ month: 6, type: "teddy_klein", reached: true, isNext: false, monthsRemaining: 0 },
{ month: 12, type: "teddy_mittel", reached: false, isNext: true, monthsRemaining: 4 },
{ month: 18, type: "teddy_gross", reached: false, isNext: false, monthsRemaining: 10 },
{ month: 24, type: "tasse_karte", reached: false, isNext: false, monthsRemaining: 16 },
{ month: 30, type: "hoodie", reached: false, isNext: false, monthsRemaining: 22 },
],
},
archive: [
{
collectionNumber: 1,
collectionName: "Hasen-Teddy-Kollektion",
premiums: [
{ id: "vorschau-1", label: "Kleiner Hasen-Teddy", redeem_status: "verfuegbar", premium_type: "teddy_klein" },
],
},
],
};
}
async function laden() {
const vorschau = window.dogiVorschauModus ? window.dogiVorschauModus() : null;
// Sichtbarkeit zurücksetzen — laden() kann bei einem Vorschau-Wechsel
// (siehe Event-Listener unten) mehrfach laufen, nicht nur beim ersten
// Seitenaufbau.
document.getElementById("supp-root").style.display = "none";
document.getElementById("supp-nicht-eingeloggt").style.display = "none";
if (vorschau === "nicht-abonnent") {
document.getElementById("supp-nicht-eingeloggt").style.display = "";
return;
}
// "Als Abonnent" heißt nur: mit erfundenen Beispieldaten anzeigen, WENN
// (noch) kein echtes Konto existiert. Ist Dogi/VanVan zufällig wirklich
// eingeloggt, sollen die eigenen echten Daten (echter Name, echter
// Fortschritt) zu sehen sein statt Fantasiewerten — Nutzer-Feedback
// 03.08.2026: "da soll der Name vom Abonnenten stehen, wenn die Abo
// haben." Nur ohne echtes Token braucht es überhaupt die Mock-Daten,
// um das Design vorab begutachten zu können.
const nurMockNoetig = vorschau === "abonnent" && !S.getToken();
let data;
if (nurMockNoetig) {
data = mockDaten();
} else {
if (!S.getToken()) {
document.getElementById("supp-nicht-eingeloggt").style.display = "";
return;
}
try {
data = await S.apiFetch("/supporter/me", {}, true);
} catch {
S.clearToken();
document.getElementById("supp-nicht-eingeloggt").style.display = "";
return;
}
S.cacheSubStatus(data.subscription?.status || "none");
S.cacheDisplayName(data.profile?.displayName || "");
}
letzteDaten = data;
document.getElementById("supp-root").style.display = "";
document.getElementById("supp-begruessung").textContent = `Willkommen, ${data.profile.displayName}!`;
const sub = data.subscription;
/* Läuft überhaupt ein Abo? Entscheidet, ob der nächste Meilenstein als
"läuft schon" (✨ + Countdown) oder als gesperrt (🔒) dargestellt wird.
Dieselbe Statusliste wie beim Nav-Knopf in main.js — "cancelled" zählt
bewusst noch dazu, weil der Zugang bis zum Ende des bezahlten Zeitraums
bestehen bleibt und in dieser Zeit auch noch Monate dazukommen können. */
const aboLaeuft = ["active", "pending", "suspended", "cancelled"].includes(sub.status);
document.getElementById("stat-status").innerHTML =
`<span class="supp-badge ${sub.status}">${STATUS_LABELS[sub.status] || sub.status}</span>`;
/* Nutzer-Wunsch 21.08.2026: "da soll noch kein preis stehen bitte."
Der Preis ist überall sonst bewusst noch verborgen (auf abonnieren.html
steht an seiner Stelle "Coming soon…", siehe .abo-preis-zahl dort) —
über diese Kachel wäre er sonst durch die Hintertür doch schon
öffentlich gewesen. Wer bereits zahlt, MUSS seinen Preis dagegen sehen
können, deshalb nur ausblenden solange kein Abo läuft. */
document.getElementById("stat-preis").textContent = aboLaeuft ? sub.monthlyPrice : "Coming soon…";
document.getElementById("stat-naechste").textContent = fmtDatum(sub.nextPaymentAt);
document.getElementById("stat-monate").textContent = sub.totalPaidMonths;
document.getElementById("stat-zyklus").textContent = `Zyklus ${data.progress.cycleNumber}`;
document.getElementById("supp-keine-abo").style.display = sub.status === "none" ? "" : "none";
// Nutzer-Wunsch 21.08.2026: Kündigen-Knopf ergibt nur Sinn, wenn
// überhaupt ein (noch nicht schon gekündigtes) Abo existiert -- sonst
// sieht man einen Knopf für etwas, das es gar nicht gibt.
document.getElementById("btn-cancel").style.display = ["none", "cancelled", "expired"].includes(sub.status) ? "none" : "";
if (sub.status === "cancelled") {
const hinweis = document.getElementById("supp-kuendigungs-hinweis");
hinweis.style.display = "";
hinweis.textContent = `Dein Abo wurde gekündigt. Dein Zugang und dein Fortschritt bleiben bis ${fmtDatum(sub.paidPeriodEndAt)} erhalten.`;
}
// Fortschritt-Zeitleiste (Fenster 2)
const t = data.timeline;
document.getElementById("fortschritt-text").textContent =
`Zyklus ${t.cycleNumber} — Monat ${t.currentCycleMonth} von 30`;
const zeitleisteEl = document.getElementById("supp-zeitleiste");
zeitleisteEl.innerHTML = t.milestones
.map((m) => {
// Nutzer-Wunsch 21.08.2026: "die erste kiste soll blockiert sein wie
// die anderen, denn das ist ja verbunden mit dem abo." Ohne laufendes
// Abo läuft auch kein Countdown -- die Rechnung "noch 6 Monate" wäre
// schlicht falsch, weil der erste dieser Monate noch gar nicht
// angefangen hat. Deshalb: ohne Abo ist ALLES gesperrt, inklusive des
// ersten Meilensteins. Siehe aboLaeuft weiter oben.
const naechste = m.isNext && aboLaeuft;
const klasse = m.reached ? "erreicht" : naechste ? "naechste" : "";
const status = m.reached ? "Freigeschaltet" : naechste ? `Noch ${m.monthsRemaining} Monate` : "Gesperrt";
return `<div class="supp-meilenstein ${klasse}">
<div class="icon-medaille">${m.reached ? "🎁" : naechste ? "✨" : "🔒"}</div>
<div class="monat">Monat ${m.month}</div>
<div class="titel">${REDEEM_LABELS[m.type]}</div>
<div class="supp-premium-status">${status}</div>
</div>`;
})
.join("");
// Sammlungsarchiv
const archivEl = document.getElementById("supp-archiv");
if (!data.archive.length) {
archivEl.innerHTML = `<p class="small" style="opacity:.7;">Noch keine Prämien freigeschaltet — dein erster Meilenstein ist nach 6 erfolgreich bezahlten Monaten erreicht.</p>`;
} else {
archivEl.innerHTML = data.archive
.map(
(kol) => `
<div class="supp-kollektion">
<h3>Supporter-Kollektion ${String(kol.collectionNumber).padStart(2, "0")} — ${kol.collectionName}</h3>
${kol.premiums
.map(
(p) => `
<div class="supp-premium-row">
<div>
<strong>${p.label}</strong>
<div class="supp-premium-status status-${p.redeem_status}">${REDEEM_STATUS_LABELS[p.redeem_status] || p.redeem_status}${p.tracking_number ? ` · Sendungsnr. ${p.tracking_number}` : ""}</div>
</div>
${
p.redeem_status === "verfuegbar"
? `<button type="button" class="btn btn-outline btn-sm" data-redeem-toggle="${p.id}">Prämie einlösen</button>`
: ""
}
</div>
${p.redeem_status === "verfuegbar" ? redeemFormHtml(p) : ""}
`
)
.join("")}
</div>`
)
.join("");
archivEl.querySelectorAll("[data-redeem-toggle]").forEach((btn) => {
btn.addEventListener("click", () => {
const form = document.getElementById(`redeem-form-${btn.dataset.redeemToggle}`);
form.classList.toggle("offen");
});
});
archivEl.querySelectorAll("[data-redeem-submit]").forEach((btn) => {
btn.addEventListener("click", () => redeemAbschicken(btn.dataset.redeemSubmit, data.profile.hasShippingAddress));
});
}
// Profil-Formular vorbefüllen
document.getElementById("edit-name").value = data.profile.displayName || "";
document.getElementById("edit-tiktok").value = data.profile.tiktokUsername || "";
// Supporter-Abstimmungen (20.08.2026) — nur mit echtem Token abrufen,
// nicht im Vorschau-Modus mit Beispieldaten (kein API-Aufruf gewollt).
if (!nurMockNoetig) await ladePoll();
else document.getElementById("supp-poll-card").style.display = "none";
}
/* ---------- Supporter-Abstimmungen (20.08.2026) ----------
Zeigt die aktuell aktive Abstimmung (falls vorhanden). Vor der eigenen
Stimme: nur die Antwortoptionen zum Anklicken, keine Ergebnisse (damit
sie die eigene Wahl nicht beeinflussen). Nach der Stimme (auch bei
einem erneuten Seitenaufruf, dank myVote vom Server): Ergebnisbalken
statt Auswahl-Buttons. */
const feldSprache = (mehrsprachigesFeld) =>
window.dogiFeld ? window.dogiFeld(mehrsprachigesFeld) : mehrsprachigesFeld?.de || "";
let aktuellePoll = null;
let gewaehlteOption = null;
function renderPollErgebnisse(poll, counts, totalVotes, meineOption) {
const container = document.getElementById("poll-optionen-container");
container.innerHTML = poll.options
.map((opt, i) => {
const n = counts?.[i] || 0;
const proz = totalVotes ? Math.round((n / totalVotes) * 100) : 0;
const istMeine = i === meineOption;
return `<div class="poll-result-row">
<div class="poll-result-head${istMeine ? " meine-stimme" : ""}">
<span>${istMeine ? "✓ " : ""}${escapeHtmlPoll(feldSprache(opt))}</span>
<span>${n} ${escapeHtmlPoll(feldSprache(window.I18N_PAGE?.su_poll_stimmen))} · ${proz}%</span>
</div>
<div class="poll-bar-track"><div class="poll-bar-fill" style="width:${proz}%;"></div></div>
</div>`;
})
.join("");
document.getElementById("poll-abstimmen-btn").style.display = "none";
}
function escapeHtmlPoll(text) {
return String(text ?? "").replace(/[&<>"']/g, (z) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[z]));
}
function renderPollAuswahl(poll) {
const container = document.getElementById("poll-optionen-container");
gewaehlteOption = null;
container.innerHTML = poll.options
.map((opt, i) => `<button type="button" class="poll-option-btn" data-option-index="${i}">${escapeHtmlPoll(feldSprache(opt))}</button>`)
.join("");
container.querySelectorAll(".poll-option-btn").forEach((btn) => {
btn.addEventListener("click", () => {
gewaehlteOption = Number(btn.dataset.optionIndex);
container.querySelectorAll(".poll-option-btn").forEach((b) => b.classList.toggle("gewaehlt", b === btn));
});
});
document.getElementById("poll-abstimmen-btn").style.display = "";
}
async function ladePoll() {
const karte = document.getElementById("supp-poll-card");
let res;
try {
res = await S.apiFetch("/supporter/polls/active", {}, true);
} catch {
karte.style.display = "none";
return;
}
if (!res.poll) {
karte.style.display = "none";
return;
}
aktuellePoll = res.poll;
karte.style.display = "";
document.getElementById("poll-frage-text").textContent = feldSprache(res.poll.question);
document.getElementById("poll-erfolg").classList.remove("zeige");
document.getElementById("poll-fehler").classList.remove("zeige");
if (res.myVote !== null && res.myVote !== undefined) {
renderPollErgebnisse(res.poll, res.counts, res.totalVotes, res.myVote);
document.getElementById("poll-erfolg").classList.add("zeige");
} else {
renderPollAuswahl(res.poll);
}
}
document.getElementById("poll-abstimmen-btn").addEventListener("click", async () => {
const fehlerEl = document.getElementById("poll-fehler");
fehlerEl.classList.remove("zeige");
if (gewaehlteOption === null || !aktuellePoll) {
fehlerEl.textContent = feldSprache(window.I18N_PAGE?.su_poll_bitte_waehlen) || "Bitte wähle eine Antwort aus.";
fehlerEl.classList.add("zeige");
return;
}
try {
const res = await S.apiFetch("/supporter/polls/vote", { pollId: aktuellePoll.id, optionIndex: gewaehlteOption }, true);
renderPollErgebnisse(aktuellePoll, res.counts, res.totalVotes, gewaehlteOption);
document.getElementById("poll-erfolg").classList.add("zeige");
} catch (err) {
fehlerEl.textContent = err.message || "Abstimmen hat leider nicht geklappt. Bitte versuch es nochmal.";
fehlerEl.classList.add("zeige");
}
});
/* ---------- Detail-Modals für die klickbaren Kennzahl-Kärtchen ----------
Nutzer-Wunsch 04.08.2026: "wenn man auf diese 2 Kisten drückt, sollen
die viel mehr Infos kriegen ... richtig geil". Recherche, was für
Infos ein Supporter-/Abo-Bereich hier sinnvollerweise zeigen kann
(analog zu Patreon/Twitch-Sub-Verwaltung & App-Store-Aboverwaltung):
vollständige Zahlungshistorie mit Beträgen/Status, Gesamtsumme,
Zahlungsmethode, Vertragsbeginn — bzw. bei der Fortschritts-Kachel
eine visuelle Fortschrittsleiste zum nächsten Meilenstein, alle
Meilensteine des laufenden Zyklus mit Restzeit, und eine Würdigung
bereits komplett durchlaufener Kollektionen. */
function oeffneModal(html) {
// Verteidigung gegen ein leeres/kaputtes Modal (Nutzer-Meldung
// 04.08.2026: "immer noch" ein leerer Balken statt Inhalt) — falls die
// Builder-Funktion aus irgendeinem Grund nichts Sinnvolles liefert,
// lieber gar nicht erst öffnen als eine leere Hülle zeigen.
if (!html || !String(html).trim()) {
console.error("oeffneModal() wurde mit leerem Inhalt aufgerufen — Modal bleibt zu.");
return;
}
document.getElementById("supp-modal-body").innerHTML = html;
document.getElementById("supp-modal-overlay").hidden = false;
document.body.style.overflow = "hidden";
document.getElementById("supp-modal-close").focus();
}
function schliesseModal() {
document.getElementById("supp-modal-overlay").hidden = true;
document.body.style.overflow = "";
}
document.getElementById("supp-modal-close").addEventListener("click", schliesseModal);
document.getElementById("supp-modal-overlay").addEventListener("click", (e) => {
if (e.target.id === "supp-modal-overlay") schliesseModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !document.getElementById("supp-modal-overlay").hidden) schliesseModal();
});
function fmtBetrag(amount, currency) {
const n = parseFloat(amount);
if (Number.isNaN(n)) return "–";
return `${n.toFixed(2).replace(".", ",")} ${currency || "EUR"}`;
}
function modalZahlungHtml(data) {
const sub = data.subscription;
// Gleiche Regel wie auf der Preis-Kachel: ohne laufendes Abo bleibt der
// Preis verborgen (siehe Kommentar bei stat-preis).
const aboLaeuftZahlung = ["active", "pending", "suspended", "cancelled"].includes(sub.status);
const payments = data.payments || [];
const gesamtBezahlt = payments
.filter((p) => p.payment_status === "completed")
.reduce((summe, p) => summe + (parseFloat(p.amount) || 0), 0);
const zeilen = payments.length
? payments
.map(
(p) => `
<div class="supp-pay-row">
<span class="supp-pay-datum">${fmtDatum(p.paid_at)}</span>
<span class="supp-pay-betrag">${fmtBetrag(p.amount, p.currency)}</span>
<span class="supp-pay-status status-${p.payment_status}">${PAYMENT_STATUS_LABELS[p.payment_status] || p.payment_status}</span>
</div>`
)
.join("")
: `<p class="small" style="opacity:.7;">Noch keine Zahlungen erfasst.</p>`;
return `
<div class="supp-modal-head">
<span class="supp-modal-icon" aria-hidden="true">💳</span>
<h3 id="supp-modal-titel">Zahlungsdetails</h3>
<span class="supp-badge ${sub.status}">${STATUS_LABELS[sub.status] || sub.status}</span>
</div>
<div class="supp-modal-grid">
<div><div class="supp-modal-label">Nächste Zahlung</div><div class="supp-modal-value">${fmtDatum(sub.nextPaymentAt)}</div></div>
<!-- Preis auch hier verbergen, solange kein Abo läuft (21.08.2026) —
sonst wäre er über den Klick auf "Nächste Zahlung" doch wieder
sichtbar, obwohl er auf der Kachel daneben schon verborgen ist. -->
<div><div class="supp-modal-label">Betrag</div><div class="supp-modal-value">${aboLaeuftZahlung ? sub.monthlyPrice : "Coming soon…"}</div></div>
<div><div class="supp-modal-label">Abo läuft seit</div><div class="supp-modal-value">${fmtDatum(sub.startedAt)}</div></div>
<div><div class="supp-modal-label">Zahlungsmethode</div><div class="supp-modal-value">PayPal</div></div>
${sub.cancelledAt ? `<div><div class="supp-modal-label">Gekündigt am</div><div class="supp-modal-value">${fmtDatum(sub.cancelledAt)}</div></div>` : ""}
${sub.paidPeriodEndAt ? `<div><div class="supp-modal-label">Zugang bis</div><div class="supp-modal-value">${fmtDatum(sub.paidPeriodEndAt)}</div></div>` : ""}
<div><div class="supp-modal-label">Insgesamt bezahlt</div><div class="supp-modal-value">${gesamtBezahlt.toFixed(2).replace(".", ",")} €</div></div>
</div>
<h4 class="supp-modal-subh">Zahlungshistorie</h4>
<div class="supp-pay-liste">${zeilen}</div>
`;
}
function modalFortschrittHtml(data) {
const t = data.timeline;
const p = data.progress;
// Gleiche Regel wie in der Zeitleiste oben (21.08.2026): ohne laufendes
// Abo ist auch der erste Meilenstein gesperrt, sonst stünden hier und
// dort widersprüchliche Angaben.
const aboLaeuftModal = ["active", "pending", "suspended", "cancelled"].includes(data.subscription.status);
const naechster = aboLaeuftModal ? t.milestones.find((m) => m.isNext) : null;
const prozent = Math.round((t.currentCycleMonth / 30) * 100);
const meilensteineHtml = t.milestones
.map((m) => {
const naechste = m.isNext && aboLaeuftModal;
const klasse = m.reached ? "erreicht" : naechste ? "naechste" : "";
const status = m.reached ? "Freigeschaltet" : naechste ? `noch ${m.monthsRemaining} Monate` : "gesperrt";
return `<div class="supp-modal-meilenstein ${klasse}">
<span class="icon-medaille-mini" aria-hidden="true">${m.reached ? "🎁" : naechste ? "✨" : "🔒"}</span>
<div>
<div class="titel">${REDEEM_LABELS[m.type]}</div>
<div class="small" style="opacity:.75;">Monat ${m.month} · ${status}</div>
</div>
</div>`;
})
.join("");
return `
<div class="supp-modal-head">
<span class="supp-modal-icon" aria-hidden="true">🗓️</span>
<h3 id="supp-modal-titel">Deine Reise als Supporter</h3>
</div>
<div class="supp-modal-hero-zahl">${data.subscription.totalPaidMonths}<span class="small">Monate insgesamt treu dabei</span></div>
<div class="supp-progress-bar"><div class="supp-progress-fill" style="width:${prozent}%"></div></div>
<p class="small" style="margin-top:.5rem;opacity:.8;">Zyklus ${t.cycleNumber} — Monat ${t.currentCycleMonth} von 30 (${prozent}%)</p>
${
p.completedCycles > 0
? `<p class="supp-modal-celebrate">🏆 Du hast bereits ${p.completedCycles} komplette Kollektion${p.completedCycles > 1 ? "en" : ""} durchlaufen!</p>`
: ""
}
${
!aboLaeuftModal
? /* Ohne Abo darf hier NICHT "Zyklus komplett" stehen (das wäre der
Fall, wenn wirklich alle Meilensteine erreicht wären) — es hat
ja noch gar nichts angefangen. */
`<p class="small" style="margin-top:.9rem;">Alle Meilensteine sind noch gesperrt — sie starten, sobald dein Abo läuft.</p>`
: naechster
? `<p class="small" style="margin-top:.9rem;">Nächster Meilenstein: <strong>${REDEEM_LABELS[naechster.type]}</strong> in ${naechster.monthsRemaining} Monat${naechster.monthsRemaining === 1 ? "" : "en"}.</p>`
: `<p class="small" style="margin-top:.9rem;">Dieser Zyklus ist komplett — alle Prämien freigeschaltet! 🎉</p>`
}
<h4 class="supp-modal-subh">Meilensteine in diesem Zyklus</h4>
<div class="supp-modal-meilensteine">${meilensteineHtml}</div>
`;
}
function bindeStatKarten() {
const naechsteCard = document.getElementById("stat-card-naechste");
const monateCard = document.getElementById("stat-card-monate");
const oeffnen = (builder) => {
// Optional Chaining statt letzteDaten.subscription.status — falls
// letzteDaten aus irgendeinem Grund (Timing, unvollständige
// Vorschau-Daten) kein subscription-Objekt hat, würde der direkte
// Zugriff eine Exception werfen, die den Klick-Handler abbricht,
// OHNE dass irgendwo ein Fehler sichtbar wird — genau das Bild, das
// Dogi als "leeres Modal" gemeldet hat, könnte durch einen
// fehlgeschlagenen Aufruf VOR diesem Fix entstanden sein, wenn eine
// vorherige Zeile im selben Klick trotzdem schon das Overlay
// sichtbar gemacht hätte. Zusätzlich try/catch um den Builder-Aufruf
// selbst, mit sichtbarer Konsolen-Meldung statt stillem Fehlschlag.
/* Nutzer-Wunsch 21.08.2026: "sie sollen ALLES SEHEN KÖNNEN." Die
frühere Bedingung blockierte das Detail-Fenster komplett, solange
kein Abo lief — die Kacheln laden aber sichtbar mit "Details
ansehen ›" zum Klicken ein, es passierte dann nur einfach nichts.
Beide Fenster kommen mit "kein Abo" sauber zurecht: die
Zahlungsübersicht zeigt "Noch keine Zahlungen erfasst.", die
Fortschritts-Ansicht alle Meilensteine gesperrt plus passenden
Hinweis (siehe modalFortschrittHtml). Deshalb bleibt hier nur noch
die Absicherung gegen fehlende Daten. */
if (!letzteDaten) return;
try {
oeffneModal(builder(letzteDaten));
} catch (err) {
console.error("Konnte Detail-Modal nicht bauen:", err);
}
};
const alsTaste = (fn) => (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fn(); }
};
naechsteCard.addEventListener("click", () => oeffnen(modalZahlungHtml));
naechsteCard.addEventListener("keydown", alsTaste(() => oeffnen(modalZahlungHtml)));
monateCard.addEventListener("click", () => oeffnen(modalFortschrittHtml));
monateCard.addEventListener("keydown", alsTaste(() => oeffnen(modalFortschrittHtml)));
}
bindeStatKarten();
function redeemFormHtml(p) {
return `
<div class="supp-redeem-form" id="redeem-form-${p.id}">
<div class="supp-checkbox-row">
<input type="checkbox" id="use-saved-${p.id}" />
<label for="use-saved-${p.id}" style="margin:0;">Gespeicherte Adresse verwenden</label>
</div>
<div class="abo-field"><label>Name</label><input type="text" id="ship-name-${p.id}" /></div>
<div class="abo-field"><label>Straße &amp; Hausnummer</label><input type="text" id="ship-street-${p.id}" /></div>
<div class="abo-field"><label>PLZ</label><input type="text" id="ship-zip-${p.id}" /></div>
<div class="abo-field"><label>Ort</label><input type="text" id="ship-city-${p.id}" /></div>
<div class="abo-field"><label>Land</label><input type="text" id="ship-country-${p.id}" /></div>
${
p.premium_type === "hoodie"
? `<div class="abo-field"><label>Hoodie-Größe</label>
<select id="hoodie-size-${p.id}">
<option value="S">S</option><option value="M">M</option><option value="L" selected>L</option>
<option value="XL">XL</option><option value="XXL">XXL</option>
</select></div>`
: ""
}
<div class="supp-checkbox-row">
<input type="checkbox" id="save-address-${p.id}" />
<label for="save-address-${p.id}" style="margin:0;">Diese Adresse sicher für zukünftige Prämien speichern</label>
</div>
<button type="button" class="btn btn-primary btn-sm" data-redeem-submit="${p.id}">Absenden</button>
<p class="abo-fehler" id="redeem-fehler-${p.id}"></p>
</div>`;
}
async function redeemAbschicken(premiumId) {
const fehlerEl = document.getElementById(`redeem-fehler-${premiumId}`);
fehlerEl.classList.remove("zeige");
const useSaved = document.getElementById(`use-saved-${premiumId}`).checked;
const body = {
premiumId,
useSavedAddress: useSaved,
shipName: document.getElementById(`ship-name-${premiumId}`).value.trim(),
shipStreet: document.getElementById(`ship-street-${premiumId}`).value.trim(),
shipZip: document.getElementById(`ship-zip-${premiumId}`).value.trim(),
shipCity: document.getElementById(`ship-city-${premiumId}`).value.trim(),
shipCountry: document.getElementById(`ship-country-${premiumId}`).value.trim(),
saveAddress: document.getElementById(`save-address-${premiumId}`).checked,
};
const sizeEl = document.getElementById(`hoodie-size-${premiumId}`);
if (sizeEl) body.hoodieSize = sizeEl.value;
try {
await S.apiFetch("/supporter/redeem-premium", body, true);
await laden();
} catch (err) {
fehlerEl.textContent = err.message;
fehlerEl.classList.add("zeige");
}
}
document.getElementById("btn-save-profile").addEventListener("click", async () => {
const erfolgEl = document.getElementById("profil-erfolg");
erfolgEl.classList.remove("zeige");
try {
await S.apiFetch(
"/supporter/update-profile",
{ displayName: document.getElementById("edit-name").value.trim(), tiktokUsername: document.getElementById("edit-tiktok").value.trim() },
true
);
erfolgEl.classList.add("zeige");
} catch (err) {
alert(err.message);
}
});
document.getElementById("btn-cancel").addEventListener("click", async () => {
if (!confirm("Abo wirklich kündigen? Dein Zugang bleibt bis zum Ende des bezahlten Zeitraums bestehen.")) return;
const fehlerEl = document.getElementById("verwalten-fehler");
fehlerEl.classList.remove("zeige");
try {
await S.apiFetch("/supporter/paypal/cancel", {}, true);
await laden();
} catch (err) {
fehlerEl.textContent = err.message;
fehlerEl.classList.add("zeige");
}
});
document.getElementById("btn-logout").addEventListener("click", async () => {
try { await S.apiFetch("/supporter/logout", {}, true); } catch { /* egal */ }
S.clearToken();
window.location.href = "abonnieren.html";
});
document.addEventListener("dogi-vorschau-geaendert", laden);
// Bewusst NICHT sofort aufgerufen: main.js übersetzt beim
// "DOMContentLoaded"-Ereignis alle data-i18n-Elemente (auch die
// Begrüßungs-Überschrift, die hier gleich mit dem echten Namen
// überschrieben wird) — würde laden() vorher laufen, überschreibt
// applyTranslations() den echten Namen anschließend wieder mit dem
// generischen Standardtext. main.js patcht addEventListener so, dass
// ein "DOMContentLoaded"-Listener, der NACH dem eigentlichen Ereignis
// registriert wird (z.B. beim nahtlosen Seitenwechsel), sofort
// nachgeholt wird — hier reicht also ein normaler Listener für beide
// Fälle (erster Seitenaufbau UND spätere SPA-Navigation).
document.addEventListener("DOMContentLoaded", laden);
})();
</script>
</body>
</html>