Files
dogfather-universe/verwaltung.html
T

1524 lines
88 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" />
<meta name="robots" content="noindex, nofollow" />
<title>Verwaltung — DOGFATHER UNIVERSE</title>
<meta name="description" content="Interne Verwaltung — DogiCrew-Supporter, Prämien und Kollektionen. Nur mit Zugangscode." />
<link rel="icon" type="image/png" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/main.css" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
<style>
/* =====================================================================
verwaltung.html — Dogis privater Command-Center-Bereich.
Nutzer-Wunsch 04.08.2026 (1. Runde): "die Verwaltungsseite wird mein
privater Bereich und der soll so ultra krank geil aussehen, hoch
speziell, auf allen Ebenen."
Nutzer-Wunsch 04.08.2026 (2. Runde, nach Screenshot der 1. Version):
"ich will das noch viel spezieller und andere Farben. falls du dir
auch noch mehr da einfallen lassen kannst, dann mach es — ich will
dass da alles perfekt ist und nichts fehlt." → deshalb hier bewusst
WEG vom reinen Gold-Einheitslook (der ist schon auf jeder öffentlichen
DogiCrew-Seite verbaut) HIN zu einem eigenen "Juwelen-Tresor"-Look:
jede Kennzahl hat ihren eigenen Edelstein-Farbton (Amethyst/Smaragd/
Gold/Rosé), der Rahmen-Schimmer läuft durch alle vier Farben statt nur
Gold. Zusätzlich fehlende Admin-Funktionen ergänzt, die in einer
echten Verwaltung nicht fehlen dürfen: Live-Suche, sortierbare
Spalten, CSV-Export, mehr Detail-Infos (E-Mail verifiziert, zuletzt
aktiv, Kündigungs-/Zugangsdaten), Live-Uhrzeit "zuletzt aktualisiert".
Diese Seite liegt zusätzlich hinter der site-weiten Zugangsschranke
(gate-worker.js) UND braucht zusätzlich den internen Team-Code —
doppelt abgesichert, weil hier echte Supporter-Daten (E-Mails,
Adressen, Zahlungen) sichtbar sind. Bewusst NUR Deutsch (kein
data-i18n) — reines internes Werkzeug, nur für Dogi/Team.
Nutzer-Wunsch 04.08.2026 (4. Runde): "überall wo gelb ist, Babyblau
benutzen" — die komplette Gold-Farbfamilie (#d4af37/#ffe9a8 sowie die
zugehörigen rgba-Werte) wurde 1:1 durch Babyblau ersetzt — auch
innerhalb der mehrfarbigen Schimmer-Ränder, Glows und der Kennzahl-
Kachel "Monatsumsatz" (vorher Gold, Klasse dafür von `.vw-stat--gold`
in `.vw-stat--blau` umbenannt). Amethyst/Smaragd/Rosé bei den anderen
drei Kennzahlen bleiben unverändert, die waren nie gelb.
Nutzer-Wunsch 04.08.2026 (5. Runde): "das Babyblau soll erkennbarer
sein" — das ursprünglich verwendete, sehr helle/blasse Standard-
Babyblau der Seite (#8fd9ea/#c8f0f7, aus theme-dogfather.css) ging auf
dem dunklen Verwaltungs-Hintergrund optisch unter und wirkte neben den
gesättigten Amethyst-/Smaragd-/Rosé-Kacheln blass. NUR auf dieser
Seite (nicht am Marken-Farbwert selbst, der bleibt für die restliche
Website unverändert) durch ein kräftigeres, saturierteres Sky-Blau
ersetzt: #38bdf8 (Basis, vorher #8fd9ea) + #7dd3fc (helle Variante,
vorher #c8f0f7). Gleiche Baby-/Himmelblau-Familie, aber deutlich mehr
Sättigung und weniger Helligkeit → sticht jetzt genauso klar hervor
wie die anderen drei Akzentfarben.
===================================================================== */
#supp-root, #vw-stats-root, #session-bar { --accent: #7dd3fc; --accent-2: #38bdf8; --accent-contrast: #1a1408; }
body { position: relative; }
/* Nutzer-Wunsch 04.08.2026 (3. Runde): "alles bisschen größer, Schrift
deutlicher — nur ich seh das hier". Da fast die komplette Seite in
rem definiert ist, skaliert eine höhere Basis-Schriftgröße praktisch
alles proportional mit (Text UND viele Abstände/Paddings), ohne jede
einzelne Regel von Hand anfassen zu müssen. Nur px-Werte (Icons,
Avatare) skalieren NICHT automatisch mit — die werden unten gezielt
einzeln angehoben. */
html { font-size: 18.5px; }
/* ---------- Durchgehende Juwelen-Aurora (nicht nur beim Login) ---------- */
.vw-aurora { position: fixed; inset: -10%; z-index: -2; pointer-events: none; filter: blur(2px); }
.vw-aurora span { position: absolute; width: 44vw; height: 44vw; border-radius: 50%; filter: blur(85px); opacity: .3; mix-blend-mode: screen; }
.vw-aurora .g1 { left: -10%; top: -8%; background: radial-gradient(circle, rgba(56,189,248,.55), transparent 70%); animation: vw-drift 26s ease-in-out infinite; }
.vw-aurora .g2 { right: -12%; top: 16%; background: radial-gradient(circle, rgba(139,92,246,.42), transparent 70%); animation: vw-drift 32s ease-in-out infinite reverse; }
.vw-aurora .g3 { left: 22%; bottom: -18%; background: radial-gradient(circle, rgba(52,211,153,.3), transparent 70%); animation: vw-drift 38s ease-in-out infinite; }
.vw-aurora .g4 { right: 8%; bottom: -12%; background: radial-gradient(circle, rgba(255,111,165,.28), transparent 70%); animation: vw-drift 34s ease-in-out infinite reverse; }
@keyframes vw-drift { 0%,100% { transform: translate(0,0) scale(1); } 50% { transform: translate(5vw,4vh) scale(1.12); } }
@media (prefers-reduced-motion: reduce) { .vw-aurora span { animation: none; } }
/* =====================================================================
Logo-Wappen-Hintergrund (Nutzer-Wunsch 04.08.2026, 3. Runde: "ich will
auf dieser Seite auch mein Logo als Hintergrund, so richtig ultra
speziell, überrasch mich"). Zwei Ebenen:
1) Ein riesiges, sehr dezentes "Siegel" — das Logo mittig hinter der
gesamten Seite, langsam atmend, mit Gold-Glow und weichem
Rundausblenden zum Rand hin (mask-image), damit es wie ein
eingeprägtes Wappen wirkt statt wie ein plumpes Wasserzeichen.
2) Ein feines, winzig gekacheltes Muster desselben Logos, schräg
gedreht, bei nur ~2% Deckkraft — dieselbe Textur wie ein
Sicherheits-/Tresordokument, verstärkt das "Privater Bereich"-
Gefühl, ohne jemals mit dem Text zu konkurrieren.
Beide Ebenen liegen HINTER der Aurora (noch niedrigerer z-index),
damit die Farbverläufe weiter obendrüber schimmern. */
.vw-logo-bg { position: fixed; inset: 0; z-index: -4; pointer-events: none; overflow: hidden; }
.vw-logo-emblem {
position: absolute; top: 50%; left: 50%; width: min(1200px, 150vw); height: min(1200px, 150vw);
transform: translate(-50%, -50%);
background-image: url('assets/img/logo-dogfather-transparent.png');
background-size: contain; background-repeat: no-repeat; background-position: center;
opacity: .05;
filter: drop-shadow(0 0 120px rgba(56,189,248,.55)) drop-shadow(0 0 60px rgba(139,92,246,.25));
mask-image: radial-gradient(circle, #000 0%, #000 38%, transparent 72%);
-webkit-mask-image: radial-gradient(circle, #000 0%, #000 38%, transparent 72%);
animation: vw-emblem-atem 9s ease-in-out infinite;
}
@keyframes vw-emblem-atem {
0%, 100% { opacity: .045; transform: translate(-50%, -50%) scale(1); }
50% { opacity: .085; transform: translate(-50%, -50%) scale(1.035); }
}
.vw-logo-tile {
position: absolute; inset: -25%;
background-image: url('assets/img/logo-dogfather-transparent.png');
background-size: 150px 150px; background-repeat: repeat;
opacity: .02; transform: rotate(-14deg);
}
@media (prefers-reduced-motion: reduce) { .vw-logo-emblem { animation: none; } }
.vw-card { position: relative; overflow: hidden; border: 1.5px solid rgba(56,189,248,.35);
background: radial-gradient(circle at 18% 0%, rgba(56,189,248,.1), transparent 55%), linear-gradient(180deg, rgba(23,27,33,.92), rgba(14,16,20,.96)); }
/* Feines Foliekorn (dieselbe SVG-Turbulence wie .btn-holo im Hauptstyle)
für einen materiellen, "echten" Tresor-Oberflächen-Look statt platter
Flächen — sehr dezent (opacity), nur auf den großen Schimmer-Karten. */
.vw-noise::after {
content: ""; position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: .05; mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
background-size: 160px 160px;
}
.vw-noise > * { position: relative; z-index: 1; }
/* Juwelen-Schimmer-Rand: läuft durch Gold → Amethyst → Rosé → Smaragd →
Gold, statt nur eintönigem Gold — der "andere Farben"-Wunsch. Immer
derselbe zweischichtige background-position-Trick, NIE rotieren. */
.vw-shine-card {
position: relative; isolation: isolate; overflow: hidden;
border-radius: 18px; border: 1.5px solid transparent;
background:
linear-gradient(rgba(20,23,29,.94), rgba(12,14,18,.97)) padding-box,
linear-gradient(110deg, #38bdf8, #7dd3fc, #8b5cf6, #ff6fa5, #34d399, #38bdf8) border-box;
background-size: 100% 100%, 340% 100%;
animation: vw-stat-shimmer 10s linear infinite;
box-shadow: 0 14px 34px -18px rgba(0,0,0,.75);
}
@keyframes vw-stat-shimmer { to { background-position: 0 0, -340% 0; } }
@media (prefers-reduced-motion: reduce) { .vw-shine-card { animation: none; } }
.vw-badge { display: inline-flex; align-items: center; gap: .3rem; padding: .28rem .7rem; border-radius: 999px; font-size: .74rem; font-weight: 700; }
.vw-badge.active { background: rgba(52,211,153,.15); color: #5eeaa7; box-shadow: 0 0 12px rgba(52,211,153,.2); }
.vw-badge.pending { background: rgba(56,189,248,.15); color: #7dd3fc; }
.vw-badge.suspended, .vw-badge.expired { background: rgba(217,74,92,.15); color: #ff9a9a; }
.vw-badge.cancelled { background: rgba(255,255,255,.08); color: var(--text-muted); }
.vw-badge.none { background: rgba(255,255,255,.06); color: var(--text-muted); }
.vw-back { display: inline-flex; align-items: center; gap: .4rem; cursor: pointer; color: #7dd3fc; font-size: .9rem; font-weight: 600; transition: gap .15s ease; }
.vw-back:hover { gap: .65rem; }
.vw-fehler { color: #ff9a9a; font-size: .88rem; margin-top: .6rem; display: none; }
.vw-fehler.zeige { display: block; }
.vw-erfolg { color: #baf7d6; font-size: .88rem; margin-top: .6rem; display: none; }
.vw-erfolg.zeige { display: block; }
/* =====================================================================
Login-Gate: Tresor-/Sicherheits-Optik statt einer schlichten Karte.
===================================================================== */
#gate-container { position: relative; min-height: 70vh; display: flex; align-items: center; }
.vw-gate-wrap { max-width: 780px; }
.vw-gate-head { margin-bottom: 1.6rem; }
.vw-gate-grid {
display: flex; flex-wrap: wrap; gap: 1.3rem; justify-content: center; align-items: stretch;
}
.vw-gate-card {
position: relative; isolation: isolate; overflow: hidden;
flex: 1 1 280px; max-width: 340px;
padding: 1.7rem 1.5rem 1.6rem;
border-radius: 20px; border: 1.5px solid transparent;
background:
linear-gradient(rgba(16,18,22,.95), rgba(9,10,13,.97)) padding-box,
linear-gradient(110deg, #38bdf8, #7dd3fc, #8b5cf6, #ff6fa5, #34d399, #38bdf8) border-box;
background-size: 100% 100%, 340% 100%;
animation: vw-stat-shimmer 10s linear infinite;
box-shadow: 0 30px 70px -25px rgba(0,0,0,.8), 0 0 60px -20px rgba(56,189,248,.35);
}
@media (prefers-reduced-motion: reduce) { .vw-gate-card { animation: none; } }
/* VanVans Kiste: bewusst eigene, femininere Farbwelt (Rosé/Lavendel/Gold
statt Babyblau/Lila) und ein Blüten- statt Schloss-Symbol — keine
bloße Umfärbung von Dogis Kiste, damit beide klar als "ihre eigene"
erkennbar sind. */
.vw-gate-card--vanvan {
background:
linear-gradient(rgba(24,15,20,.95), rgba(15,9,13,.97)) padding-box,
linear-gradient(110deg, #ff8fc6, #ffd1e8, #c084fc, #ffd76a, #ff8fc6) border-box;
box-shadow: 0 30px 70px -25px rgba(0,0,0,.8), 0 0 60px -20px rgba(255,143,198,.4);
}
/* Nutzer-Wunsch 04.08.2026 (7. Runde): "richtig geil, mega speziell" —
die drei Platzhalter-Symbole (🔒 im großen Kreis oben, 🐺 bei Dogi,
🌸 bei VanVan) durch echte Fotos ersetzt: derselbe HasiDog-Schnitt wie
beim "Privater Bereich"-Wappen oben auf der Seite (gate-siegel-
hasidog.jpg), ein echtes Portrait für Dogis Kiste (vw-gate-dogi.jpg)
und ein echtes Portrait für VanVans Kiste (vw-gate-vanvan.jpg). Alle
drei als rundes Foto mit pulsierendem Farb-Ring statt Emoji, damit es
zum Rest der Schmuck-/Siegel-Optik dieser Seite passt. */
.vw-gate-schloss {
width: 72px; height: 72px; margin: 0 auto 1rem; border-radius: 50%;
background: radial-gradient(circle at 32% 28%, rgba(125,211,252,.35), rgba(56,189,248,.08) 70%),
url('assets/img/gate-siegel-hasidog.jpg') center / cover no-repeat;
border: 2px solid rgba(56,189,248,.6);
box-shadow: 0 0 26px rgba(56,189,248,.4), inset 0 0 12px rgba(255,255,255,.08);
animation: vw-schloss-puls 2.6s ease-in-out infinite;
}
@keyframes vw-schloss-puls {
0%, 100% { box-shadow: 0 0 20px rgba(56,189,248,.3), inset 0 0 12px rgba(255,255,255,.06); }
50% { box-shadow: 0 0 38px rgba(56,189,248,.6), inset 0 0 16px rgba(255,255,255,.12); }
}
.vw-gate-eyebrow {
display: block; text-align: center; font-size: .78rem; font-weight: 800; letter-spacing: .18em;
text-transform: uppercase; color: #ff9a9a; margin-bottom: .5rem;
}
.vw-gate-titel {
text-align: center; font-size: 1.6rem; font-weight: 800; margin: 0 0 .4rem;
background: linear-gradient(135deg, #fff, #7dd3fc 55%, #38bdf8);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.vw-gate-sub { text-align: center; font-size: .92rem; color: var(--text-muted); margin-bottom: 0; }
/* Kleine Kiste pro Person: eigenes Portrait-Foto + Name-Überschrift
statt Tier-Emoji, mit demselben Puls-Ring-Mechanismus wie oben, nur
in der jeweiligen Kartenfarbe. */
.vw-gate-card-icon {
display: block; width: 64px; height: 64px; margin: 0 auto .6rem; border-radius: 50%;
background-size: cover; background-position: center;
border: 2px solid rgba(56,189,248,.6);
box-shadow: 0 0 20px rgba(56,189,248,.4), inset 0 0 10px rgba(255,255,255,.08);
animation: vw-schloss-puls 2.6s ease-in-out infinite;
}
.vw-gate-card-icon--dogi { background-image: url('assets/img/vw-gate-dogi.jpg'); }
.vw-gate-card-icon--vanvan { background-image: url('assets/img/vw-gate-vanvan.jpg'); }
@keyframes vw-schloss-puls-rose {
0%, 100% { box-shadow: 0 0 20px rgba(255,143,198,.35), inset 0 0 10px rgba(255,255,255,.06); }
50% { box-shadow: 0 0 36px rgba(255,143,198,.65), inset 0 0 14px rgba(255,255,255,.12); }
}
.vw-gate-card--vanvan .vw-gate-card-icon {
border-color: rgba(255,143,198,.65);
animation-name: vw-schloss-puls-rose;
}
.vw-gate-card-name {
text-align: center; font-size: 1.15rem; font-weight: 800; margin: 0 0 1rem; color: var(--text);
}
.vw-gate-card--vanvan .vw-gate-card-name { color: #ffd1e8; }
@media (prefers-reduced-motion: reduce) {
.vw-gate-schloss, .vw-gate-card-icon { animation: none; }
}
.vw-gate-card label { display: block; font-size: .78rem; font-weight: 600; color: var(--text-muted); margin-bottom: .4rem; }
.vw-gate-card input {
width: 100%; box-sizing: border-box; padding: .85rem 1rem; border-radius: 14px;
background: rgba(255,255,255,.04); border: 1.5px solid var(--border); color: var(--text);
font-size: 1.05rem; letter-spacing: .25em; text-align: center;
transition: border-color .15s ease, box-shadow .15s ease;
}
.vw-gate-card input:focus { outline: none; border-color: rgba(56,189,248,.7); box-shadow: 0 0 0 4px rgba(56,189,248,.14); }
.vw-gate-card--vanvan input:focus { border-color: rgba(255,143,198,.7); box-shadow: 0 0 0 4px rgba(255,143,198,.16); }
/* Bugfix 04.08.2026: .btn-holo (main.css) ist für große, freistehende
Haupt-Knöpfe gedacht (großzügiges Padding, Großbuchstaben) — in einer
schmalen halben Login-Kiste (erst recht auf Mobilgeräten) brach der
Text dadurch auf zwei Zeilen um und wirkte riesig. Eigener, deutlich
kompakterer Knopf-Stil nur für diese Kisten. */
.vw-gate-card .btn-holo.vw-gate-btn {
width: 100%; justify-content: center; margin-top: 1.3rem;
padding: .6rem 1rem; font-size: .88rem; letter-spacing: .01em;
gap: .3rem;
}
.vw-gate-btn--vanvan {
background: linear-gradient(120deg, #ff8fc6 0%, #c084fc 30%, #ffd76a 55%, #ff8fc6 80%, #c084fc 100%);
background-size: 340% 340%;
}
@media (max-width: 640px) {
.vw-gate-grid { flex-direction: column; align-items: center; }
.vw-gate-card { max-width: 340px; width: 100%; }
}
/* =====================================================================
Hero: persönliches "Command Center" statt generischer Seitenkopf.
===================================================================== */
.vw-hero-crest {
width: 116px; height: 116px; margin: 0 auto 1.2rem; border-radius: 50%;
background:
radial-gradient(circle at 32% 26%, rgba(125,211,252,.25), rgba(56,189,248,.08) 72%),
url('assets/img/hasidog-kopf-siegel.jpg') center / cover no-repeat;
border: 2.5px solid rgba(56,189,248,.6);
box-shadow: 0 0 34px rgba(56,189,248,.4), inset 0 0 18px rgba(0,0,0,.35), inset 0 0 0 3px rgba(10,12,16,.5);
animation: vw-schloss-puls 3.4s ease-in-out infinite;
}
.vw-hero-eyebrow {
display: flex; align-items: center; justify-content: center; gap: .4rem;
font-size: .78rem; font-weight: 800; letter-spacing: .18em; text-transform: uppercase;
color: #ff9a9a; margin-bottom: .6rem;
}
.vw-hero-titel {
font-size: clamp(1.9rem, 4vw, 2.7rem); font-weight: 900; text-align: center; margin: 0 0 .5rem;
background: linear-gradient(100deg, #fff, #7dd3fc 30%, #8b5cf6 50%, #ff6fa5 65%, #38bdf8 85%, #fff);
background-size: 260% 100%; -webkit-background-clip: text; background-clip: text; color: transparent;
animation: vw-titel-glanz 9s ease-in-out infinite;
}
@keyframes vw-titel-glanz { 0%,100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
.vw-hero-gruss { text-align: center; font-size: 1.02rem; color: #7dd3fc; font-weight: 700; margin-bottom: .3rem; }
.vw-hero-sub { text-align: center; color: var(--text-muted); max-width: 560px; margin: 0 auto; }
@media (prefers-reduced-motion: reduce) { .vw-hero-crest, .vw-hero-titel { animation: none; } }
/* ---------- Session-Leiste ---------- */
.vw-session-bar {
display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;
padding: .9rem 1.3rem; border-radius: 14px;
}
.vw-owner-badge {
display: inline-flex; align-items: center; gap: .5rem; padding: .4rem .9rem .4rem .6rem; border-radius: 999px;
background: radial-gradient(circle at 20% 30%, rgba(125,211,252,.18), rgba(56,189,248,.06));
border: 1.5px solid rgba(56,189,248,.4); font-weight: 800; font-size: .84rem; color: #7dd3fc;
box-shadow: 0 0 16px rgba(56,189,248,.2);
}
.vw-owner-badge .krone { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.15rem;
background: radial-gradient(circle at 32% 26%, rgba(125,211,252,.6), rgba(56,189,248,.15) 70%); }
.vw-live-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #5eeaa7; box-shadow: 0 0 8px #5eeaa7; animation: vw-live-puls 2s ease-in-out infinite; }
@keyframes vw-live-puls { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
.vw-uhrzeit { font-size: .82rem; color: var(--text-muted); display: inline-flex; align-items: center; gap: .4rem; }
.vw-session-right { display: flex; align-items: center; gap: 1.1rem; flex-wrap: wrap; }
.vw-logout-btn { transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease; }
.vw-logout-btn:hover { border-color: rgba(56,189,248,.6); box-shadow: 0 0 16px rgba(56,189,248,.2); transform: translateY(-1px); }
/* ---------- Kennzahlen-Übersicht: jede Kachel ihr eigener Edelstein ---------- */
.vw-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 1.1rem; margin-bottom: 2rem; }
.vw-stat {
position: relative; isolation: isolate; overflow: hidden;
display: flex; flex-direction: column; align-items: flex-start; gap: .7rem;
border-radius: 18px; padding: 1.6rem 1.5rem 1.7rem;
border: 1.5px solid transparent;
background:
linear-gradient(rgba(20,23,29,.94), rgba(12,14,18,.96)) padding-box,
linear-gradient(120deg, color-mix(in srgb, var(--sc) 55%, transparent), color-mix(in srgb, var(--sc) 90%, white 15%), color-mix(in srgb, var(--sc) 55%, transparent)) border-box;
background-size: 100% 100%, 280% 100%;
animation: vw-stat-shimmer-solo 6s ease-in-out infinite;
box-shadow: 0 14px 30px -18px rgba(0,0,0,.7);
transition: transform .2s ease, box-shadow .2s ease;
}
@keyframes vw-stat-shimmer-solo { 0%,100% { background-position: 0 0, 0% 50%; } 50% { background-position: 0 0, 100% 50%; } }
.vw-stat:hover { transform: translateY(-4px); box-shadow: 0 20px 40px -16px rgba(0,0,0,.75), 0 0 26px color-mix(in srgb, var(--sc) 40%, transparent); }
.vw-stat--violet { --sc: #8b5cf6; }
.vw-stat--emerald { --sc: #34d399; }
.vw-stat--blau { --sc: #38bdf8; }
.vw-stat--rose { --sc: #ff6fa5; }
.vw-stat-icon {
flex: none; width: 56px; height: 56px; border-radius: 50%; font-size: 1.55rem;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(circle at 30% 25%, color-mix(in srgb, var(--sc) 55%, white 20%), color-mix(in srgb, var(--sc) 15%, transparent) 72%);
border: 1.5px solid color-mix(in srgb, var(--sc) 55%, transparent);
box-shadow: 0 0 16px color-mix(in srgb, var(--sc) 35%, transparent), inset 0 0 10px rgba(255,255,255,.06);
}
.vw-stat .label { font-size: .8rem; font-weight: 800; color: var(--sc); margin-top: .2rem; text-transform: uppercase; letter-spacing: .06em; opacity: .95; }
.vw-stat .value { font-size: 1.65rem; font-weight: 800; line-height: 1.3; }
.vw-stat .value small { font-size: .76rem; font-weight: 600; color: var(--text-muted); text-transform: none; letter-spacing: 0; }
@media (prefers-reduced-motion: reduce) { .vw-stat { animation: none; } }
/* Fallback für Browser ohne color-mix() — bewusst konservativ, sieht
trotzdem gut aus statt kaputt (main.css unterstützt bereits moderne
CSS-Features, aber sicher ist sicher). */
@supports not (color: color-mix(in srgb, red, blue)) {
.vw-stat--violet .vw-stat-icon, .vw-stat--violet { border-color: rgba(139,92,246,.5); }
.vw-stat--emerald .vw-stat-icon, .vw-stat--emerald { border-color: rgba(52,211,153,.5); }
.vw-stat--rose .vw-stat-icon, .vw-stat--rose { border-color: rgba(255,111,165,.5); }
}
/* ---------- Toolbar / Filter / Suche ---------- */
.vw-toolbar { display: flex; gap: .9rem; flex-wrap: wrap; align-items: center; padding: 1rem 1.2rem; margin-bottom: 1.4rem; }
.vw-toolbar label { font-size: .8rem; color: var(--text-muted); font-weight: 600; }
.vw-select-wrap { position: relative; }
.vw-select-btn {
display: flex; align-items: center; justify-content: space-between; gap: .8rem; width: 100%;
min-width: 190px; appearance: none; -webkit-appearance: none; padding: .6rem 2.2rem .6rem .9rem; border-radius: 10px;
background: rgba(255,255,255,.04); border: 1.5px solid rgba(56,189,248,.3); color: var(--text); font-weight: 600;
font-family: inherit; font-size: .95rem; text-align: left; cursor: pointer;
transition: border-color .15s ease, box-shadow .15s ease;
}
.vw-select-btn:hover, .vw-select-wrap.open .vw-select-btn { outline: none; border-color: rgba(56,189,248,.7); box-shadow: 0 0 0 3px rgba(56,189,248,.12); }
.vw-select-wrap::after {
content: "▾"; position: absolute; right: .9rem; top: 50%; transform: translateY(-50%); pointer-events: none;
color: #7dd3fc; font-size: .8rem; transition: transform .15s ease;
}
.vw-select-wrap.open::after { transform: translateY(-50%) rotate(180deg); }
/* Eigenes Dropdown statt der nativen <select>-Liste — die wird vom
Browser/Betriebssystem gezeichnet und lässt sich mit CSS praktisch
nicht themen (siehe Kommentar oben bei der HTML-Struktur). */
/* Bugfix 04.08.2026: "position:fixed" führte auf dieser Seite (viele
verschachtelte "isolation:isolate"/"overflow:hidden"-Schimmer-Karten
im Seitenbaum) zu einem echten Compositing-Fehler des Browsers — die
Liste wurde beim Rendern teilweise dupliziert/an falscher Stelle
gemalt (reproduzierbar über mehrere unabhängige Testmethoden
verifiziert, es lag NICHT an Scroll-Handling oder der Animation).
Fix: "Portal"-Muster — die Liste wird per JS beim Öffnen direkt als
Kind von <body> angehängt (siehe initFilterDropdown) und dokument-
relativ absolut positioniert, komplett außerhalb jeder Karten-
Verschachtelung. Dasselbe Muster wird von den meisten UI-Bibliotheken
genau für solche Fälle verwendet. */
.vw-select-list {
position: absolute; z-index: 200; list-style: none;
margin: 0; width: max-content; max-width: 260px; padding: .4rem;
border-radius: 14px; border: 1.5px solid rgba(56,189,248,.4);
background: rgba(10,12,16,.98);
box-shadow: 0 14px 34px rgba(0,0,0,.6), 0 0 26px rgba(56,189,248,.22);
}
/* Bugfix-Muster aus früheren Runden (Spezifitätskonflikt [hidden] vs.
Autor-CSS) hier vorsorglich vermieden: .vw-select-list selbst setzt
kein "display", nur diese Regel — [hidden] gewinnt dadurch immer. */
.vw-select-list[hidden] { display: none; }
/* Bugfix 04.08.2026: eine CSS-"animation" mit fill-mode "both" blieb
nach dem Portal-Umzug (siehe initFilterDropdown) manchmal exakt im
Startzustand hängen (opacity:0) statt am Ende bei opacity:1 zu
landen — vermutlich, weil das Neu-Anhängen an <body> plus die
Scroll/Resize-Neupositionierung die Animation mehrfach neu
angestoßen hat. Einfacher & zuverlässiger ohne Animation. */
.vw-select-list li {
padding: .55rem .7rem; border-radius: 9px; font-size: .88rem; font-weight: 600; color: var(--text);
cursor: pointer; border-left: 3px solid transparent; transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.vw-select-list li:hover { background: rgba(56,189,248,.14); }
.vw-select-list li.selected { color: #7dd3fc; border-left-color: #38bdf8; background: rgba(56,189,248,.1); }
.vw-search-wrap { position: relative; flex: 1 1 220px; max-width: 320px; }
.vw-search-wrap input {
width: 100%; box-sizing: border-box; padding: .6rem .9rem .6rem 2.2rem; border-radius: 10px;
background: rgba(255,255,255,.04); border: 1.5px solid rgba(139,92,246,.35); color: var(--text);
transition: border-color .15s ease, box-shadow .15s ease;
}
.vw-search-wrap input:focus { outline: none; border-color: rgba(139,92,246,.75); box-shadow: 0 0 0 3px rgba(139,92,246,.15); }
.vw-search-wrap::before { content: "🔍"; position: absolute; left: .75rem; top: 50%; transform: translateY(-50%); font-size: .85rem; opacity: .7; pointer-events: none; }
.vw-refresh-btn, .vw-export-btn {
display: inline-flex; align-items: center; gap: .5rem; padding: .58rem 1.1rem; border-radius: 999px;
background: rgba(56,189,248,.08); border: 1.5px solid rgba(56,189,248,.35); color: #7dd3fc; font-weight: 700; font-size: .85rem;
cursor: pointer; transition: background .15s ease, border-color .15s ease, transform .15s ease;
}
.vw-refresh-btn:hover, .vw-export-btn:hover { background: rgba(56,189,248,.16); border-color: rgba(56,189,248,.6); }
.vw-export-btn { background: rgba(52,211,153,.08); border-color: rgba(52,211,153,.35); color: #5eeaa7; }
.vw-export-btn:hover { background: rgba(52,211,153,.16); border-color: rgba(52,211,153,.6); }
.vw-refresh-btn .ic { display: inline-block; transition: transform .5s ease; }
.vw-refresh-btn.lädt .ic { animation: vw-spin .6s linear infinite; }
@keyframes vw-spin { to { transform: rotate(360deg); } }
.vw-anzahl { margin-left: auto; font-size: .85rem; color: var(--text-muted); font-weight: 600; }
/* ---------- Supporter-Tabelle ---------- */
.vw-table-wrap { overflow-x: auto; border-radius: 18px; }
.vw-table { width: 100%; border-collapse: collapse; font-size: .92rem; min-width: 680px; }
.vw-table thead th {
position: sticky; top: 0; z-index: 1;
text-align: left; padding: .8rem .9rem; color: #7dd3fc; font-size: .72rem; text-transform: uppercase;
letter-spacing: .06em; font-weight: 800; background: rgba(10,15,22,.94); border-bottom: 1.5px solid rgba(56,189,248,.35);
}
.vw-table thead th.sortierbar { cursor: pointer; user-select: none; transition: color .15s ease; }
.vw-table thead th.sortierbar:hover { color: #fff; }
.vw-table thead th .pfeil { opacity: .5; margin-left: .25rem; font-size: .7rem; }
.vw-table thead th.aktiv-sort .pfeil { opacity: 1; color: #ff6fa5; }
.vw-table tbody td { padding: .75rem .9rem; border-bottom: 1px solid rgba(255,255,255,.05); vertical-align: middle; }
.vw-table tbody tr { transition: background .15s ease, transform .15s ease; }
.vw-table tbody tr:nth-child(even) { background: rgba(255,255,255,.015); }
.vw-table tr.klickbar { cursor: pointer; }
.vw-table tr.klickbar:hover { background: rgba(139,92,246,.09); }
.vw-avatar {
width: 40px; height: 40px; border-radius: 50%; flex: none;
display: flex; align-items: center; justify-content: center; font-size: .92rem; font-weight: 800;
background: radial-gradient(circle at 30% 25%, rgba(125,211,252,.4), rgba(56,189,248,.12) 75%);
border: 1.5px solid rgba(56,189,248,.4); color: #7dd3fc;
}
.vw-name-cell { display: flex; align-items: center; gap: .7rem; font-weight: 700; }
.vw-mono { font-family: ui-monospace, "SF Mono", Consolas, monospace; font-size: .85rem; color: var(--text-muted); }
/* ---------- Detailansicht ---------- */
.vw-detail-head { display: flex; align-items: center; gap: 1.1rem; flex-wrap: wrap; margin-bottom: 1.4rem; }
.vw-avatar-lg {
width: 78px; height: 78px; border-radius: 50%; flex: none;
display: flex; align-items: center; justify-content: center; font-size: 1.8rem; font-weight: 800;
background: radial-gradient(circle at 30% 25%, rgba(125,211,252,.5), rgba(56,189,248,.15) 75%);
border: 1.5px solid rgba(56,189,248,.5); color: #7dd3fc;
box-shadow: 0 0 20px rgba(56,189,248,.25);
}
.vw-detail-name { margin: 0 0 .35rem; font-size: 1.4rem; }
.vw-chip-row { display: flex; flex-wrap: wrap; gap: .5rem; }
.vw-chip {
display: inline-flex; align-items: center; gap: .35rem; padding: .3rem .7rem; border-radius: 999px;
font-size: .8rem; font-weight: 600; background: rgba(255,255,255,.04); border: 1px solid var(--border); color: var(--text-muted);
}
.vw-chip.verifiziert { background: rgba(52,211,153,.1); border-color: rgba(52,211,153,.35); color: #5eeaa7; }
.vw-chip.unverifiziert { background: rgba(217,74,92,.1); border-color: rgba(217,74,92,.35); color: #ff9a9a; }
.vw-copy-btn {
display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 50%;
background: rgba(255,255,255,.05); border: 1px solid var(--border); color: var(--text-muted); cursor: pointer;
font-size: .88rem; transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.vw-copy-btn:hover { background: rgba(56,189,248,.15); border-color: rgba(56,189,248,.5); color: #7dd3fc; }
.vw-copy-btn.kopiert { background: rgba(52,211,153,.15); border-color: rgba(52,211,153,.5); color: #5eeaa7; }
.vw-detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: .9rem 1.1rem; margin: 1.4rem 0 .4rem; }
.vw-detail-label { font-size: .68rem; text-transform: uppercase; letter-spacing: .06em; color: #7dd3fc; opacity: .8; font-weight: 700; }
.vw-detail-value { font-size: 1.02rem; font-weight: 700; margin-top: .15rem; }
.vw-subh { font-size: .85rem; text-transform: uppercase; letter-spacing: .06em; color: #7dd3fc; opacity: .85; margin: 1.8rem 0 .8rem; display: flex; align-items: center; gap: .4rem; }
/* ---------- Mini-Fortschrittsbalken in der Tabelle ---------- */
.vw-mini-progress-wrap { display: flex; flex-direction: column; gap: .3rem; min-width: 130px; }
.vw-mini-progress { height: 9px; border-radius: 999px; background: rgba(255,255,255,.07); overflow: hidden; border: 1px solid var(--border); }
.vw-mini-progress-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #8b5cf6, #ff6fa5, #38bdf8); box-shadow: 0 0 8px rgba(56,189,248,.4); transition: width .4s ease; }
.vw-mini-progress-label { font-size: .72rem; color: var(--text-muted); white-space: nowrap; }
/* ---------- Fortschritts-Kiste in der Detailansicht ---------- */
.vw-progress-box { padding: 1.6rem 1.6rem 1.8rem; margin-top: 1.6rem; }
.vw-progress-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .8rem; margin-bottom: 1rem; }
.vw-progress-zyklus {
display: inline-flex; align-items: center; gap: .4rem; padding: .35rem .85rem; border-radius: 999px;
background: rgba(139,92,246,.12); border: 1.5px solid rgba(139,92,246,.4); color: #c4b0fb; font-weight: 800; font-size: .82rem;
}
.vw-progress-hero { font-size: 2.1rem; font-weight: 900; line-height: 1; margin-bottom: .3rem; }
.vw-progress-hero small { display: block; font-size: .8rem; font-weight: 600; color: var(--text-muted); margin-top: .3rem; }
.vw-progress-bar-big { height: 18px; border-radius: 999px; background: rgba(255,255,255,.06); overflow: hidden; border: 1px solid var(--border); margin: 1rem 0 .5rem; }
.vw-progress-bar-big-fill {
height: 100%; border-radius: 999px; background: linear-gradient(90deg, #8b5cf6, #ff6fa5, #38bdf8, #7dd3fc);
background-size: 220% 100%; animation: abo-progress-shine 5s ease-in-out infinite; box-shadow: 0 0 14px rgba(56,189,248,.45);
transition: width .5s ease;
}
@keyframes abo-progress-shine { 0%,100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
.vw-progress-celebrate {
margin-top: .8rem; padding: .7rem 1rem; border-radius: 12px; font-size: .87rem; font-weight: 700;
background: rgba(56,189,248,.1); border: 1px solid rgba(56,189,248,.35); color: #7dd3fc;
}
.vw-progress-milestones { display: flex; flex-wrap: wrap; gap: .8rem; margin-top: 1.4rem; }
.vw-progress-milestone {
flex: 1 1 140px; text-align: center; padding: 1rem .6rem .8rem; border-radius: 14px;
border: 1.5px solid var(--border); background: rgba(255,255,255,.02); transition: transform .18s ease, border-color .18s ease;
}
.vw-progress-milestone.erreicht { border-color: rgba(56,189,248,.55); background: rgba(56,189,248,.07); }
.vw-progress-milestone.naechste { border-color: rgba(56,189,248,.55); background: rgba(56,189,248,.07); }
.vw-progress-milestone:hover { transform: translateY(-3px); }
.vw-progress-milestone .med {
width: 56px; height: 56px; margin: 0 auto .4rem; 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);
}
.vw-progress-milestone.erreicht .med { border-color: #7dd3fc; background: radial-gradient(circle at 32% 26%, rgba(125,211,252,.55), rgba(56,189,248,.12) 70%); }
.vw-progress-milestone.naechste .med { border-color: #38bdf8; background: radial-gradient(circle at 32% 26%, rgba(56,189,248,.4), transparent 70%); }
.vw-progress-milestone .m-monat { font-size: .74rem; color: var(--text-muted); }
.vw-progress-milestone .m-titel { font-weight: 800; font-size: .85rem; margin-top: .15rem; }
.vw-progress-milestone .m-status { font-size: .72rem; margin-top: .3rem; color: var(--text-muted); }
.vw-premium-card {
display: flex; align-items: center; gap: .9rem; flex-wrap: wrap; padding: .9rem 1rem; border-radius: 14px;
background: rgba(255,255,255,.02); border: 1px solid var(--border); margin-bottom: .7rem;
}
.vw-premium-icon {
flex: none; width: 50px; height: 50px; border-radius: 50%; font-size: 1.35rem;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(circle at 32% 26%, rgba(125,211,252,.4), rgba(56,189,248,.1) 70%);
border: 1.5px solid rgba(56,189,248,.4);
}
.vw-premium-info { flex: 1 1 180px; min-width: 0; }
.vw-premium-info strong { display: block; }
.vw-premium-row-fields { display: flex; gap: .6rem; flex-wrap: wrap; align-items: center; }
.vw-premium-status {
display: inline-block; font-size: .74rem; font-weight: 700; padding: .22rem .6rem; border-radius: 999px;
color: var(--text-muted); background: rgba(255,255,255,.06); white-space: nowrap;
}
.vw-premium-status.status-verfuegbar { background: rgba(56,189,248,.15); color: #7dd3fc; }
.vw-premium-status.status-wird_geprueft, .vw-premium-status.status-wird_vorbereitet { background: rgba(255,111,165,.15); color: #ff9fc4; }
.vw-premium-status.status-versandbereit, .vw-premium-status.status-versendet { background: rgba(56,189,248,.15); color: #38bdf8; }
.vw-premium-status.status-zugestellt, .vw-premium-status.status-erhalten { background: rgba(52,211,153,.15); color: #5eeaa7; }
.vw-premium-status.status-problem, .vw-premium-status.status-ersatz_wird_vorbereitet { background: rgba(217,74,92,.15); color: #ff9a9a; }
.vw-premium-card select {
padding: .45rem .7rem; border-radius: 8px; background: rgba(255,255,255,.04); border: 1px solid var(--border); color: var(--text); font-size: .85rem;
}
.vw-premium-card input[type="text"] {
padding: .45rem .7rem; border-radius: 8px; background: rgba(255,255,255,.04); border: 1px solid var(--border); color: var(--text);
font-size: .85rem; max-width: 160px;
}
.vw-pay-liste { display: flex; flex-direction: column; gap: .45rem; max-height: 280px; overflow-y: auto; padding-right: .3rem; }
.vw-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;
}
.vw-pay-datum { color: var(--text-muted); flex: 1 1 auto; }
.vw-pay-betrag { font-weight: 700; }
.vw-pay-status { font-size: .74rem; font-weight: 700; padding: .18rem .55rem; border-radius: 999px; white-space: nowrap; }
.vw-pay-status.status-completed { background: rgba(52,211,153,.15); color: #5eeaa7; }
.vw-pay-status.status-failed { background: rgba(217,74,92,.15); color: #ff9a9a; }
.vw-pay-status.status-refunded, .vw-pay-status.status-reversed { background: rgba(56,189,248,.15); color: #38bdf8; }
.vw-leer { color: var(--text-muted); font-size: .9rem; padding: 1rem 0; text-align: center; }
/* =====================================================================
Feste Übersichts-Kisten auf der Hauptseite (Nutzer-Wunsch 04.08.2026:
"alle diese Fenster die nur dann erscheinen wenn ein Abonnent da ist,
sollen ein festes Fenster bekommen ... man muss draufklicken um zu
sehen wer was hat, wie weit ist"). Statt Fortschritt/Prämien/Zahlungen
nur je EINZELNEM Supporter in dessen Detailansicht zu zeigen, gibt es
jetzt drei permanent sichtbare, aufklappbare Kisten mit der Übersicht
über ALLE Supporter gleichzeitig — Kopfzeile zeigt die Kurzfassung
(Kennzahlen-Chips) immer, der Klick klappt die volle Liste auf.
===================================================================== */
.vw-overview-box { margin-bottom: 1.4rem; padding: 0; }
.vw-overview-head {
display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
padding: 1.1rem 1.4rem; cursor: pointer; user-select: none;
}
.vw-overview-title { display: flex; align-items: center; gap: .6rem; font-weight: 800; font-size: 1rem; flex: 1 1 auto; }
.vw-overview-summary { display: flex; gap: .5rem; flex-wrap: wrap; }
.vw-overview-chip {
padding: .32rem .75rem; border-radius: 999px; font-size: .76rem; font-weight: 700;
background: rgba(255,255,255,.05); border: 1px solid var(--border); color: var(--text-muted); white-space: nowrap;
}
.vw-overview-chip.warn { background: rgba(56,189,248,.14); border-color: rgba(56,189,248,.4); color: #7dd3fc; }
.vw-overview-chip.gefahr { background: rgba(217,74,92,.14); border-color: rgba(217,74,92,.4); color: #ff9a9a; }
.vw-overview-toggle {
font-size: .82rem; color: #7dd3fc; display: flex; align-items: center; gap: .35rem; font-weight: 700; flex: none;
}
.vw-overview-toggle .pfeil { display: inline-block; transition: transform .2s ease; }
.vw-overview-box.offen .vw-overview-toggle .pfeil { transform: rotate(180deg); }
.vw-overview-body { display: none; padding: 0 1.4rem 1.4rem; border-top: 1px solid var(--border); max-height: 480px; overflow-y: auto; }
.vw-overview-box.offen .vw-overview-body { display: block; padding-top: 1.1rem; animation: vw-fade-in .25s ease-out; }
.vw-fortschritt-row {
display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; padding: .7rem .5rem; border-radius: 10px;
cursor: pointer; transition: background .15s ease;
}
.vw-fortschritt-row:hover { background: rgba(139,92,246,.08); }
.vw-fortschritt-row + .vw-fortschritt-row { border-top: 1px dashed var(--border); }
/* Sanftes Einblenden von Kennzahlen/Zeilen — fühlt sich "lebendig" an
statt dass alles einfach ruckartig erscheint. */
@keyframes vw-fade-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.vw-stat, .vw-table tbody tr { animation: vw-fade-in .35s ease-out backwards; }
.vw-table tbody tr:nth-child(1) { animation-delay: .02s; }
.vw-table tbody tr:nth-child(2) { animation-delay: .05s; }
.vw-table tbody tr:nth-child(3) { animation-delay: .08s; }
.vw-table tbody tr:nth-child(4) { animation-delay: .11s; }
.vw-table tbody tr:nth-child(5) { animation-delay: .14s; }
@media (prefers-reduced-motion: reduce) { .vw-stat, .vw-table tbody tr { animation: none; } }
</style>
</head>
<body>
<div class="vw-logo-bg" aria-hidden="true">
<div class="vw-logo-tile"></div>
<div class="vw-logo-emblem"></div>
</div>
<div class="vw-aurora" aria-hidden="true"><span class="g1"></span><span class="g2"></span><span class="g3"></span><span class="g4"></span></div>
<div id="site-header"></div>
<main>
<section class="hero" id="page-hero" style="display:none;">
<div class="container">
<div class="vw-hero-crest" aria-hidden="true"></div>
<span class="vw-hero-eyebrow">🔒 Privater Bereich</span>
<h1 class="vw-hero-titel">DogiCrew-Verwaltung</h1>
<p class="vw-hero-gruss" id="hero-gruss">Willkommen zurück.</p>
<p class="vw-hero-sub">Supporter, Prämien und Kollektionen — alles auf einen Blick, nur für dich sichtbar.</p>
</div>
</section>
<!-- ===== Login-Gate =====
Nutzer-Wunsch 04.08.2026 (6. Runde): (1) der "Verwaltung öffnen"-Knopf
war auf schmalen Bildschirmen riesig (umgebrochen auf zwei Zeilen) —
jetzt ein deutlich kompakterer .vw-gate-btn-Stil statt des vollen
.btn-holo-Formats. (2) VanVan hatte bisher keine eigene Zeile/Kiste,
beide teilten sich ein einziges Eingabefeld — jetzt zwei getrennte,
gleichwertige Kisten nebeneinander (auf Mobilgeräten untereinander),
jede mit eigenem Feld + eigenem Knopf. Dogis Kiste behält die
bisherige Babyblau/Lila-Optik, VanVans Kiste bekommt bewusst eine
eigene, femininere Optik (Rosé/Lavendel/Gold, Blüten-Icon statt
Schloss) statt einer bloßen Farbkopie. Beide Formulare rufen
weiterhin denselben Login-Endpunkt auf — technisch unverändert,
nur die Zugangscodes entscheiden serverseitig, wer sich anmeldet. -->
<section id="gate-container">
<div class="container vw-gate-wrap">
<div class="vw-gate-head">
<div class="vw-gate-schloss" aria-hidden="true"></div>
<span class="vw-gate-eyebrow">Gesperrter Bereich</span>
<h1 class="vw-gate-titel">DogiCrew-Verwaltung</h1>
<p class="vw-gate-sub">Zugangscode erforderlich — gilt danach 24 Stunden.</p>
</div>
<div class="vw-gate-grid">
<form id="code-gate-dogi" class="vw-gate-card vw-gate-card--dogi" novalidate>
<span class="vw-gate-card-icon vw-gate-card-icon--dogi" aria-hidden="true"></span>
<h2 class="vw-gate-card-name">Dogi</h2>
<label for="code-input-dogi">Zugangscode</label>
<input id="code-input-dogi" name="code" type="password" autocomplete="off" required autofocus />
<button class="btn-holo vw-gate-btn" type="submit">
<span class="btn-holo-text">💙 Öffnen</span>
</button>
<p class="vw-fehler" id="gate-fehler-dogi"></p>
</form>
<form id="code-gate-vanvan" class="vw-gate-card vw-gate-card--vanvan" novalidate>
<span class="vw-gate-card-icon vw-gate-card-icon--vanvan" aria-hidden="true"></span>
<h2 class="vw-gate-card-name">VanVan</h2>
<label for="code-input-vanvan">Zugangscode</label>
<input id="code-input-vanvan" name="code" type="password" autocomplete="off" required />
<button class="btn-holo vw-gate-btn vw-gate-btn--vanvan" type="submit">
<span class="btn-holo-text">💗 Öffnen</span>
</button>
<p class="vw-fehler" id="gate-fehler-vanvan"></p>
</form>
</div>
</div>
</section>
<!-- ===== Session-Leiste ===== -->
<section class="section-tight" id="session-bar" style="display:none;padding-top:0;">
<div class="container">
<div class="vw-session-bar vw-shine-card vw-noise">
<span class="vw-owner-badge" id="role-badge"><span class="krone">👑</span> Owner</span>
<div class="vw-session-right">
<span class="vw-uhrzeit"><span class="vw-live-dot" aria-hidden="true"></span> Aktualisiert: <strong id="letzte-aktualisierung"></strong></span>
<button class="btn btn-outline vw-logout-btn" id="logout-btn" type="button">Abmelden</button>
</div>
</div>
</div>
</section>
<!-- ===== Kennzahlen-Übersicht ===== -->
<section class="section-tight" id="vw-stats-root" style="display:none;padding-top:0;">
<div class="container">
<div class="vw-stats">
<div class="vw-stat vw-stat--violet"><span class="vw-stat-icon" aria-hidden="true">👥</span><div><div class="label">Gesamt Supporter</div><div class="value" id="stat-gesamt"></div></div></div>
<div class="vw-stat vw-stat--emerald"><span class="vw-stat-icon" aria-hidden="true">💚</span><div><div class="label">Aktive Abos</div><div class="value" id="stat-aktiv"></div></div></div>
<div class="vw-stat vw-stat--blau"><span class="vw-stat-icon" aria-hidden="true">💰</span><div><div class="label">Monatsumsatz</div><div class="value" id="stat-umsatz"></div></div></div>
<div class="vw-stat vw-stat--rose"><span class="vw-stat-icon" aria-hidden="true"></span><div><div class="label">Neu (30 Tage)</div><div class="value" id="stat-neu"></div></div></div>
</div>
</div>
</section>
<!-- ===== Supporter-Liste ===== -->
<section class="section-tight" id="supp-root" style="display:none;padding-top:0;">
<div class="container" id="liste-ansicht">
<!-- ===== Feste Übersichts-Kisten: immer sichtbar, kein Anklicken
eines einzelnen Supporters nötig (Nutzer-Wunsch 04.08.2026). ===== -->
<div class="card vw-card vw-shine-card vw-noise vw-overview-box" id="box-praemien">
<div class="vw-overview-head" data-toggle="box-praemien">
<span class="vw-overview-title">🎁 Prämien-Warteschlange</span>
<div class="vw-overview-summary" id="praemien-summary"></div>
<span class="vw-overview-toggle">Anzeigen <span class="pfeil"></span></span>
</div>
<div class="vw-overview-body" id="praemien-body"></div>
</div>
<div class="card vw-card vw-shine-card vw-noise vw-overview-box" id="box-fortschritt">
<div class="vw-overview-head" data-toggle="box-fortschritt">
<span class="vw-overview-title">📈 Fortschritts-Übersicht</span>
<div class="vw-overview-summary" id="fortschritt-summary"></div>
<span class="vw-overview-toggle">Anzeigen <span class="pfeil"></span></span>
</div>
<div class="vw-overview-body" id="fortschritt-body"></div>
</div>
<div class="card vw-card vw-shine-card vw-noise vw-overview-box" id="box-zahlungen">
<div class="vw-overview-head" data-toggle="box-zahlungen">
<span class="vw-overview-title">💳 Zahlungen-Übersicht</span>
<div class="vw-overview-summary" id="zahlungen-summary"></div>
<span class="vw-overview-toggle">Anzeigen <span class="pfeil"></span></span>
</div>
<div class="vw-overview-body" id="zahlungen-body"></div>
</div>
<!-- Nutzer-Wunsch 04.08.2026 (8. Runde): "dieses Fenster soll nicht
einfach kacke weiß aussehen, soll der ganzen Seite angepasst
sein" — das aufgeklappte Dropdown eines nativen <select> wird
vom Betriebssystem/Browser gezeichnet, NICHT von unserem CSS
(deshalb war es immer weiß, egal wie das <select> selbst
gestylt war — eine bekannte, browserübergreifende Grenze von
<select>, keine Design-Lücke). Lösung: komplett selbstgebautes
Dropdown (Knopf + eigene Liste), im selben dunklen/babyblauen
Look wie der Rest der Seite. Ersetzt das vorherige <select
id="filter-select">; die komplette Filter-Logik läuft jetzt
über die Variable currentFilter statt select.value. -->
<div class="vw-toolbar vw-shine-card vw-noise">
<label id="filter-label">Filter</label>
<div class="vw-select-wrap" id="filter-dropdown">
<button type="button" class="vw-select-btn" id="filter-btn" aria-haspopup="listbox" aria-expanded="false">
<span id="filter-btn-label">Alle</span>
</button>
<ul class="vw-select-list" id="filter-list" role="listbox" aria-labelledby="filter-label" hidden>
<li role="option" data-value="alle" aria-selected="true" class="selected">Alle</li>
<li role="option" data-value="aktiv" aria-selected="false">Aktiv</li>
<li role="option" data-value="gekuendigt" aria-selected="false">Gekündigt</li>
<li role="option" data-value="zahlung_fehlgeschlagen" aria-selected="false">Zahlung fehlgeschlagen</li>
<li role="option" data-value="monat_6" aria-selected="false">Ab Monat 6</li>
<li role="option" data-value="monat_12" aria-selected="false">Ab Monat 12</li>
<li role="option" data-value="monat_18" aria-selected="false">Ab Monat 18</li>
<li role="option" data-value="monat_24" aria-selected="false">Ab Monat 24</li>
<li role="option" data-value="monat_30" aria-selected="false">Ab Monat 30</li>
</ul>
</div>
<div class="vw-search-wrap">
<input type="search" id="such-input" placeholder="Suche nach Name, E-Mail, TikTok …" />
</div>
<button class="vw-refresh-btn" id="refresh-btn" type="button"><span class="ic" aria-hidden="true">🔄</span> Aktualisieren</button>
<button class="vw-export-btn" id="export-btn" type="button"><span aria-hidden="true">⬇️</span> CSV-Export</button>
<span class="vw-anzahl" id="anzahl-text"></span>
</div>
<div class="card vw-card vw-table-wrap">
<table class="vw-table">
<thead>
<tr>
<th></th>
<th class="sortierbar" data-sort="display_name">Name <span class="pfeil"></span></th>
<th>TikTok</th>
<th>E-Mail</th>
<th>Status</th>
<th class="sortierbar" data-sort="total_paid_months">Monate gesamt <span class="pfeil"></span></th>
<th>Fortschritt</th>
<th class="sortierbar" data-sort="created_at">Seit <span class="pfeil"></span></th>
</tr>
</thead>
<tbody id="supporter-tbody"></tbody>
</table>
</div>
<p class="vw-fehler" id="liste-fehler"></p>
</div>
<!-- ===== Supporter-Detail ===== -->
<div class="container" id="detail-ansicht" style="display:none;">
<p class="vw-back" id="zurueck-link">← Zurück zur Liste</p>
<div class="card vw-card vw-shine-card vw-noise" style="margin-top:1rem;padding:1.6rem 1.6rem 1.8rem;">
<div class="vw-detail-head">
<div class="vw-avatar-lg" id="detail-avatar" aria-hidden="true"></div>
<div>
<h2 class="vw-detail-name" id="detail-name"></h2>
<div class="vw-chip-row" id="detail-chips"></div>
</div>
</div>
<div class="vw-detail-grid" id="detail-grid">
<div><div class="vw-detail-label">Status</div><div class="vw-detail-value" id="detail-status"></div></div>
<div><div class="vw-detail-label">Nächste Zahlung</div><div class="vw-detail-value" id="detail-naechste"></div></div>
<div><div class="vw-detail-label">Bezahlte Monate</div><div class="vw-detail-value" id="detail-monate"></div></div>
<div><div class="vw-detail-label">PayPal-Abo-ID</div><div class="vw-detail-value vw-mono" id="detail-paypal" style="font-size:.78rem;"></div></div>
</div>
</div>
<!-- ===== Fortschritts-Kiste: wo steht diese Person im 30-Monats-
Zyklus (Nutzer-Wunsch 04.08.2026: "eine Kiste wo zeigt, wo die
Leute mit den Abos dran sind"). Dieselbe Zyklus-/Meilenstein-
Logik wie im Supporter-Bereich selbst. ===== -->
<div class="card vw-card vw-shine-card vw-noise vw-progress-box" id="detail-progress-box">
<div class="vw-progress-head">
<h3 class="vw-subh" style="margin:0;">📈 Fortschritt im Prämien-Zyklus</h3>
<span class="vw-progress-zyklus" id="detail-zyklus-badge">Zyklus 1</span>
</div>
<div class="vw-progress-hero" id="detail-progress-hero">0 <small>Monate im aktuellen Zyklus</small></div>
<div class="vw-progress-bar-big"><div class="vw-progress-bar-big-fill" id="detail-progress-fill" style="width:0%"></div></div>
<p class="small" id="detail-progress-text" style="color:var(--text-muted);"></p>
<p class="vw-progress-celebrate" id="detail-progress-celebrate" style="display:none;"></p>
<div class="vw-progress-milestones" id="detail-progress-milestones"></div>
</div>
<div class="card vw-card vw-shine-card" style="margin-top:1rem;padding:1.6rem 1.6rem 1.8rem;">
<h3 class="vw-subh" style="margin-top:0;">🎁 Prämien</h3>
<div id="detail-premiums"></div>
<p class="vw-erfolg" id="premium-erfolg">Gespeichert.</p>
<p class="vw-fehler" id="premium-fehler"></p>
<h3 class="vw-subh">💳 Zahlungen (letzte 50)</h3>
<div id="detail-payments" class="vw-pay-liste"></div>
</div>
</div>
</section>
</main>
<div id="site-footer"></div>
<script src="assets/js/main.js"></script>
<script src="assets/js/admin-auth.js"></script>
<script>
(function () {
const API_BASE_URL = "https://dogfather-universe-postfach.dogfather1608.workers.dev";
const REDEEM_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 PREMIUM_ICONS = {
teddy_klein: "🧸", teddy_mittel: "🧸", teddy_gross: "🧸", tasse_karte: "☕", hoodie: "🎽",
};
const PREMIUM_TYPE_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", expired: "Beendet", none: "Kein Abo",
};
const PAYMENT_STATUS_LABELS = { completed: "Erfolgreich", failed: "Fehlgeschlagen", refunded: "Erstattet", reversed: "Storniert" };
// Kompletter, ungefilterter Supporter-Bestand — wird EINMAL geladen und
// danach für Filter/Suche/Sortierung komplett im Browser verarbeitet
// (kein Server-Roundtrip pro Tastenanschlag, fühlt sich sofort an).
let alleSupporter = [];
let sortierung = { feld: "created_at", richtung: "desc" };
function fmtDatum(iso) {
if (!iso) return "";
try { return new Date(iso).toLocaleDateString("de-DE"); } catch { return iso; }
}
function fehlerZeigen(el, msg) { el.textContent = msg; el.classList.add("zeige"); }
function fehlerVerstecken(el) { el.classList.remove("zeige"); }
function escapeHtml(text) {
return String(text ?? "").replace(/[&<>"']/g, (z) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[z]));
}
function initialen(name) {
const teile = String(name || "?").trim().split(/\s+/).filter(Boolean);
if (!teile.length) return "?";
return (teile[0][0] + (teile[1]?.[0] || "")).toUpperCase();
}
function gruss(name) {
// Bugfix 04.08.2026: war fest auf "Dogi" verdrahtet — seit VanVan einen
// eigenen Zugang zu dieser Seite hat (siehe 03 Verwaltungs-Zugang),
// muss die Begrüßung den tatsächlich eingeloggten Namen zeigen statt
// immer "Dogi" anzuzeigen, egal wer sich anmeldet.
// Ziel ist .textContent (kein innerHTML) -> kein escapeHtml nötig, das
// würde &/</> nur unnötig als Text-Entities anzeigen statt sie sicher
// zu neutralisieren (textContent tut das ohnehin automatisch).
const n = name || "Dogi";
const h = new Date().getHours();
if (h < 5) return `Noch wach, ${n}?`;
if (h < 11) return `Guten Morgen, ${n}.`;
if (h < 18) return `Willkommen zurück, ${n}.`;
if (h < 23) return `Guten Abend, ${n}.`;
return `Noch wach, ${n}?`;
}
async function pruefeSession() {
const session = window.AdminAuth.getSession();
if (!session) { zeigeGate(); return; }
document.getElementById("page-hero").style.display = "";
document.getElementById("hero-gruss").textContent = gruss(session.isOwner ? "Dogi" : session.name);
document.getElementById("gate-container").style.display = "none";
document.getElementById("session-bar").style.display = "";
document.getElementById("vw-stats-root").style.display = "";
document.getElementById("supp-root").style.display = "";
const roleBadge = document.getElementById("role-badge");
roleBadge.innerHTML = `<span class="krone">👑</span> ${session.isOwner ? "Owner" : (session.roleName || "Eingeloggt")}`;
await ladeAlleSupporter();
}
function zeigeGate() {
document.getElementById("page-hero").style.display = "none";
document.getElementById("gate-container").style.display = "";
document.getElementById("session-bar").style.display = "none";
document.getElementById("vw-stats-root").style.display = "none";
document.getElementById("supp-root").style.display = "none";
}
// Nutzer-Wunsch 04.08.2026 (6. Runde): zwei getrennte Kisten/Formulare
// (Dogi + VanVan) statt einem gemeinsamen Feld — beide rufen denselben
// Login-Endpunkt auf, die Fehlermeldung erscheint jeweils nur in der
// eigenen Kiste.
function bindeGateFormular(formId, inputId, fehlerId) {
document.getElementById(formId).addEventListener("submit", async (e) => {
e.preventDefault();
const fehlerEl = document.getElementById(fehlerId);
fehlerVerstecken(fehlerEl);
const code = document.getElementById(inputId).value.trim();
if (!code) return;
const res = await window.AdminAuth.doLogin(API_BASE_URL, code);
if (!res.ok) return fehlerZeigen(fehlerEl, res.error || "Zugangscode falsch.");
await pruefeSession();
});
}
bindeGateFormular("code-gate-dogi", "code-input-dogi", "gate-fehler-dogi");
bindeGateFormular("code-gate-vanvan", "code-input-vanvan", "gate-fehler-vanvan");
document.getElementById("logout-btn").addEventListener("click", async () => {
await window.AdminAuth.doLogout(API_BASE_URL);
zeigeGate();
});
function berechneStats(supporters) {
const gesamt = supporters.length;
const aktiv = supporters.filter((s) => s.sub_status === "active").length;
const umsatz = (aktiv * 4.99).toFixed(2).replace(".", ",");
const vor30Tagen = Date.now() - 30 * 86400000;
const neu = supporters.filter((s) => s.created_at && new Date(s.created_at).getTime() >= vor30Tagen).length;
document.getElementById("stat-gesamt").textContent = gesamt;
document.getElementById("stat-aktiv").textContent = aktiv;
document.getElementById("stat-umsatz").innerHTML = `${umsatz} € <small>geschätzt</small>`;
document.getElementById("stat-neu").textContent = neu;
}
// Zeigt pro Filter-Option direkt an, wie viele Supporter zutreffen
// ("Aktiv (3)" statt einfach nur "Aktiv") — man sieht auf einen Blick,
// was sich hinter jedem Filter verbirgt, ohne ihn erst anklicken zu müssen.
const FILTER_PRUEFUNG = {
alle: () => true,
aktiv: (r) => r.sub_status === "active",
gekuendigt: (r) => r.sub_status === "cancelled",
zahlung_fehlgeschlagen: (r) => r.sub_status === "suspended",
monat_6: (r) => (r.total_paid_months || 0) >= 6,
monat_12: (r) => (r.total_paid_months || 0) >= 12,
monat_18: (r) => (r.total_paid_months || 0) >= 18,
monat_24: (r) => (r.total_paid_months || 0) >= 24,
monat_30: (r) => (r.total_paid_months || 0) >= 30,
};
const FILTER_LABELS = {
alle: "Alle", aktiv: "Aktiv", gekuendigt: "Gekündigt",
zahlung_fehlgeschlagen: "Zahlung fehlgeschlagen",
monat_6: "Ab Monat 6", monat_12: "Ab Monat 12", monat_18: "Ab Monat 18",
monat_24: "Ab Monat 24", monat_30: "Ab Monat 30",
};
let currentFilter = "alle";
function aktualisiereFilterZahlen() {
document.querySelectorAll("#filter-list li").forEach((li) => {
const wert = li.dataset.value;
const pruef = FILTER_PRUEFUNG[wert];
const anzahl = pruef ? alleSupporter.filter(pruef).length : 0;
li.textContent = `${FILTER_LABELS[wert] || wert} (${anzahl})`;
if (wert === currentFilter) {
document.getElementById("filter-btn-label").textContent = li.textContent;
}
});
}
// Eigenes Dropdown (siehe HTML-Kommentar oben): Knopf öffnet/schließt
// die Liste, Klick auf einen Eintrag setzt currentFilter + rendert neu.
(function initFilterDropdown() {
const wrap = document.getElementById("filter-dropdown");
const btn = document.getElementById("filter-btn");
const list = document.getElementById("filter-list");
// "Portal"-Muster (siehe CSS-Kommentar bei .vw-select-list): die
// Liste wird beim allerersten Öffnen aus ihrer ursprünglichen Stelle
// im DOM herausgelöst und direkt an <body> gehängt — dadurch liegt
// sie außerhalb JEDER Karten-Verschachtelung/Stacking-Context-Kette
// und das Compositing-Problem kann gar nicht mehr auftreten. Position
// ist "absolute", also dokument-relativ (inkl. Scroll-Offset), nicht
// viewport-relativ wie bei "fixed" — deshalb + window.scrollX/Y.
let portalisiert = false;
function positioniere() {
if (!portalisiert) { document.body.appendChild(list); portalisiert = true; }
const rect = btn.getBoundingClientRect();
list.style.top = `${rect.bottom + window.scrollY + 8}px`;
list.style.left = `${rect.left + window.scrollX}px`;
list.style.minWidth = `${rect.width}px`;
}
function oeffnen() { positioniere(); wrap.classList.add("open"); list.hidden = false; btn.setAttribute("aria-expanded", "true"); }
function schliessen() { wrap.classList.remove("open"); list.hidden = true; btn.setAttribute("aria-expanded", "false"); }
btn.addEventListener("click", () => (list.hidden ? oeffnen() : schliessen()));
// Bei Scroll/Resize passt die Position nicht mehr zum Knopf — live
// neu berechnen statt schließen.
window.addEventListener("scroll", () => { if (!list.hidden) positioniere(); }, { passive: true, capture: true });
window.addEventListener("resize", () => { if (!list.hidden) positioniere(); });
list.addEventListener("click", (e) => {
const li = e.target.closest("li[data-value]");
if (!li) return;
currentFilter = li.dataset.value;
document.getElementById("filter-btn-label").textContent = li.textContent;
list.querySelectorAll("li").forEach((el) => {
const gewaehlt = el === li;
el.classList.toggle("selected", gewaehlt);
el.setAttribute("aria-selected", String(gewaehlt));
});
schliessen();
renderTabelle();
});
document.addEventListener("click", (e) => { if (!wrap.contains(e.target)) schliessen(); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") schliessen(); });
})();
function gefilterteSortierteListe() {
const filter = currentFilter;
const suchbegriff = document.getElementById("such-input").value.trim().toLowerCase();
let liste = alleSupporter.filter(FILTER_PRUEFUNG[filter] || FILTER_PRUEFUNG.alle);
if (suchbegriff) {
liste = liste.filter((s) =>
[s.display_name, s.email, s.tiktok_username].filter(Boolean).some((f) => f.toLowerCase().includes(suchbegriff))
);
}
const { feld, richtung } = sortierung;
liste = [...liste].sort((a, b) => {
let av = a[feld], bv = b[feld];
if (feld === "created_at") { av = av ? new Date(av).getTime() : 0; bv = bv ? new Date(bv).getTime() : 0; }
else if (feld === "total_paid_months") { av = av || 0; bv = bv || 0; }
else { av = String(av || "").toLowerCase(); bv = String(bv || "").toLowerCase(); }
if (av < bv) return richtung === "asc" ? -1 : 1;
if (av > bv) return richtung === "asc" ? 1 : -1;
return 0;
});
return liste;
}
async function ladeAlleSupporter() {
document.getElementById("detail-ansicht").style.display = "none";
document.getElementById("liste-ansicht").style.display = "";
const fehlerEl = document.getElementById("liste-fehler");
fehlerVerstecken(fehlerEl);
const refreshBtn = document.getElementById("refresh-btn");
refreshBtn.classList.add("lädt");
// Immer "alle" laden — Filtern/Suchen/Sortieren passiert danach
// komplett lokal, ohne erneuten Server-Roundtrip.
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/supporter/list", { filter: "alle" });
refreshBtn.classList.remove("lädt");
if (!res.ok) return fehlerZeigen(fehlerEl, res.error || "Konnte Liste nicht laden.");
alleSupporter = res.supporters;
document.getElementById("letzte-aktualisierung").textContent = new Date().toLocaleTimeString("de-DE");
berechneStats(alleSupporter);
aktualisiereFilterZahlen();
renderTabelle();
renderFortschrittUebersicht();
await ladeUebersicht();
}
// ---------- Feste Übersichts-Kisten (Nutzer-Wunsch 04.08.2026) ----------
let uebersichtDaten = { premiums: [], payments: [] };
document.querySelectorAll(".vw-overview-head").forEach((head) => {
head.addEventListener("click", () => {
const box = document.getElementById(head.dataset.toggle);
const offen = box.classList.toggle("offen");
head.querySelector(".vw-overview-toggle").firstChild.textContent = offen ? "Verbergen " : "Anzeigen ";
});
});
async function ladeUebersicht() {
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/overview", {});
if (!res.ok) return; // still zurückhaltend — die Hauptliste funktioniert auch ohne
uebersichtDaten = { premiums: res.premiums || [], payments: res.payments || [] };
renderPraemienUebersicht();
renderZahlungenUebersicht();
}
const PRAEMIEN_AKTIONSBEDUERFTIG = ["wird_geprueft", "wird_vorbereitet", "versandbereit", "problem", "ersatz_wird_vorbereitet"];
function bindePraemienSpeichernKnoepfe(container) {
container.querySelectorAll("[data-save]").forEach((btn) => {
btn.addEventListener("click", async () => {
const row = btn.closest("[data-premium-id]");
const redeemStatus = row.querySelector(".status-select").value;
const trackingNumber = row.querySelector(".tracking-input").value.trim();
btn.disabled = true;
const r = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/supporter/premium/update", {
premiumId: btn.dataset.save, redeemStatus, trackingNumber,
});
btn.disabled = false;
if (!r.ok) { alert(r.error || "Speichern fehlgeschlagen."); return; }
btn.textContent = "✓ Gespeichert";
setTimeout(() => ladeUebersicht(), 700);
});
});
}
function renderPraemienUebersicht() {
const alle = uebersichtDaten.premiums;
const zaehler = {};
alle.forEach((p) => { zaehler[p.redeem_status] = (zaehler[p.redeem_status] || 0) + 1; });
const chips = PRAEMIEN_AKTIONSBEDUERFTIG.filter((s) => zaehler[s]).map(
(s) => `<span class="vw-overview-chip ${s === "problem" ? "gefahr" : "warn"}">${zaehler[s]} × ${REDEEM_LABELS[s]}</span>`
);
chips.push(`<span class="vw-overview-chip">${alle.length} Prämien gesamt</span>`);
document.getElementById("praemien-summary").innerHTML = chips.join("");
const body = document.getElementById("praemien-body");
if (!alle.length) {
body.innerHTML = `<p class="vw-leer">Noch keine Prämien freigeschaltet — der erste Meilenstein ist nach 6 bezahlten Monaten erreicht.</p>`;
return;
}
body.innerHTML = alle
.map(
(p) => `<div class="vw-premium-card" data-premium-id="${p.id}">
<span class="vw-premium-icon" aria-hidden="true">${PREMIUM_ICONS[p.premium_type] || "🎁"}</span>
<div class="vw-premium-info">
<strong>${escapeHtml(p.display_name)}</strong> — ${PREMIUM_TYPE_LABELS[p.premium_type] || p.premium_type} (Monat ${p.milestone_month})
<div class="small" style="color:var(--text-muted);">${escapeHtml(p.email)}${p.tracking_number ? " · Sendungsnr. " + escapeHtml(p.tracking_number) : ""}</div>
</div>
<div class="vw-premium-row-fields">
<span class="vw-premium-status status-${p.redeem_status}">${REDEEM_LABELS[p.redeem_status] || p.redeem_status}</span>
<select class="status-select">
${Object.entries(REDEEM_LABELS).map(([val, label]) => `<option value="${val}" ${p.redeem_status === val ? "selected" : ""}>${label}</option>`).join("")}
</select>
<input type="text" class="tracking-input" placeholder="Sendungsnummer" value="${escapeHtml(p.tracking_number || "")}" />
<button type="button" class="btn btn-outline btn-sm" data-save="${p.id}">Speichern</button>
</div>
</div>`
)
.join("");
bindePraemienSpeichernKnoepfe(body);
}
const CYCLE_MEILENSTEINE = [6, 12, 18, 24, 30];
function naechsterMeilensteinMonat(monat) {
return CYCLE_MEILENSTEINE.find((m) => monat < m) ?? null;
}
function renderFortschrittUebersicht() {
const aktive = alleSupporter.filter((s) => s.sub_status === "active" && s.progress);
if (!aktive.length) {
document.getElementById("fortschritt-summary").innerHTML = `<span class="vw-overview-chip">Keine aktiven Abos</span>`;
document.getElementById("fortschritt-body").innerHTML = `<p class="vw-leer">Keine aktiven Abos vorhanden.</p>`;
return;
}
const avgMonat = Math.round(aktive.reduce((sum, s) => sum + s.progress.currentCycleMonth, 0) / aktive.length);
const balDran = aktive.filter((s) => {
const naechster = naechsterMeilensteinMonat(s.progress.currentCycleMonth);
return naechster !== null && naechster - s.progress.currentCycleMonth <= 2;
});
document.getElementById("fortschritt-summary").innerHTML = `
<span class="vw-overview-chip">Ø Monat ${avgMonat}/30</span>
${balDran.length ? `<span class="vw-overview-chip warn">${balDran.length} kurz vor Meilenstein</span>` : ""}
<span class="vw-overview-chip">${aktive.length} aktiv</span>`;
const sortiert = [...aktive].sort((a, b) => {
const na = naechsterMeilensteinMonat(a.progress.currentCycleMonth);
const nb = naechsterMeilensteinMonat(b.progress.currentCycleMonth);
const ra = na === null ? 999 : na - a.progress.currentCycleMonth;
const rb = nb === null ? 999 : nb - b.progress.currentCycleMonth;
return ra - rb;
});
const body = document.getElementById("fortschritt-body");
body.innerHTML = sortiert
.map((s) => {
const prozent = Math.round((s.progress.currentCycleMonth / 30) * 100);
const naechster = naechsterMeilensteinMonat(s.progress.currentCycleMonth);
const rest = naechster === null ? null : naechster - s.progress.currentCycleMonth;
return `<div class="vw-fortschritt-row" data-goto="${s.id}">
<span class="vw-avatar">${escapeHtml(initialen(s.display_name))}</span>
<span class="vw-name-cell" style="flex:0 1 160px;">${escapeHtml(s.display_name)}</span>
<div class="vw-mini-progress-wrap" style="flex:1 1 160px;">
<div class="vw-mini-progress"><div class="vw-mini-progress-fill" style="width:${prozent}%"></div></div>
<span class="vw-mini-progress-label">Zyklus ${s.progress.cycleNumber} · Monat ${s.progress.currentCycleMonth}/30</span>
</div>
<span class="small" style="color:var(--text-muted);white-space:nowrap;">${rest !== null ? `noch ${rest} Mon. bis Meilenstein` : "Zyklus komplett 🏆"}</span>
</div>`;
})
.join("");
body.querySelectorAll("[data-goto]").forEach((el) => el.addEventListener("click", () => zeigeDetail(el.dataset.goto)));
}
function renderZahlungenUebersicht() {
const alle = uebersichtDaten.payments;
const vor30Tagen = Date.now() - 30 * 86400000;
const letzte30 = alle.filter((p) => p.payment_status === "completed" && p.paid_at && new Date(p.paid_at).getTime() >= vor30Tagen);
const umsatz30 = letzte30.reduce((sum, p) => sum + (parseFloat(p.amount) || 0), 0);
const fehlgeschlagen = alle.filter((p) => p.payment_status === "failed").length;
document.getElementById("zahlungen-summary").innerHTML = `
<span class="vw-overview-chip">${umsatz30.toFixed(2).replace(".", ",")} € (30 Tage)</span>
${fehlgeschlagen ? `<span class="vw-overview-chip gefahr">${fehlgeschlagen} fehlgeschlagen</span>` : ""}
<span class="vw-overview-chip">${alle.length} Zahlungen gesamt</span>`;
const body = document.getElementById("zahlungen-body");
body.innerHTML = !alle.length
? `<p class="vw-leer">Noch keine Zahlungen.</p>`
: alle
.slice(0, 60)
.map((p) => {
const status = p.payment_status || "completed";
return `<div class="vw-pay-row">
<span class="vw-pay-datum">${fmtDatum(p.paid_at)} · ${escapeHtml(p.display_name)}</span>
<span class="vw-pay-betrag">${p.amount ? p.amount + " €" : ""}</span>
<span class="vw-pay-status status-${status}">${PAYMENT_STATUS_LABELS[status] || status}</span>
</div>`;
})
.join("");
}
function renderTabelle() {
const liste = gefilterteSortierteListe();
const tbody = document.getElementById("supporter-tbody");
document.getElementById("anzahl-text").textContent = `${liste.length} von ${alleSupporter.length} Supporter${alleSupporter.length === 1 ? "" : "n"}`;
document.querySelectorAll("#supporter-tbody").length; // no-op, hält Linter ruhig
document.querySelectorAll("th.sortierbar").forEach((th) => {
th.classList.toggle("aktiv-sort", th.dataset.sort === sortierung.feld);
th.querySelector(".pfeil").textContent = th.dataset.sort === sortierung.feld ? (sortierung.richtung === "asc" ? "↑" : "↓") : "↕";
});
if (!liste.length) {
const text = alleSupporter.length
? "Keine Treffer für diese Filter-/Suchkombination."
: "Noch keine Einträge — sobald sich jemand als Supporter registriert, taucht er hier auf.";
tbody.innerHTML = `<tr><td colspan="8"><p class="vw-leer">${text}</p></td></tr>`;
return;
}
tbody.innerHTML = liste
.map((s) => {
const status = s.sub_status || "none";
const p = s.progress || { cycleNumber: 1, currentCycleMonth: 0 };
const prozent = Math.round((p.currentCycleMonth / 30) * 100);
return `<tr class="klickbar" data-id="${s.id}">
<td><span class="vw-avatar">${escapeHtml(initialen(s.display_name))}</span></td>
<td><span class="vw-name-cell">${escapeHtml(s.display_name)}</span></td>
<td>${s.tiktok_username ? "@" + escapeHtml(s.tiktok_username) : ""}</td>
<td class="vw-mono">${escapeHtml(s.email)}</td>
<td><span class="vw-badge ${status}">${STATUS_LABELS[status] || status}</span></td>
<td>${s.total_paid_months ?? 0}</td>
<td>
<div class="vw-mini-progress-wrap">
<div class="vw-mini-progress"><div class="vw-mini-progress-fill" style="width:${prozent}%"></div></div>
<span class="vw-mini-progress-label">Zyklus ${p.cycleNumber} · Monat ${p.currentCycleMonth}/30</span>
</div>
</td>
<td>${fmtDatum(s.created_at)}</td>
</tr>`;
})
.join("");
tbody.querySelectorAll("tr[data-id]").forEach((tr) => {
tr.addEventListener("click", () => zeigeDetail(tr.dataset.id));
});
}
document.querySelectorAll("th.sortierbar").forEach((th) => {
th.addEventListener("click", () => {
const feld = th.dataset.sort;
if (sortierung.feld === feld) sortierung.richtung = sortierung.richtung === "asc" ? "desc" : "asc";
else sortierung = { feld, richtung: feld === "created_at" ? "desc" : "asc" };
renderTabelle();
});
});
// Der Filter selbst löst renderTabelle() jetzt direkt beim Klick im
// eigenen Dropdown aus (siehe initFilterDropdown oben) statt über ein
// "change"-Event auf einem <select>, das es nicht mehr gibt.
document.getElementById("such-input").addEventListener("input", renderTabelle);
document.getElementById("refresh-btn").addEventListener("click", ladeAlleSupporter);
// CSV-Export der aktuell sichtbaren (gefilterten/durchsuchten) Ansicht —
// z.B. für Buchhaltung/Übersicht außerhalb der Seite.
document.getElementById("export-btn").addEventListener("click", () => {
const liste = gefilterteSortierteListe();
const kopf = ["Name", "TikTok", "E-Mail", "Status", "Monate gesamt", "Zyklus", "Monat im Zyklus", "Seit"];
const zeilen = liste.map((s) => [
s.display_name || "", s.tiktok_username ? "@" + s.tiktok_username : "", s.email || "",
STATUS_LABELS[s.sub_status || "none"] || "", s.total_paid_months ?? 0,
s.progress?.cycleNumber ?? 1, s.progress?.currentCycleMonth ?? 0, fmtDatum(s.created_at),
]);
const csvZeile = (arr) => arr.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(";");
const csv = "" + [csvZeile(kopf), ...zeilen.map(csvZeile)].join("\r\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `dogicrew-supporter-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
});
function kopierenKnopf(text, id) {
return `<button type="button" class="vw-copy-btn" data-copy="${escapeHtml(text)}" id="${id}" title="Kopieren" aria-label="Kopieren">📋</button>`;
}
async function zeigeDetail(supporterId) {
document.getElementById("liste-ansicht").style.display = "none";
document.getElementById("detail-ansicht").style.display = "";
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/supporter/detail", { supporterId });
if (!res.ok) { alert(res.error || "Konnte Details nicht laden."); return; }
const { supporter, subscription, premiums, payments } = res;
document.getElementById("detail-avatar").textContent = initialen(supporter.display_name);
document.getElementById("detail-name").textContent = supporter.display_name;
const chips = [
`📧 ${escapeHtml(supporter.email)} ${kopierenKnopf(supporter.email, "copy-email")}`,
supporter.tiktok_username
? `🎵 @${escapeHtml(supporter.tiktok_username)} ${kopierenKnopf("@" + supporter.tiktok_username, "copy-tiktok")}`
: null,
`<span class="vw-chip ${supporter.email_verified ? "verifiziert" : "unverifiziert"}" style="border:none;background:none;padding:0;">${supporter.email_verified ? "✅ E-Mail bestätigt" : "⚠️ E-Mail nicht bestätigt"}</span>`,
`🗓️ Mitglied seit ${fmtDatum(supporter.created_at)}`,
supporter.last_login_at ? `🕓 Zuletzt aktiv ${fmtDatum(supporter.last_login_at)}` : null,
].filter(Boolean);
document.getElementById("detail-chips").innerHTML = chips
.map((c) => (c.startsWith("<span") ? c : `<span class="vw-chip">${c}</span>`))
.join("");
const status = subscription?.status || "none";
document.getElementById("detail-status").innerHTML = `<span class="vw-badge ${status}">${STATUS_LABELS[status] || status}</span>`;
document.getElementById("detail-naechste").textContent = fmtDatum(subscription?.next_payment_at);
document.getElementById("detail-monate").textContent = subscription?.total_paid_months ?? 0;
const paypalId = subscription?.paypal_subscription_id;
document.getElementById("detail-paypal").innerHTML = paypalId
? `${escapeHtml(paypalId)} ${kopierenKnopf(paypalId, "copy-paypal")}`
: "";
// Zusatzfelder je nach Situation: bei Kündigung "Zugang bis"/"Gekündigt
// am" ergänzen, statt sie zu verstecken (Nutzer-Wunsch: "nichts fehlt").
const grid = document.getElementById("detail-grid");
grid.querySelectorAll("[data-extra]").forEach((el) => el.remove());
const extras = [];
if (subscription?.cancelled_at) extras.push(["Gekündigt am", fmtDatum(subscription.cancelled_at)]);
if (subscription?.paid_period_end_at) extras.push(["Zugang bis", fmtDatum(subscription.paid_period_end_at)]);
if (subscription?.started_at) extras.push(["Abo läuft seit", fmtDatum(subscription.started_at)]);
extras.forEach(([label, value]) => {
const div = document.createElement("div");
div.dataset.extra = "1";
div.innerHTML = `<div class="vw-detail-label">${escapeHtml(label)}</div><div class="vw-detail-value">${escapeHtml(value)}</div>`;
grid.appendChild(div);
});
// Fortschritts-Kiste: wo steht dieser Supporter im 30-Monats-Zyklus
// (Nutzer-Wunsch 04.08.2026). progress/timeline kommen direkt vom
// Server (lib/supporter-cycle.js — dieselbe Rechenlogik wie im
// öffentlichen Supporter-Bereich, damit nichts auseinanderläuft).
const progressBox = document.getElementById("detail-progress-box");
if (subscription && res.progress && res.timeline) {
progressBox.style.display = "";
const { progress, timeline } = res;
const prozent = Math.round((timeline.currentCycleMonth / 30) * 100);
document.getElementById("detail-zyklus-badge").textContent = `Zyklus ${progress.cycleNumber}`;
document.getElementById("detail-progress-hero").innerHTML =
`${timeline.currentCycleMonth} <small>von 30 Monaten im aktuellen Zyklus</small>`;
document.getElementById("detail-progress-fill").style.width = `${prozent}%`;
document.getElementById("detail-progress-text").textContent =
`${progress.totalPaidMonths} Monate insgesamt bezahlt · ${prozent}% des aktuellen Zyklus geschafft`;
const celebrateEl = document.getElementById("detail-progress-celebrate");
if (progress.completedCycles > 0) {
celebrateEl.style.display = "";
celebrateEl.textContent = `🏆 Hat bereits ${progress.completedCycles} komplette Kollektion${progress.completedCycles > 1 ? "en" : ""} durchlaufen.`;
} else {
celebrateEl.style.display = "none";
}
document.getElementById("detail-progress-milestones").innerHTML = timeline.milestones
.map((m) => {
const klasse = m.reached ? "erreicht" : m.isNext ? "naechste" : "";
const statusText = m.reached ? "Freigeschaltet" : m.isNext ? `Noch ${m.monthsRemaining} Monate` : "Gesperrt";
return `<div class="vw-progress-milestone ${klasse}">
<div class="med" aria-hidden="true">${m.reached ? "🎁" : m.isNext ? "✨" : "🔒"}</div>
<div class="m-monat">Monat ${m.month}</div>
<div class="m-titel">${PREMIUM_TYPE_LABELS[m.type]}</div>
<div class="m-status">${statusText}</div>
</div>`;
})
.join("");
} else {
progressBox.style.display = "none";
}
document.querySelectorAll("[data-copy]").forEach((btn) => {
btn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(btn.dataset.copy);
btn.textContent = "✓"; btn.classList.add("kopiert");
setTimeout(() => { btn.textContent = "📋"; btn.classList.remove("kopiert"); }, 1400);
} catch { /* Clipboard evtl. nicht erlaubt — kein Beinbruch */ }
});
});
const premEl = document.getElementById("detail-premiums");
if (!premiums.length) {
premEl.innerHTML = `<p class="vw-leer">Noch keine Prämien freigeschaltet.</p>`;
} else {
premEl.innerHTML = premiums
.map(
(p) => `<div class="vw-premium-card" data-premium-id="${p.id}">
<span class="vw-premium-icon" aria-hidden="true">${PREMIUM_ICONS[p.premium_type] || "🎁"}</span>
<div class="vw-premium-info">
<strong>${escapeHtml(p.collection_name)}</strong> — Monat ${p.milestone_month}
<div class="small" style="color:var(--text-muted);">${p.tracking_number ? "Sendungsnr. " + escapeHtml(p.tracking_number) : ""}</div>
</div>
<div class="vw-premium-row-fields">
<select class="status-select">
${Object.entries(REDEEM_LABELS).map(([val, label]) => `<option value="${val}" ${p.redeem_status === val ? "selected" : ""}>${label}</option>`).join("")}
</select>
<input type="text" class="tracking-input" placeholder="Sendungsnummer" value="${escapeHtml(p.tracking_number || "")}" />
<button type="button" class="btn btn-outline btn-sm" data-save="${p.id}">Speichern</button>
</div>
</div>`
)
.join("");
premEl.querySelectorAll("[data-save]").forEach((btn) => {
btn.addEventListener("click", async () => {
const row = btn.closest("[data-premium-id]");
const redeemStatus = row.querySelector(".status-select").value;
const trackingNumber = row.querySelector(".tracking-input").value.trim();
const erfolgEl = document.getElementById("premium-erfolg");
const fehlerEl = document.getElementById("premium-fehler");
fehlerVerstecken(fehlerEl); fehlerVerstecken(erfolgEl);
const r = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/supporter/premium/update", {
premiumId: btn.dataset.save, redeemStatus, trackingNumber,
});
if (!r.ok) return fehlerZeigen(fehlerEl, r.error || "Speichern fehlgeschlagen.");
fehlerZeigen(erfolgEl, "Gespeichert.");
erfolgEl.classList.add("zeige");
});
});
}
const payEl = document.getElementById("detail-payments");
payEl.innerHTML = !payments.length
? `<p class="vw-leer">Noch keine Zahlungen.</p>`
: payments
.map((p) => {
const pStatus = p.payment_status || "completed";
return `<div class="vw-pay-row">
<span class="vw-pay-datum">${fmtDatum(p.paid_at)}</span>
<span class="vw-pay-betrag">${p.amount ? p.amount + " €" : ""}</span>
<span class="vw-pay-status status-${pStatus}">${PAYMENT_STATUS_LABELS[pStatus] || pStatus}</span>
</div>`;
})
.join("");
}
document.getElementById("zurueck-link").addEventListener("click", () => {
document.getElementById("detail-ansicht").style.display = "none";
document.getElementById("liste-ansicht").style.display = "";
});
// Nutzer-Wunsch 04.08.2026 (aktualisiert): der Zugangscode soll nur
// EINMAL PRO TAG eingegeben werden müssen, nicht bei jedem Besuch (das
// war die ursprüngliche, jetzt bewusst gelockerte Regel). admin-auth.js
// speichert die Sitzung dafür seit heute in localStorage mit eigenem
// 24-Std-Ablauf, der Server erzwingt zusätzlich dasselbe Zeitlimit
// (lib/auth.js) — läuft die Sitzung ab, verlangt pruefeSession() ganz
// automatisch wieder den Gate-Bildschirm.
pruefeSession();
})();
</script>
</body>
</html>