Nutzer-Wunsch 21.08.2026: "benutz da bitte nur die originalen husky und hasen. mach nur die zwei. und die auswahl wo die leute selbst ein bild rein setzen können." - Auswahl von acht auf zwei reduziert (DogFather-Husky, HasiDog). Die übrigen Bilddateien bleiben liegen, falls sie je zurücksollen -- es genügt, die Zeile in data-stimmen-avatare.js und die Id in ERLAUBTE_AVATARE wieder zu ergänzen. - Neue Kachel "eigenes Bild" (gestrichelter Rand + Plus), die den Dateidialog öffnet, das Bild sofort hochlädt und als Vorschau in der Kachel zeigt. Bereits freigegebene Stimmen mit einer der entfernten Figuren zeigen wieder den Anfangsbuchstaben statt eines kaputten Bildes -- die Auflösung unbekannter Ids liefert null, das war schon so vorgesehen. Sicherheit des öffentlichen Uploads (bisher war Hochladen bewusst nur der Verwaltung erlaubt): - Gleiche multer-Härtung wie der Verwaltungs-Upload: nur JPG/PNG/WebP, max. 5 MB, zufälliger UUID-Dateiname (kein Originalname). - Der Server nimmt im Avatar-Feld weiterhin NUR bekannte Ids an oder eine Adresse, die exakt auf den eigenen Upload-Ordner zeigt und danach nur aus UUID + Bildendung besteht. Gegengetestet: fremde Domains, "../"-Ausbruch, .svg/.html, javascript:, angehängte Skripte und http statt https werden alle abgelehnt. - Missbrauchsbremse gegen Vollschreiben der Festplatte: max. 10 Uploads pro Stunde und IP. - Sichtbar wird ein Bild ohnehin erst, wenn die Stimme freigegeben wird. Mit Playwright end-to-end geprüft (10 Tests) plus 9 Sicherheitsfälle. Cache-Busting-Version auf 20260821q erhöht. Co-Authored-By: Claude Opus 5 <[email protected]>
1143 lines
63 KiB
HTML
1143 lines
63 KiB
HTML
<!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/favicon.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=20260821q" />
|
||
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821q" />
|
||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821q" />
|
||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821q" />
|
||
<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=20260821q"></script>
|
||
<script src="assets/js/supporter.js?v=20260821q"></script>
|
||
<script src="assets/js/main.js?v=20260821q"></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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 & 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>
|