API_BASE_URL/API_BASE zeigen jetzt auf https://postfach.dogfather-universe.com (eigener Server-internal-Dienst) statt auf den Cloudflare Worker dogfather-universe-postfach.dogfather1608.workers.dev.
1524 lines
88 KiB
HTML
1524 lines
88 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" />
|
||
<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://postfach.dogfather-universe.com";
|
||
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>
|