Arbeit der parallelen Session in git gesichert (war nur auf dem Server)
Diese Änderungen liefen bereits live auf dem Server, lagen dort aber ausschließlich als nicht eingecheckte Arbeitskopie — bei jedem Deploy (stash/pull/pop) und bei jedem Serverproblem wären sie verloren gewesen. Deshalb hier unverändert in git übernommen, bevor darauf aufgebaut wird. Enthalten (nicht von mir gebaut, nur gesichert): - Supporter: eigener fester Zugangscode statt Einmalcode-Login (Migration 0009, lib/crypto.js scrypt-Hash, routes/supporter.js, abonnieren.html, supporter.html, i18n-abonnieren/-supporter) - Stimmen: Profilbilder (Migration 0010, routes/testimonials.js) - Event-Bild-Upload: voller Pfad statt relativem (routes/events.js) - Neue/überarbeitete Hintergrundbilder für viele Seiten - Teilen-Funktion (streamplan.js, i18n-index/-streamplan, main.css) Co-Authored-By: Claude Opus 5 <[email protected]>
@@ -216,6 +216,104 @@
|
||||
.abo-switch a { color: var(--accent); cursor: pointer; font-weight: 600; }
|
||||
.abo-switch a:hover { text-decoration: underline; }
|
||||
|
||||
/* Registrieren/Einloggen-Umschalter (21.08.2026, Nutzer-Wunsch: "die
|
||||
sollen dann sofort immer den anmelde knopf auswählen können") — zwei
|
||||
Segmente in einer Pille, aktiver Tab in Gold hervorgehoben, damit die
|
||||
Wahl sofort ins Auge fällt, noch bevor überhaupt ein Formularfeld zu
|
||||
sehen ist. Augenschonend: kein grelles Weiß, ruhiger Farbwechsel. */
|
||||
/* =====================================================================
|
||||
Aktionsleiste (21.08.2026) — EIN Element für beide Wege, statt vorher
|
||||
doppeltem "Registrieren" (Umschalter oben + Knopf unten). Links die
|
||||
eigentliche Aktion in Gold (unverändert im bestehenden Marken-Look),
|
||||
rechts der Wechsel in Babyblau — der zweiten Hausfarbe der Seite
|
||||
(theme-dogfather.css). Beide Hälften nutzen dieselbe Technik wie die
|
||||
bestehenden Gold-Knöpfe (wandernder Verlauf per background-position +
|
||||
ein durchlaufender Glanzstreifen als ::after) — dadurch wirken sie wie
|
||||
ein zusammengehöriges Paar statt wie zwei fremde Knöpfe.
|
||||
Augenschonend (Projektregel): kein Blinken/Flackern, nur ruhig
|
||||
wandernde Verläufe, und bei prefers-reduced-motion steht alles still.
|
||||
===================================================================== */
|
||||
.abo-aktionsleiste {
|
||||
display: flex; gap: .6rem; margin-top: .4rem;
|
||||
}
|
||||
/* flex-basis "auto" statt "0": jede Hälfte startet bei ihrer eigenen
|
||||
Textbreite und wächst dann anteilig mit. Dadurch bekommt automatisch
|
||||
die Seite mit dem längeren Text mehr Platz — egal ob das gerade links
|
||||
("Login-Code anfordern") oder rechts ("Schon registriert? Einloggen")
|
||||
ist. Mit fester 0-Basis wären beide exakt gleich breit gewesen und der
|
||||
jeweils längere Text wäre auf zwei Zeilen umgebrochen. */
|
||||
.abo-aktionsleiste > button {
|
||||
position: relative; isolation: isolate; overflow: hidden;
|
||||
flex: 1 1 auto; min-width: 0;
|
||||
display: flex; align-items: center; justify-content: center; text-align: center;
|
||||
padding: 1rem .95rem; border-radius: 14px;
|
||||
font-weight: 800; font-size: .92rem; line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: transform .2s ease, box-shadow .2s ease;
|
||||
}
|
||||
.abo-aktionsleiste > button::after {
|
||||
content: ""; position: absolute; inset: 0; z-index: 1;
|
||||
background: linear-gradient(100deg, transparent 30%, rgba(255,255,255,.85) 48%, transparent 66%);
|
||||
background-size: 240% 100%;
|
||||
mix-blend-mode: overlay;
|
||||
animation: abo-btn-glare 3.2s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
.abo-aktionsleiste > button:hover { transform: translateY(-3px) scale(1.015); }
|
||||
|
||||
/* Links: Gold — exakt die bestehende Knopf-Optik der Seite. */
|
||||
.abo-aktion-haupt {
|
||||
color: #1a1408;
|
||||
border: 1.5px solid rgba(255,233,168,.65);
|
||||
background: linear-gradient(120deg, #ffe9a8 0%, #d4af37 30%, #ffe9a8 55%, #fff2c9 78%, #d4af37 100%);
|
||||
background-size: 280% 100%;
|
||||
animation: abo-btn-shine 6s ease-in-out infinite;
|
||||
box-shadow: 0 10px 26px -12px rgba(212,175,55,.55);
|
||||
}
|
||||
.abo-aktion-haupt:hover {
|
||||
box-shadow: 0 16px 34px -10px rgba(212,175,55,.65), 0 0 26px rgba(212,175,55,.4);
|
||||
}
|
||||
|
||||
/* Babyblau — gleiche Machart, kräftigeres Sky-Blau statt des sehr
|
||||
blassen Standard-Babyblaus, damit es neben dem Gold nicht untergeht
|
||||
(dieselbe Lektion wie auf der Verwaltungsseite). Dunkler Tiefsee-Text
|
||||
darauf für klaren Kontrast; zusätzlich ein sanft atmender Ring, der die
|
||||
Kachel leicht leuchten lässt, ohne zu blenden.
|
||||
|
||||
Zwei Namen, eine Optik: ".abo-aktion-neben" ist der Wechsel-Knopf neben
|
||||
einer Gold-Aktion (Registrierungs-Seite), ".abo-aktion-blau" ist ein
|
||||
Knopf, der selbst DIE Hauptaktion ist und trotzdem blau sein soll
|
||||
(Einloggen-Seite, Nutzer-Wunsch 21.08.2026). Bewusst getrennte Namen,
|
||||
damit später klar bleibt, warum ein Knopf blau ist. */
|
||||
.abo-aktion-neben,
|
||||
.abo-aktion-blau {
|
||||
color: #05202b;
|
||||
border: 1.5px solid rgba(224,247,254,.7);
|
||||
background: linear-gradient(120deg, #e0f7fe 0%, #38bdf8 30%, #7dd3fc 55%, #f0fbff 78%, #38bdf8 100%);
|
||||
background-size: 280% 100%;
|
||||
animation: abo-btn-shine 6s ease-in-out infinite, abo-blau-atem 4.5s ease-in-out infinite;
|
||||
box-shadow: 0 10px 26px -12px rgba(56,189,248,.6), 0 0 0 0 rgba(125,211,252,.5);
|
||||
}
|
||||
.abo-aktion-neben:hover,
|
||||
.abo-aktion-blau:hover {
|
||||
box-shadow: 0 16px 34px -10px rgba(56,189,248,.75), 0 0 30px rgba(125,211,252,.55);
|
||||
}
|
||||
@keyframes abo-blau-atem {
|
||||
0%, 100% { box-shadow: 0 10px 26px -12px rgba(56,189,248,.55), 0 0 14px rgba(125,211,252,.25); }
|
||||
50% { box-shadow: 0 12px 30px -12px rgba(56,189,248,.75), 0 0 26px rgba(125,211,252,.5); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.abo-aktionsleiste > button,
|
||||
.abo-aktionsleiste > button::after { animation: none; }
|
||||
}
|
||||
/* Auf schmalen Handys untereinander statt gequetscht nebeneinander. */
|
||||
@media (max-width: 430px) {
|
||||
.abo-aktionsleiste { flex-direction: column; gap: .55rem; }
|
||||
.abo-aktionsleiste > button { font-size: .92rem; }
|
||||
}
|
||||
|
||||
.abo-oder {
|
||||
display: flex; align-items: center; gap: .8rem;
|
||||
color: var(--text-muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em;
|
||||
@@ -321,62 +419,88 @@
|
||||
korrekt "nicht bedienbar" meldet. -->
|
||||
<div class="abo-field">
|
||||
<label for="reg-name" data-i18n="ab_feld_name">Name oder Anzeigename</label>
|
||||
<input type="text" id="reg-name" maxlength="60" disabled />
|
||||
<input type="text" id="reg-name" maxlength="60" />
|
||||
</div>
|
||||
<div class="abo-field">
|
||||
<label for="reg-tiktok" data-i18n="ab_feld_tiktok">TikTok-Benutzername</label>
|
||||
<input type="text" id="reg-tiktok" placeholder="@benutzername" maxlength="40" disabled />
|
||||
<input type="text" id="reg-tiktok" placeholder="@benutzername" maxlength="40" />
|
||||
</div>
|
||||
<div class="abo-field">
|
||||
<label for="reg-email" data-i18n="ab_feld_email">E-Mail-Adresse</label>
|
||||
<input type="email" id="reg-email" maxlength="120" disabled />
|
||||
<input type="email" id="reg-email" maxlength="120" />
|
||||
</div>
|
||||
<!-- Eigener, dauerhafter Zugangscode (21.08.2026, Nutzer-Wunsch:
|
||||
"die leute sollen nach der registrierung einen festen code
|
||||
erstellen können für sich selbst und fertig, mit dem sie rein
|
||||
kommen"). type="password" — der Code soll beim Tippen nicht
|
||||
für Umstehende lesbar sein. -->
|
||||
<!-- Beschriftung geschärft 21.08.2026 (Nutzer-Rückmeldung: "auf
|
||||
dieser seite brauch man doch keine zugangsdaten, da kann man
|
||||
sich ja noch nicht einloggen"). Genau dieser Eindruck entstand,
|
||||
weil "Dein Zugangscode" wie die Abfrage eines BESTEHENDEN Codes
|
||||
klang. Jetzt steht als Aufforderung im Text, dass man ihn sich
|
||||
hier gerade ausdenkt — plus ein Platzhalter im Feld selbst, der
|
||||
das nochmal zeigt, falls jemand nur auf die Felder schaut. -->
|
||||
<div class="abo-field">
|
||||
<label for="reg-code" data-i18n="ab_feld_code_neu">Denk dir jetzt deinen Zugangscode aus (mindestens 6 Zeichen)</label>
|
||||
<input type="password" id="reg-code" maxlength="200" autocomplete="new-password"
|
||||
placeholder="Frei wählbar — z. B. DogiCrew2026"
|
||||
data-i18n-attr="placeholder:ab_feld_code_platzhalter" />
|
||||
<p class="small muted" style="margin:.45rem 0 0;" data-i18n="ab_code_hinweis_neu">Mit diesem Code loggst du dich künftig ein — merk ihn dir gut.</p>
|
||||
</div>
|
||||
<!-- Nutzer-Wunsch 21.08.2026: "die leute sollen sich auch schon
|
||||
registrieren können, aber der abo soll noch zu sein, da soll
|
||||
dann coming soon stehen." Registrierung/Login sind ab jetzt
|
||||
offen -- Konten können angelegt werden. Gesperrt bleibt NUR
|
||||
noch der eigentliche Zahlungsschritt danach (siehe
|
||||
panel-subscribe weiter unten), weil ohne Dogis echte PayPal-
|
||||
Business-Zugangsdaten niemand wirklich zahlen kann. Wieder
|
||||
normaler .btn-primary-Stil statt .btn-gold-locked. -->
|
||||
<!-- Aktionsleiste (21.08.2026, Nutzer-Wunsch: "bringt nichts 2 mal
|
||||
registrieren da zu haben"). EIN Element statt vorher Umschalter
|
||||
oben + Knopf unten: links die eigentliche Aktion in Gold,
|
||||
rechts der Wechsel zum Einloggen in Babyblau. Beide Hälften
|
||||
sind immer aktiv (kein Tab-Zustand) — links macht etwas,
|
||||
rechts wechselt die Ansicht. -->
|
||||
<div class="abo-aktionsleiste">
|
||||
<button type="button" class="abo-aktion-haupt" id="btn-register" data-i18n="ab_btn_register">Registrieren</button>
|
||||
<button type="button" class="abo-aktion-neben" id="tab-login" data-i18n="ab_tab_login">Schon registriert? Einloggen</button>
|
||||
</div>
|
||||
<!-- Bugfix/Nutzer-Wunsch 20.08.2026: Die DogiCrew-Registrierung ist
|
||||
technisch fertig, aber ohne Dogis echte PayPal-Business-Zugangsdaten
|
||||
kann niemand wirklich zahlen (siehe DogFather Website - Offene
|
||||
Punkte.md). Bis die stehen: Knopf bleibt sichtbar (Teaser, "soll
|
||||
alles bleiben"), aber NICHT bedienbar — natives disabled-Attribut
|
||||
(nicht nur optisch/pointer-events, damit es auch für Tastatur/
|
||||
Screenreader korrekt gesperrt ist). main.js hängt weiterhin
|
||||
unverändert einen Klick-Listener an dieselbe ID — disabled-Buttons
|
||||
feuern in allen Browsern keine Click-Events, daher hier bewusst
|
||||
KEINE JS-Änderung nötig. Eigener Gold/Schloss-Stil (.btn-gold-locked,
|
||||
siehe main.css) statt des generischen .btn-silver, der auf dieser
|
||||
Seite schon für die Teddy-Kollektion vergeben ist. -->
|
||||
<button type="button" class="btn-gold-locked" style="width:100%;margin-top:.2rem;" id="btn-register"
|
||||
disabled aria-disabled="true">
|
||||
<span class="btn-gold-locked-lock" aria-hidden="true">🔒</span>
|
||||
<span class="btn-gold-locked-text" data-i18n="ab_kollektion_coming_soon">Coming soon…</span>
|
||||
</button>
|
||||
<p class="abo-fehler" id="register-fehler"></p>
|
||||
<p class="abo-switch"><a id="switch-to-login" data-i18n="ab_switch_login">Schon Supporter? Hier einloggen</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Zustand 3: Login (E-Mail-Code anfordern) -->
|
||||
<!-- Bugfix/Nutzer-Wunsch 20.08.2026: War die einzige noch offene Lücke, nachdem
|
||||
der Registrieren-Knopf gesperrt wurde — über "Schon Supporter? Hier
|
||||
einloggen" kam man hierher und konnte trotzdem einen echten Login-Code
|
||||
anfordern, obwohl die DogiCrew noch gar nicht live ist. Jetzt genauso
|
||||
gesperrt: E-Mail-Feld disabled (nicht eintippbar, wie gewünscht), Knopf
|
||||
derselbe .btn-gold-locked-Stil wie beim Registrieren-Knopf. Betrifft NUR
|
||||
diesen öffentlichen Anmelde-Einstieg — bereits eingeloggte Supporter
|
||||
(Dogi/VanVan als Test-Accounts) merken nichts davon, ihr Zugang läuft
|
||||
weiterhin über den gespeicherten Token/supporter.html. -->
|
||||
<!-- Zustand 3: Einloggen mit E-Mail + eigenem Zugangscode.
|
||||
Umgestellt 21.08.2026 (Nutzer-Wunsch: "wieso sollte ich wieder
|
||||
ein code bekommen, mach die anmeldung einfach"): vorher wurde
|
||||
auch hier bei JEDEM Login erst ein Einmalcode per E-Mail
|
||||
verschickt — für längst registrierte Leute ein völlig
|
||||
unnötiger Umweg. Der E-Mail-Weg existiert weiterhin, aber nur
|
||||
noch als "Code vergessen?" (siehe Link unten). -->
|
||||
<div class="abo-panel" id="panel-login" hidden>
|
||||
<div class="abo-field">
|
||||
<label for="login-email" data-i18n="ab_feld_email">E-Mail-Adresse</label>
|
||||
<input type="email" id="login-email" maxlength="120" disabled />
|
||||
<input type="email" id="login-email" maxlength="120" autocomplete="email" />
|
||||
</div>
|
||||
<div class="abo-field">
|
||||
<label for="login-code" data-i18n="ab_feld_code_eigen">Dein Zugangscode</label>
|
||||
<input type="password" id="login-code" maxlength="200" autocomplete="current-password" />
|
||||
</div>
|
||||
<!-- Nutzer-Wunsch 21.08.2026: "da soll nur noch einloggen sein und
|
||||
der knopf soll auch blau sein, der neu registriert soll weg."
|
||||
Hier steht deshalb nur EIN Knopf, in Babyblau statt Gold —
|
||||
wer auf dieser Ansicht gelandet ist, will einloggen; der Weg
|
||||
zurück zur Registrierung führt über den Zurück-Knopf des
|
||||
Browsers bzw. den blauen Knopf auf der Registrierungs-Seite. -->
|
||||
<div class="abo-aktionsleiste">
|
||||
<button type="button" class="abo-aktion-blau" id="btn-login" data-i18n="ab_btn_login">Einloggen</button>
|
||||
</div>
|
||||
<button type="button" class="btn-gold-locked" style="width:100%;margin-top:.2rem;" id="btn-login-request"
|
||||
disabled aria-disabled="true">
|
||||
<span class="btn-gold-locked-lock" aria-hidden="true">🔒</span>
|
||||
<span class="btn-gold-locked-text" data-i18n="ab_kollektion_coming_soon">Coming soon…</span>
|
||||
</button>
|
||||
<p class="abo-fehler" id="login-fehler"></p>
|
||||
<p class="abo-switch"><a id="switch-to-register" data-i18n="ab_switch_register">Noch kein Konto? Jetzt registrieren</a></p>
|
||||
<p class="abo-switch"><a id="code-vergessen" data-i18n="ab_code_vergessen">Code vergessen?</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Zustand 4: Code-Eingabe (nach Registrierung ODER Login-Anfrage) -->
|
||||
<!-- Zustand 4: Einmalcode aus der E-Mail eingeben. Kommt seit
|
||||
21.08.2026 NUR noch im "Code vergessen?"-Weg vor — die normale
|
||||
Registrierung/Anmeldung braucht ihn nicht mehr. -->
|
||||
<div class="abo-panel" id="panel-code" hidden>
|
||||
<p class="small" id="code-hinweis" data-i18n="ab_code_hinweis">Wir haben dir einen 6-stelligen Code per E-Mail geschickt.</p>
|
||||
<div class="abo-field">
|
||||
@@ -387,6 +511,20 @@
|
||||
<p class="abo-fehler" id="code-fehler"></p>
|
||||
</div>
|
||||
|
||||
<!-- Zustand 4b: neuen dauerhaften Zugangscode festlegen. Letzter
|
||||
Schritt des "Code vergessen?"-Weges — an dieser Stelle ist man
|
||||
durch den Einmalcode bereits eingeloggt, es fehlt nur noch der
|
||||
neue Code für künftige Anmeldungen. -->
|
||||
<div class="abo-panel" id="panel-neuer-code" hidden>
|
||||
<p class="small" data-i18n="ab_neuer_code_hinweis">Fast fertig — leg jetzt deinen neuen Zugangscode fest.</p>
|
||||
<div class="abo-field">
|
||||
<label for="neuer-code" data-i18n="ab_feld_code_neu">Dein Zugangscode (mindestens 6 Zeichen)</label>
|
||||
<input type="password" id="neuer-code" maxlength="200" autocomplete="new-password" />
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" style="width:100%;" id="btn-neuer-code" data-i18n="ab_btn_code_speichern">Zugangscode speichern</button>
|
||||
<p class="abo-fehler" id="neuer-code-fehler"></p>
|
||||
</div>
|
||||
|
||||
<!-- Zustand 5: eingeloggt, noch kein Abo -> PayPal Smart Buttons.
|
||||
Zeigt automatisch alle für die Käuferin/den Käufer verfügbaren
|
||||
Zahlarten (PayPal-Konto, Kreditkarte als Gast, je nach Land) —
|
||||
@@ -394,24 +532,45 @@
|
||||
<div class="abo-panel" id="panel-subscribe" hidden>
|
||||
<p class="abo-erfolg zeige" data-i18n="ab_eingeloggt_als">Eingeloggt.</p>
|
||||
|
||||
<!-- Button-Lösung (§ 312j BGB / entspr. Regelung): Preis- und
|
||||
Zahlungspflicht-Hinweis unmittelbar vor der Zahlung. -->
|
||||
<p class="small" style="text-align:center;font-weight:700;margin-bottom:.8rem;">4,99 € / Monat · zahlungspflichtiges Abo · monatlich kündbar</p>
|
||||
<!-- Nutzer-Wunsch 21.08.2026: "die leute sollen sich schon registrieren
|
||||
können, aber der abo soll noch zu sein, da soll dann coming soon
|
||||
stehen." Konto ist angelegt/Login hat geklappt -- aber die eigent-
|
||||
liche Zahlung (PayPal) bleibt gesperrt, bis Dogis echte PayPal-
|
||||
Business-Zugangsdaten stehen. Die echten Zahlungs-Elemente (Preis-
|
||||
Hinweis, Widerrufs-Checkbox, PayPal-Container) bleiben unten UNVER-
|
||||
ÄNDERT im DOM (nur per hidden versteckt), damit das Skript weiter
|
||||
unten (Checkbox-Listener, ladePaypalButtons() usw.) unangetastet
|
||||
funktioniert -- einfach das hidden am Wrapper unten entfernen,
|
||||
sobald PayPal bereitsteht. -->
|
||||
<div class="btn-gold-locked" style="width:100%;margin-top:.4rem;cursor:default;">
|
||||
<span class="btn-gold-locked-lock" aria-hidden="true">🔒</span>
|
||||
<span class="btn-gold-locked-text" data-i18n="ab_kollektion_coming_soon">Coming soon…</span>
|
||||
</div>
|
||||
<p class="small muted" style="text-align:center;margin-top:.9rem;" data-i18n="ab_subscribe_locked_hinweis">Dein Konto ist bereit — die Bezahlung startet, sobald das Abo offiziell live geht. Schau einfach bald wieder vorbei.</p>
|
||||
|
||||
<!-- Widerrufsrecht bei digitalen Inhalten (§ 356 Abs. 5 BGB /
|
||||
entspr. Regelung, siehe agb.html Ziffer 6): ohne aktives
|
||||
Ankreuzen bleibt der Zahlungsbereich gesperrt, es gibt keine
|
||||
Zahlung ohne diese ausdrückliche, nicht vorausgewählte
|
||||
Zustimmung. -->
|
||||
<label class="abo-widerruf-check" for="widerruf-consent">
|
||||
<input type="checkbox" id="widerruf-consent" />
|
||||
<span>Ich wünsche ausdrücklich, dass der Zugang zum Supporter-Bereich sofort mit Vertragsschluss beginnt, und weiß, dass mein 14-tägiges Widerrufsrecht dadurch erlischt, sobald der Zugang vollständig bereitgestellt ist (Details in den <a href="agb.html" target="_blank" rel="noopener">AGB</a>, Ziffer 6).</span>
|
||||
</label>
|
||||
<!-- Nutzer-Wunsch 21.08.2026: "ich will dass jetzt alles verbunden
|
||||
ist so dass man mit den klicks pro klick weiter kommt" — vorher
|
||||
war hier eine Sackgasse (kein Link/Knopf mehr nach dem Login).
|
||||
Jetzt zwei klare Wege weiter: zurück zur Startseite, oder
|
||||
direkt zum Streamplan (naheliegendster nächster Klick für
|
||||
jemanden, der sich gerade für Dogi interessiert). -->
|
||||
<div class="btn-row" style="justify-content:center;margin-top:1.4rem;">
|
||||
<a class="btn btn-outline" href="index.html" data-i18n="ab_weiter_startseite">Zur Startseite →</a>
|
||||
<a class="btn btn-outline" href="streamplan.html" data-i18n="ab_weiter_streamplan">Zum Streamplan →</a>
|
||||
</div>
|
||||
|
||||
<div id="paypal-button-container" class="paypal-gesperrt"></div>
|
||||
<p class="small muted" id="paypal-gesperrt-hinweis">Bitte bestätige oben die Checkbox, um die Zahlungsoptionen freizuschalten.</p>
|
||||
<p class="small" id="paypal-laedt" data-i18n="ab_paypal_laedt" hidden>Zahlungsoptionen werden geladen …</p>
|
||||
<p class="abo-fehler" id="subscribe-fehler"></p>
|
||||
<div hidden>
|
||||
<p class="small" style="text-align:center;font-weight:700;margin-bottom:.8rem;">4,99 € / Monat · zahlungspflichtiges Abo · monatlich kündbar</p>
|
||||
<label class="abo-widerruf-check" for="widerruf-consent">
|
||||
<input type="checkbox" id="widerruf-consent" />
|
||||
<span>Ich wünsche ausdrücklich, dass der Zugang zum Supporter-Bereich sofort mit Vertragsschluss beginnt, und weiß, dass mein 14-tägiges Widerrufsrecht dadurch erlischt, sobald der Zugang vollständig bereitgestellt ist (Details in den <a href="agb.html" target="_blank" rel="noopener">AGB</a>, Ziffer 6).</span>
|
||||
</label>
|
||||
|
||||
<div id="paypal-button-container" class="paypal-gesperrt"></div>
|
||||
<p class="small muted" id="paypal-gesperrt-hinweis">Bitte bestätige oben die Checkbox, um die Zahlungsoptionen freizuschalten.</p>
|
||||
<p class="small" id="paypal-laedt" data-i18n="ab_paypal_laedt" hidden>Zahlungsoptionen werden geladen …</p>
|
||||
<p class="abo-fehler" id="subscribe-fehler"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zustand 6: bereits Supporter -->
|
||||
@@ -460,7 +619,7 @@
|
||||
<script>
|
||||
(function () {
|
||||
const S = window.DogiSupporter;
|
||||
const panels = ["panel-lade", "panel-register", "panel-login", "panel-code", "panel-subscribe", "panel-already"];
|
||||
const panels = ["panel-lade", "panel-register", "panel-login", "panel-code", "panel-neuer-code", "panel-subscribe", "panel-already"];
|
||||
// panel-social-login (Google-Button) hängt sich an register/login dran,
|
||||
// ist aber kein "richtiger" exklusiver Zustand — separat gesteuert.
|
||||
const SOCIAL_LOGIN_PANELS = ["panel-register", "panel-login"];
|
||||
@@ -498,7 +657,9 @@
|
||||
el.classList.remove("zeige");
|
||||
}
|
||||
|
||||
let codePurpose = "verify_email"; // oder "login"
|
||||
// Merkt sich die E-Mail-Adresse zwischen "Code vergessen?" und der
|
||||
// Eingabe des Einmalcodes (der frühere codePurpose-Schalter entfiel mit
|
||||
// der Umstellung 21.08.2026 — es gibt nur noch diesen einen Code-Weg).
|
||||
let codeEmail = "";
|
||||
|
||||
async function pruefeStatus() {
|
||||
@@ -511,11 +672,24 @@
|
||||
const status = data.subscription?.status || "none";
|
||||
S.cacheSubStatus(status);
|
||||
S.cacheDisplayName(data.profile?.displayName || "");
|
||||
if (["active", "pending", "suspended", "cancelled"].includes(status)) {
|
||||
zeige("panel-already");
|
||||
} else {
|
||||
zeige("panel-subscribe");
|
||||
}
|
||||
/* Nutzer-Wunsch 21.08.2026: "anstatt das was du jetzt auf dem screen
|
||||
siehst, sollen die leute nicht mehr sehen wenn sie angemeldet sind.
|
||||
sie sollen die richtige seite sehen die wir schon mal vorbereitet
|
||||
hatten, wo sie ihre persönliche daten sehen und so."
|
||||
|
||||
Wer eingeloggt ist, gehört nicht mehr auf diese Anmeldeseite —
|
||||
egal ob mit oder ohne bezahltes Abo. supporter.html zeigt in BEIDEN
|
||||
Fällen das Richtige: persönliche Daten, Fortschritt mit allen
|
||||
Prämien-Meilensteinen, Sammlungsarchiv, Profil bearbeiten,
|
||||
Ausloggen — bei "kein Abo" eben mit entsprechendem Hinweis statt
|
||||
echter Laufzeit. Vorher landete man hier stattdessen in einer
|
||||
Sackgasse ("Coming soon…"), ohne je die eigentliche Seite zu sehen.
|
||||
|
||||
replace() statt href: die Anmeldeseite verschwindet damit aus dem
|
||||
Verlauf — ein "Zurück" führt sonst sofort wieder hierher und von
|
||||
hier wieder weiter, was sich wie eine Schleife anfühlt. */
|
||||
location.replace("supporter.html");
|
||||
return;
|
||||
} catch (err) {
|
||||
// Token ungültig/abgelaufen -> zurück zur Registrierung/Login
|
||||
S.clearToken();
|
||||
@@ -628,8 +802,10 @@
|
||||
ladeHinweis.hidden = true;
|
||||
}
|
||||
|
||||
document.getElementById("switch-to-login").addEventListener("click", () => zeige("panel-login"));
|
||||
document.getElementById("switch-to-register").addEventListener("click", () => zeige("panel-register"));
|
||||
// Der frühere Gegenstück-Knopf "Neu registrieren" auf der Einloggen-Seite
|
||||
// wurde am 21.08.2026 auf Wunsch entfernt — deshalb hier nur noch der eine
|
||||
// Wechsel von der Registrierung zum Einloggen.
|
||||
document.getElementById("tab-login").addEventListener("click", () => zeige("panel-login"));
|
||||
|
||||
// Widerrufsrecht-Checkbox: erst nach aktivem Ankreuzen werden die
|
||||
// PayPal-Zahlungsoptionen überhaupt geladen/klickbar (siehe agb.html
|
||||
@@ -656,25 +832,44 @@
|
||||
const displayName = document.getElementById("reg-name").value.trim();
|
||||
const tiktokUsername = document.getElementById("reg-tiktok").value.trim();
|
||||
const email = document.getElementById("reg-email").value.trim();
|
||||
const accessCode = document.getElementById("reg-code").value;
|
||||
if (!displayName || !email) return fehlerZeigen(el, "Bitte Name und E-Mail-Adresse angeben.");
|
||||
if (!accessCode) return fehlerZeigen(el, "Bitte leg einen Zugangscode fest.");
|
||||
try {
|
||||
await S.apiFetch("/supporter/register", { displayName, tiktokUsername, email });
|
||||
codePurpose = "verify_email";
|
||||
codeEmail = email;
|
||||
zeige("panel-code");
|
||||
// Seit 21.08.2026 liefert /supporter/register direkt ein Token zurück —
|
||||
// kein Bestätigungscode per E-Mail mehr dazwischen, man ist sofort drin.
|
||||
const data = await S.apiFetch("/supporter/register", { displayName, tiktokUsername, email, accessCode });
|
||||
S.setToken(data.token);
|
||||
await pruefeStatus();
|
||||
} catch (err) {
|
||||
fehlerZeigen(el, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btn-login-request").addEventListener("click", async () => {
|
||||
// Normale Anmeldung: E-Mail + eigener Zugangscode, ohne Umweg über E-Mail.
|
||||
document.getElementById("btn-login").addEventListener("click", async () => {
|
||||
const el = document.getElementById("login-fehler");
|
||||
fehlerVerstecken(el);
|
||||
const email = document.getElementById("login-email").value.trim();
|
||||
if (!email) return fehlerZeigen(el, "Bitte E-Mail-Adresse angeben.");
|
||||
const accessCode = document.getElementById("login-code").value;
|
||||
if (!email || !accessCode) return fehlerZeigen(el, "Bitte E-Mail-Adresse und Zugangscode eingeben.");
|
||||
try {
|
||||
const data = await S.apiFetch("/supporter/login-code", { email, accessCode });
|
||||
S.setToken(data.token);
|
||||
await pruefeStatus();
|
||||
} catch (err) {
|
||||
fehlerZeigen(el, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// "Code vergessen?" — hier lebt der alte E-Mail-Einmalcode-Weg weiter.
|
||||
document.getElementById("code-vergessen").addEventListener("click", async () => {
|
||||
const el = document.getElementById("login-fehler");
|
||||
fehlerVerstecken(el);
|
||||
const email = document.getElementById("login-email").value.trim();
|
||||
if (!email) return fehlerZeigen(el, "Bitte zuerst deine E-Mail-Adresse eingeben.");
|
||||
try {
|
||||
await S.apiFetch("/supporter/login-request", { email });
|
||||
codePurpose = "login";
|
||||
codeEmail = email;
|
||||
zeige("panel-code");
|
||||
} catch (err) {
|
||||
@@ -688,9 +883,22 @@
|
||||
const code = document.getElementById("code-input").value.trim();
|
||||
if (!code) return fehlerZeigen(el, "Bitte Code eingeben.");
|
||||
try {
|
||||
const path = codePurpose === "login" ? "/supporter/verify-login" : "/supporter/verify-email";
|
||||
const data = await S.apiFetch(path, { email: codeEmail, code });
|
||||
const data = await S.apiFetch("/supporter/verify-login", { email: codeEmail, code });
|
||||
S.setToken(data.token);
|
||||
// Eingeloggt — jetzt fehlt nur noch der neue dauerhafte Zugangscode.
|
||||
zeige("panel-neuer-code");
|
||||
} catch (err) {
|
||||
fehlerZeigen(el, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btn-neuer-code").addEventListener("click", async () => {
|
||||
const el = document.getElementById("neuer-code-fehler");
|
||||
fehlerVerstecken(el);
|
||||
const accessCode = document.getElementById("neuer-code").value;
|
||||
if (!accessCode) return fehlerZeigen(el, "Bitte leg einen Zugangscode fest.");
|
||||
try {
|
||||
await S.apiFetch("/supporter/set-access-code", { accessCode }, true);
|
||||
await pruefeStatus();
|
||||
} catch (err) {
|
||||
fehlerZeigen(el, err.message);
|
||||
@@ -705,15 +913,13 @@
|
||||
} catch {
|
||||
/* Konfiguration konnte nicht geladen werden — Google-Button/PayPal-Buttons bleiben dann aus. */
|
||||
}
|
||||
/* Bugfix/Nutzer-Wunsch 20.08.2026: "Mit Google anmelden" wäre ein dritter, bisher
|
||||
übersehener Weg gewesen, trotz gesperrtem Registrieren-/Login-Knopf doch ein
|
||||
echtes Konto anzulegen bzw. sich einzuloggen (falls GOOGLE_CLIENT_ID auf dem
|
||||
Server konfiguriert ist). initGoogleConsent() daher bewusst NICHT mehr
|
||||
aufgerufen, solange die DogiCrew-Registrierung gesperrt ist — panel-social-login
|
||||
bleibt dadurch dauerhaft hidden. Einfach diesen Aufruf wieder aktivieren,
|
||||
sobald die Registrierung selbst (btn-register/btn-login-request, siehe oben)
|
||||
entsperrt wird. */
|
||||
// initGoogleConsent();
|
||||
/* Bugfix 20.08.2026, wieder aktiviert 21.08.2026: "Mit Google anmelden" war ein
|
||||
dritter, bisher übersehener Weg, trotz gesperrtem Registrieren-/Login-Knopf
|
||||
doch ein echtes Konto anzulegen bzw. sich einzuloggen. War deshalb deaktiviert,
|
||||
solange die Registrierung selbst gesperrt war. Registrierung ist jetzt (Nutzer-
|
||||
Wunsch 21.08.2026) bewusst offen -- Google-Login ist nur ein weiterer,
|
||||
gleichwertiger Weg zum selben jetzt erlaubten Ziel, kein Schlupfloch mehr. */
|
||||
initGoogleConsent();
|
||||
await pruefeStatus();
|
||||
}
|
||||
|
||||
|
||||
@@ -1566,6 +1566,12 @@ header.site-header {
|
||||
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.btn-row { display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 1.5rem; }
|
||||
|
||||
/* Kurzes "✓ Link kopiert"-Feedback für dogiTeilen()-Knöpfe (Zwischenablage-
|
||||
Fallback ohne Web-Share-API) -- 21.08.2026. Gleiche Erfolgsfarbe wie
|
||||
.vw-copy-btn.kopiert in der Verwaltung, für ein konsistentes Gefühl
|
||||
zwischen öffentlicher Seite und Admin-Bereich. */
|
||||
.dogi-teilen-kopiert { background: rgba(52,211,153,.15) !important; border-color: rgba(52,211,153,.5) !important; color: #5eeaa7 !important; }
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
|
Before Width: | Height: | Size: 349 KiB After Width: | Height: | Size: 482 KiB |
|
Before Width: | Height: | Size: 349 KiB After Width: | Height: | Size: 482 KiB |
|
Before Width: | Height: | Size: 318 KiB After Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 318 KiB After Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 349 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 359 KiB After Width: | Height: | Size: 459 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 349 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 403 KiB |
|
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 403 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 576 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 576 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 295 KiB After Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 458 KiB After Width: | Height: | Size: 419 KiB |
|
Before Width: | Height: | Size: 458 KiB After Width: | Height: | Size: 419 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 408 KiB After Width: | Height: | Size: 477 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 349 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 508 KiB After Width: | Height: | Size: 356 KiB |
@@ -126,10 +126,62 @@ window.I18N_PAGE = {
|
||||
de: "Registrieren", "de-CH": "Registriere",
|
||||
en: "Register", fr: "S'inscrire", pt: "Registrar",
|
||||
},
|
||||
ab_tab_register: {
|
||||
de: "Neu registrieren", "de-CH": "Nöi registriere",
|
||||
en: "Sign up", fr: "S'inscrire", pt: "Registrar",
|
||||
},
|
||||
ab_tab_login: {
|
||||
de: "Schon registriert? Einloggen", "de-CH": "Scho registriert? Yylogge",
|
||||
en: "Already registered? Log in", fr: "Déjà inscrit ? Se connecter", pt: "Já registrado? Entrar",
|
||||
},
|
||||
ab_switch_login: {
|
||||
de: "Schon Supporter? Hier einloggen", "de-CH": "Scho Supporter? Da yylogge",
|
||||
en: "Already a supporter? Log in here", fr: "Déjà supporter ? Connecte-toi ici", pt: "Já é apoiador? Faça login aqui",
|
||||
},
|
||||
ab_feld_code_neu: {
|
||||
de: "Denk dir jetzt deinen Zugangscode aus (mindestens 6 Zeichen)",
|
||||
"de-CH": "Dänk der jetz din Zuegangscode us (mindeschtens 6 Zeiche)",
|
||||
en: "Now choose your own access code (at least 6 characters)",
|
||||
fr: "Choisis maintenant ton code d'accès (au moins 6 caractères)",
|
||||
pt: "Agora escolha seu código de acesso (mínimo 6 caracteres)",
|
||||
},
|
||||
ab_feld_code_platzhalter: {
|
||||
de: "Frei wählbar — z. B. DogiCrew2026",
|
||||
"de-CH": "Frei wählbar — z. B. DogiCrew2026",
|
||||
en: "Anything you like — e.g. DogiCrew2026",
|
||||
fr: "Comme tu veux — p. ex. DogiCrew2026",
|
||||
pt: "À sua escolha — ex. DogiCrew2026",
|
||||
},
|
||||
ab_code_hinweis_neu: {
|
||||
de: "Mit diesem Code loggst du dich künftig ein — merk ihn dir gut.",
|
||||
"de-CH": "Mit däm Code loggsch di künftig y — merk der en guet.",
|
||||
en: "You'll use this code to log in from now on — keep it safe.",
|
||||
fr: "Tu utiliseras ce code pour te connecter — garde-le en lieu sûr.",
|
||||
pt: "Você usará este código para entrar a partir de agora — guarde-o bem.",
|
||||
},
|
||||
ab_feld_code_eigen: {
|
||||
de: "Dein Zugangscode", "de-CH": "Din Zuegangscode",
|
||||
en: "Your access code", fr: "Ton code d'accès", pt: "Seu código de acesso",
|
||||
},
|
||||
ab_btn_login: {
|
||||
de: "Einloggen", "de-CH": "Yylogge",
|
||||
en: "Log in", fr: "Se connecter", pt: "Entrar",
|
||||
},
|
||||
ab_code_vergessen: {
|
||||
de: "Code vergessen?", "de-CH": "Code vergässe?",
|
||||
en: "Forgot your code?", fr: "Code oublié ?", pt: "Esqueceu seu código?",
|
||||
},
|
||||
ab_neuer_code_hinweis: {
|
||||
de: "Fast fertig — leg jetzt deinen neuen Zugangscode fest.",
|
||||
"de-CH": "Fascht fertig — leg jetz din nöie Zuegangscode fescht.",
|
||||
en: "Almost done — now set your new access code.",
|
||||
fr: "Presque fini — définis maintenant ton nouveau code d'accès.",
|
||||
pt: "Quase pronto — agora defina seu novo código de acesso.",
|
||||
},
|
||||
ab_btn_code_speichern: {
|
||||
de: "Zugangscode speichern", "de-CH": "Zuegangscode spichere",
|
||||
en: "Save access code", fr: "Enregistrer le code d'accès", pt: "Salvar código de acesso",
|
||||
},
|
||||
ab_btn_login_code: {
|
||||
de: "Login-Code anfordern", "de-CH": "Login-Code aafordere",
|
||||
en: "Request login code", fr: "Demander un code de connexion", pt: "Solicitar código de login",
|
||||
@@ -151,8 +203,23 @@ window.I18N_PAGE = {
|
||||
en: "Confirm", fr: "Confirmer", pt: "Confirmar",
|
||||
},
|
||||
ab_eingeloggt_als: {
|
||||
de: "Eingeloggt. Noch ein letzter Schritt:", "de-CH": "Yyglogget. No en letschte Schritt:",
|
||||
en: "Logged in. One last step:", fr: "Connecté. Une dernière étape :", pt: "Conectado. Um último passo:",
|
||||
de: "Eingeloggt.", "de-CH": "Yyglogget.",
|
||||
en: "Logged in.", fr: "Connecté.", pt: "Conectado.",
|
||||
},
|
||||
ab_subscribe_locked_hinweis: {
|
||||
de: "Dein Konto ist bereit — die Bezahlung startet, sobald das Abo offiziell live geht. Schau einfach bald wieder vorbei.",
|
||||
"de-CH": "Dis Konto isch parat — d Zahlig startet, sobald s Abo offiziell live gaht. Schau eifach bald wieder verbi.",
|
||||
en: "Your account is ready — payment opens as soon as the subscription officially launches. Just check back soon.",
|
||||
fr: "Ton compte est prêt — le paiement s'ouvrira dès le lancement officiel de l'abonnement. Repasse bientôt.",
|
||||
pt: "Sua conta está pronta — o pagamento abre assim que a assinatura for lançada oficialmente. Volte em breve.",
|
||||
},
|
||||
ab_weiter_startseite: {
|
||||
de: "Zur Startseite →", "de-CH": "Zur Startsiite →",
|
||||
en: "To the homepage →", fr: "Vers l'accueil →", pt: "Para a página inicial →",
|
||||
},
|
||||
ab_weiter_streamplan: {
|
||||
de: "Zum Streamplan →", "de-CH": "Zum Streamplan →",
|
||||
en: "To the stream schedule →", fr: "Vers le programme des lives →", pt: "Para a programação de lives →",
|
||||
},
|
||||
ab_oder_email: {
|
||||
de: "oder mit E-Mail", "de-CH": "oder mit E-Mail",
|
||||
|
||||
@@ -97,6 +97,10 @@ window.I18N_PAGE = {
|
||||
de: "Direkt ansehen →", "de-CH": "Direkt aluege →",
|
||||
en: "View directly →", fr: "Voir directement →", pt: "Ver diretamente →",
|
||||
},
|
||||
idx_jahres_event_modal_teilen: {
|
||||
de: "📤 Teilen", "de-CH": "📤 Teile",
|
||||
en: "📤 Share", fr: "📤 Partager", pt: "📤 Compartilhar",
|
||||
},
|
||||
|
||||
idx_char_eyebrow: {
|
||||
de: "🩵 Die Gesichter der Welten", "de-CH": "🩵 D Gsichter vo de Wälte",
|
||||
|
||||
@@ -58,6 +58,10 @@ window.I18N_PAGE = {
|
||||
de: "Mehr erfahren →", "de-CH": "Mehr erfahre →",
|
||||
en: "Learn more →", fr: "En savoir plus →", pt: "Saiba mais →",
|
||||
},
|
||||
sp_radar_teilen: {
|
||||
de: "📤 Teilen", "de-CH": "📤 Teile",
|
||||
en: "📤 Share", fr: "📤 Partager", pt: "📤 Compartilhar",
|
||||
},
|
||||
sp_card1_tag: {
|
||||
de: "Fixer Termin", "de-CH": "Fixe Termin",
|
||||
en: "Fixed Time", fr: "Rendez-vous fixe", pt: "Horário Fixo",
|
||||
|
||||
@@ -58,6 +58,17 @@ window.I18N_PAGE = {
|
||||
de: "Jetzt abonnieren", "de-CH": "Jetzt abonniere",
|
||||
en: "Subscribe now", fr: "S'abonner maintenant", pt: "Assine agora",
|
||||
},
|
||||
su_kein_abo_soon: {
|
||||
de: "Coming soon…", "de-CH": "Coming soon…",
|
||||
en: "Coming soon…", fr: "Coming soon…", pt: "Coming soon…",
|
||||
},
|
||||
su_kein_abo_hinweis: {
|
||||
de: "Alles hier ist schon für dich vorbereitet — sobald das Abo startet, kannst du es mit einem Klick aktivieren.",
|
||||
"de-CH": "Alles da isch scho für di parat — sobald s Abo startet, chasch es mit eim Klick aktiviere.",
|
||||
en: "Everything here is already set up for you — as soon as the subscription starts, you can activate it with one click.",
|
||||
fr: "Tout est déjà prêt pour toi — dès le lancement de l'abonnement, tu pourras l'activer en un clic.",
|
||||
pt: "Tudo aqui já está preparado para você — assim que a assinatura começar, você poderá ativá-la com um clique.",
|
||||
},
|
||||
su_fenster2_h2: {
|
||||
de: "Mein aktueller Fortschritt", "de-CH": "Mis aktuelle Fortschritt",
|
||||
en: "My Current Progress", fr: "Ma progression actuelle", pt: "Meu progresso atual",
|
||||
|
||||
@@ -518,16 +518,24 @@
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("supporterToken");
|
||||
const status = localStorage.getItem("supporterSubStatus");
|
||||
if (!token) {
|
||||
return `<a class="nav-cta nav-cta-abo" href="abonnieren.html"><span>${t("nav_cta_abo_anonym")}</span>${soonBadge()}</a>`;
|
||||
}
|
||||
const hatBereich = ["active", "pending", "suspended", "cancelled"].includes(status);
|
||||
if (hatBereich) {
|
||||
const name = localStorage.getItem("supporterDisplayName");
|
||||
return `<a class="nav-cta nav-cta-abo" href="supporter.html"><span>${bereichLabel(name)}</span></a>`;
|
||||
}
|
||||
return `<a class="nav-cta nav-cta-abo" href="abonnieren.html"><span>${t("nav_cta_abo_aktivieren")}</span>${soonBadge()}</a>`;
|
||||
/* Nutzer-Wunsch 21.08.2026: "meine seite ändert sich nicht und es gibt
|
||||
keinen button für registrierte sich normal anzumelden und abzumelden.
|
||||
wo sie die speziellen sachen sehen als supporter und dass sie auch
|
||||
schon den abo sehen aber gesperrt ist." Vorher landeten Leute mit
|
||||
Konto, aber ohne bezahltes Abo (status "none"), im Nav-Knopf wieder
|
||||
nur bei abonnieren.html -- man merkte dem Knopf also nie an, dass man
|
||||
überhaupt eingeloggt ist. supporter.html zeigt für genau diesen Fall
|
||||
schon lange den Bereich "Mein Abo" -> #supp-keine-abo ("Du hast noch
|
||||
kein aktives Abo" + Knopf) UND den Ausloggen-Knopf (siehe dort,
|
||||
#btn-logout) -- das musste also nur noch verlinkt werden. Ab jetzt:
|
||||
JEDER mit gültigem Token (egal ob "none", "active", "pending", ...)
|
||||
geht auf supporter.html und sieht dort automatisch den passenden
|
||||
Zustand samt Logout. */
|
||||
const name = localStorage.getItem("supporterDisplayName");
|
||||
return `<a class="nav-cta nav-cta-abo" href="supporter.html"><span>${bereichLabel(name)}</span></a>`;
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
@@ -1172,6 +1180,49 @@
|
||||
return feld[sprache] || feld.de || "";
|
||||
};
|
||||
|
||||
/* =====================================================================
|
||||
dogiTeilen() — 21.08.2026, Nutzer-Wunsch: "die events des jahres und
|
||||
speziellen live events soll man auch teilen können." Wiederverwendbare
|
||||
Teilen-Funktion für einzelne Inhalte (aktuell: Event des Jahres auf
|
||||
index.html, spezielles Live-Event auf streamplan.html — künftig bei
|
||||
Bedarf für weitere Stellen nutzbar, ohne den Mechanismus neu zu bauen).
|
||||
|
||||
Nutzt zuerst die native Teilen-Funktion des Geräts (Web-Share-API) —
|
||||
auf dem Handy öffnet das den echten Teilen-Dialog (WhatsApp, TikTok,
|
||||
Mail, ...). Die meisten Desktop-Browser kennen diese API nicht/zeigen
|
||||
dort ohnehin kein sinnvolles Menü -- dort stattdessen Fallback: Link in
|
||||
die Zwischenablage kopieren, der klickende Knopf bekommt kurz ein
|
||||
"✓ Link kopiert"-Feedback (eigener Text + eigene Klasse, gleiches
|
||||
Timing wie .vw-copy-btn in der Verwaltung). */
|
||||
window.dogiTeilen = async function (daten, knopf) {
|
||||
const payload = { title: daten.title || "", text: daten.text || "", url: daten.url || location.href };
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share(payload);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (err && err.name === "AbortError") return; // Dialog selbst abgebrochen -- kein Fallback nötig
|
||||
// sonst: weiter zum Zwischenablage-Fallback unten
|
||||
}
|
||||
}
|
||||
if (!navigator.clipboard) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(payload.url);
|
||||
if (knopf) {
|
||||
const alterText = knopf.textContent;
|
||||
knopf.textContent = "✓ Link kopiert";
|
||||
knopf.classList.add("dogi-teilen-kopiert");
|
||||
setTimeout(() => {
|
||||
knopf.textContent = alterText;
|
||||
knopf.classList.remove("dogi-teilen-kopiert");
|
||||
}, 1600);
|
||||
}
|
||||
} catch {
|
||||
/* Zwischenablage nicht erreichbar (z.B. kein HTTPS-Kontext) -- kein
|
||||
Blocker, der Link bleibt über die normale Adressleiste erreichbar. */
|
||||
}
|
||||
};
|
||||
|
||||
/* =====================================================================
|
||||
dogiTeamLaden() — 21.08.2026, Nutzer-Wunsch: "überall wo Modis sind
|
||||
oder Scouts oder Manager, oder Creator, will ich dass ich die easy
|
||||
|
||||
@@ -156,7 +156,9 @@
|
||||
const seCountdown = document.getElementById("se-radar-countdown");
|
||||
const seTitel = document.getElementById("se-radar-titel");
|
||||
const seLink = document.getElementById("se-radar-link");
|
||||
const seTeilenBtn = document.getElementById("se-radar-teilen");
|
||||
let zielDatum = null;
|
||||
let aktuellesEvent = null; // für dogiTeilen() beim Klick (21.08.2026)
|
||||
let countdownIntervalId = null;
|
||||
let zeigerIntervalId = null;
|
||||
|
||||
@@ -201,6 +203,7 @@
|
||||
return;
|
||||
}
|
||||
zielDatum = new Date(event.zieldatum);
|
||||
aktuellesEvent = event;
|
||||
seWrap.hidden = false;
|
||||
seRadar.classList.remove("ist-live");
|
||||
seTitel.textContent = window.dogiFeld ? window.dogiFeld(event.titel) : event.titel?.de || "";
|
||||
@@ -226,5 +229,23 @@
|
||||
document.addEventListener("dogi-sprache-geaendert", () => {
|
||||
if (zielDatum) specialEventLaden();
|
||||
});
|
||||
|
||||
// Teilen (21.08.2026, Nutzer-Wunsch: "... speziellen live events soll man
|
||||
// auch teilen können") -- teilt die Streamplan-Seite selbst (dort steht
|
||||
// ja bereits automatisch das aktuell aktive Event), zusammen mit Titel
|
||||
// im Text, damit klar ist, worum es geht. Nur EIN spezielles Event kann
|
||||
// je aktiv sein, deshalb reicht ein einziger Knopf ohne Slot-Bezug.
|
||||
seTeilenBtn?.addEventListener("click", () => {
|
||||
if (!aktuellesEvent) return;
|
||||
const titel = window.dogiFeld ? window.dogiFeld(aktuellesEvent.titel) : aktuellesEvent.titel?.de || "";
|
||||
window.dogiTeilen?.(
|
||||
{
|
||||
title: titel,
|
||||
text: `🔴 ${titel} — spezielles Live-Event bei DogFather!`,
|
||||
url: `${location.origin}/streamplan.html`,
|
||||
},
|
||||
seTeilenBtn
|
||||
);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-dogfather.jpg');--page-bg-mobile:url('/assets/img/bg-dogfather-mobile.jpg');">
|
||||
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-bewerben-modi.jpg');--page-bg-mobile:url('/assets/img/bg-bewerben-modi-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
|
||||
<main>
|
||||
<section class="hero page-bg">
|
||||
<div class="hero-banner"><img src="/assets/img/bg-dogfather.jpg" alt="" loading="eager" /></div>
|
||||
<div class="hero-banner"><img src="/assets/img/bg-bewerben-modi.jpg" alt="" loading="eager" /></div>
|
||||
<div class="container">
|
||||
<span class="eyebrow" data-i18n="bwm_eyebrow">🩵 Team Dogi — Community</span>
|
||||
<h1 data-i18n="bwm_h1">Werde Modi bei Team Dogi</h1>
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-scouts.jpg');--page-bg-mobile:url('/assets/img/bg-scouts-mobile.jpg');">
|
||||
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-bewerben-scout.jpg');--page-bg-mobile:url('/assets/img/bg-bewerben-scout-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
|
||||
<main>
|
||||
<section class="hero page-bg">
|
||||
<div class="hero-banner"><img src="/assets/img/bg-scouts.jpg" alt="" loading="eager" /></div>
|
||||
<div class="hero-banner"><img src="/assets/img/bg-bewerben-scout.jpg" alt="" loading="eager" /></div>
|
||||
<div class="container">
|
||||
<span class="eyebrow" data-i18n="bws_eyebrow">Manager DogFather — Agentur</span>
|
||||
<h1 data-i18n="bws_h1">Werde Scout für DogFather</h1>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- =====================================================================
|
||||
-- 0009_supporter_access_code.sql — Eigener, fester Zugangscode je Supporter
|
||||
--
|
||||
-- Nutzer-Wunsch 21.08.2026: "wieso sollte ich wieder ein code bekommen,
|
||||
-- mach die anmeldung einfach, die leute sollen nach der registrierung einen
|
||||
-- festen code erstellen können für sich selbst und fertig. mit dem sie rein
|
||||
-- kommen."
|
||||
--
|
||||
-- Bis hierher lief JEDER Login über einen 6-stelligen Einmalcode per E-Mail
|
||||
-- (supporter_auth_codes) — auch für längst registrierte Leute. Ab jetzt legt
|
||||
-- man bei der Registrierung einen eigenen, dauerhaften Zugangscode fest und
|
||||
-- kommt damit direkt rein.
|
||||
--
|
||||
-- Gespeichert wird NIE der Code selbst, sondern ein scrypt-Hash inkl.
|
||||
-- zufälligem Salt (Format "scrypt$<salt-hex>$<hash-hex>", siehe
|
||||
-- lib/crypto.js). Damit ist der Code selbst aus der Datenbank heraus nicht
|
||||
-- rekonstruierbar — auch nicht von uns.
|
||||
--
|
||||
-- Der E-Mail-Einmalcode-Weg bleibt vollständig erhalten: er ist jetzt der
|
||||
-- "Code vergessen?"-Weg, über den man sich einen neuen Zugangscode setzen
|
||||
-- kann. Deshalb wird an supporter_auth_codes nichts geändert.
|
||||
--
|
||||
-- NULL = Konto aus der Zeit vor dieser Änderung, hat noch keinen eigenen
|
||||
-- Code. Solche Konten kommen über "Code vergessen?" an einen.
|
||||
-- =====================================================================
|
||||
ALTER TABLE supporter_users ADD COLUMN access_code_hash TEXT;
|
||||
ALTER TABLE supporter_users ADD COLUMN access_code_set_at TEXT;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- =====================================================================
|
||||
-- 0010_testimonials_avatar.sql — Profilbild für die "Stimmen"
|
||||
--
|
||||
-- Nutzer-Wunsch 21.08.2026: "ich soll auch paar geile bilder zum aussuchen
|
||||
-- haben als profilbild falls die leute kein bild mit schicken, das soll
|
||||
-- denen auch als möglichkeit gegeben sein."
|
||||
--
|
||||
-- Bis hierher zeigte jede Stimme nur den Anfangsbuchstaben des Namens in
|
||||
-- einem Kreis. Jetzt kann ein Bild hinterlegt werden.
|
||||
--
|
||||
-- Inhalt der Spalte:
|
||||
-- * eine Auswahlbild-Id ("husky-hoodie", "casper", …) — siehe
|
||||
-- ERLAUBTE_AVATARE in server-internal/routes/testimonials.js und
|
||||
-- assets/js/data-stimmen-avatare.js
|
||||
-- * ODER eine vollständige Adresse eines von der Verwaltung
|
||||
-- hochgeladenen eigenen Fotos
|
||||
-- * ODER NULL = kein Bild, dann bleibt es beim Anfangsbuchstaben
|
||||
--
|
||||
-- Bewusst nur EINE Spalte statt zweier (Auswahlbild/eigenes Foto): es kann
|
||||
-- ohnehin immer nur eines von beiden gleichzeitig gelten, zwei Spalten
|
||||
-- hätten nur die Frage aufgeworfen, welche gewinnt.
|
||||
-- =====================================================================
|
||||
ALTER TABLE testimonials ADD COLUMN avatar TEXT;
|
||||
@@ -46,12 +46,12 @@
|
||||
.rabatt-gesperrt-hinweis p { color: #ffe9a8; margin: 0; flex: 1 1 260px; font-size: .95rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-links.jpg');--page-bg-mobile:url('/assets/img/bg-links-mobile.jpg');">
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-links-seite.jpg');--page-bg-mobile:url('/assets/img/bg-links-seite-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
|
||||
<main>
|
||||
<section class="hero page-bg">
|
||||
<div class="hero-banner"><img src="/assets/img/bg-links.jpg" alt="" loading="eager" /></div>
|
||||
<div class="hero-banner"><img src="/assets/img/bg-links-seite.jpg" alt="" loading="eager" /></div>
|
||||
<div class="container">
|
||||
<span class="eyebrow" data-i18n="lk_eyebrow">🩵 Alle Links</span>
|
||||
<h1 data-i18n="lk_h1">Alles an einem Ort</h1>
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" data-welt="casper" style="--page-bg:url('/assets/img/bg-casper.jpg');--page-bg-mobile:url('/assets/img/bg-casper-mobile.jpg');">
|
||||
<body class="mit-hintergrund" data-welt="casper" style="--page-bg:url('/assets/img/bg-medien-casper.jpg');--page-bg-mobile:url('/assets/img/bg-medien-casper-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
|
||||
<main>
|
||||
<section class="hero page-bg">
|
||||
<div class="hero-banner"><img src="/assets/img/bg-casper.jpg" alt="" loading="eager" /></div>
|
||||
<div class="hero-banner"><img src="/assets/img/bg-medien-casper.jpg" alt="" loading="eager" /></div>
|
||||
<div class="container">
|
||||
<span class="eyebrow" data-i18n="mc_eyebrow">Welt 2 — HasiDog</span>
|
||||
<h1 data-i18n="mc_h1">Medien — Casper</h1>
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" data-welt="hasidog" style="--page-bg:url('/assets/img/bg-hasidog.jpg');--page-bg-mobile:url('/assets/img/bg-hasidog-mobile.jpg');">
|
||||
<body class="mit-hintergrund" data-welt="hasidog" style="--page-bg:url('/assets/img/bg-medien-hasidog.jpg');--page-bg-mobile:url('/assets/img/bg-medien-hasidog-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
|
||||
<main>
|
||||
<section class="hero page-bg">
|
||||
<div class="hero-banner"><img src="/assets/img/bg-hasidog.jpg" alt="" loading="eager" /></div>
|
||||
<div class="hero-banner"><img src="/assets/img/bg-medien-hasidog.jpg" alt="" loading="eager" /></div>
|
||||
<div class="container">
|
||||
<span class="eyebrow" data-i18n="mh_eyebrow">Welt 2 — HasiDog</span>
|
||||
<h1 data-i18n="mh_h1">Medien — HasiDog</h1>
|
||||
|
||||
@@ -32,6 +32,8 @@ const MIGRATION_FILES = [
|
||||
"0006_testimonials.sql",
|
||||
"0007_team_members.sql",
|
||||
"0008_team_members_rich.sql",
|
||||
"0009_supporter_access_code.sql",
|
||||
"0010_testimonials_avatar.sql",
|
||||
];
|
||||
|
||||
function alreadyApplied(name) {
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
import { listAuditLog } from "./routes/audit.js";
|
||||
import {
|
||||
registerSupporter, verifySupporterEmail, requestSupporterLogin, verifySupporterLogin, logoutSupporter,
|
||||
loginSupporterMitCode, setSupporterAccessCode,
|
||||
googleLoginSupporter, getPublicConfig,
|
||||
getSupporterMe, updateSupporterProfile, requestSupporterEmailChange, confirmSupporterEmailChange,
|
||||
redeemPremium, deleteSupporterAccount,
|
||||
@@ -67,6 +68,7 @@ import { getEventsPublic, getEventsAdmin, saveEvent, uploadEventImage } from "./
|
||||
import { getSpecialEventPublic, getSpecialEventAdmin, saveSpecialEvent } from "./routes/special-event.js";
|
||||
import {
|
||||
submitTestimonial, getApprovedTestimonialsPublic, getTestimonialsAdmin, setTestimonialStatus, deleteTestimonial,
|
||||
setTestimonialAvatar, uploadTestimonialAvatar,
|
||||
} from "./routes/testimonials.js";
|
||||
import {
|
||||
createPoll, listPolls, closePoll, reopenPoll, deletePoll, getActivePollPublic, voteOnPoll,
|
||||
@@ -116,6 +118,20 @@ const teamBildUpload = multer({
|
||||
fileFilter: (req, file, cb) => cb(null, ERLAUBTE_BILD_TYPEN.has(file.mimetype)),
|
||||
});
|
||||
|
||||
// Profilbilder für die "Stimmen" (21.08.2026) — gleiches Muster wie oben.
|
||||
// Nur aus der Verwaltung heraus erreichbar; das öffentliche Stimmen-Formular
|
||||
// nimmt bewusst keine Dateien entgegen (siehe routes/testimonials.js).
|
||||
const TESTIMONIAL_UPLOADS_DIR = `${UPLOADS_BASIS_DIR}/testimonials`;
|
||||
mkdirSync(TESTIMONIAL_UPLOADS_DIR, { recursive: true });
|
||||
const stimmenBildUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, TESTIMONIAL_UPLOADS_DIR),
|
||||
filename: (req, file, cb) => cb(null, `${crypto.randomUUID()}${extname(file.originalname).toLowerCase()}`),
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
|
||||
fileFilter: (req, file, cb) => cb(null, ERLAUBTE_BILD_TYPEN.has(file.mimetype)),
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
@@ -185,6 +201,9 @@ app.get("/site-texts", wrap(getSiteTextsPublic));
|
||||
|
||||
app.post("/supporter/register", wrap(registerSupporter));
|
||||
app.post("/supporter/verify-email", wrap(verifySupporterEmail));
|
||||
// Normaler Login seit 21.08.2026: E-Mail + selbst gewählter Zugangscode.
|
||||
app.post("/supporter/login-code", wrap(loginSupporterMitCode));
|
||||
// login-request/verify-login sind jetzt der "Code vergessen?"-Weg.
|
||||
app.post("/supporter/login-request", wrap(requestSupporterLogin));
|
||||
app.post("/supporter/verify-login", wrap(verifySupporterLogin));
|
||||
app.post("/supporter/google-login", wrap(googleLoginSupporter));
|
||||
@@ -227,6 +246,9 @@ app.post("/admin/special-event/save", requireTeamSession, withSession(saveSpecia
|
||||
app.post("/admin/testimonials/list", requireTeamSession, withSession(getTestimonialsAdmin));
|
||||
app.post("/admin/testimonials/status", requireTeamSession, withSession(setTestimonialStatus));
|
||||
app.post("/admin/testimonials/delete", requireTeamSession, withSession(deleteTestimonial));
|
||||
// Profilbild einer Stimme setzen bzw. eigenes Foto hochladen (21.08.2026).
|
||||
app.post("/admin/testimonials/avatar", requireTeamSession, withSession(setTestimonialAvatar));
|
||||
app.post("/admin/testimonials/upload-avatar", requireTeamSession, stimmenBildUpload.single("image"), withSession(uploadTestimonialAvatar));
|
||||
|
||||
// Team-Mitglieder (Modis/Scouts/Creator/Manager) — 21.08.2026
|
||||
app.post("/admin/team/list", requireTeamSession, withSession(getTeamAdmin));
|
||||
@@ -277,6 +299,9 @@ app.post("/admin/polls/delete", requireTeamSession, withSession(deletePoll));
|
||||
app.post("/supporter/logout", requireSupporterAuth, wrap(logoutSupporter));
|
||||
app.post("/supporter/me", requireSupporterAuth, withSupporter(getSupporterMe));
|
||||
app.post("/supporter/update-profile", requireSupporterAuth, withSupporter(updateSupporterProfile));
|
||||
// Neuen Zugangscode setzen — freiwillig im Supporter-Bereich oder als
|
||||
// Abschluss des "Code vergessen?"-Weges (da ist man bereits eingeloggt).
|
||||
app.post("/supporter/set-access-code", requireSupporterAuth, withSupporter(setSupporterAccessCode));
|
||||
app.post("/supporter/request-email-change", requireSupporterAuth, withSupporter(requestSupporterEmailChange));
|
||||
app.post("/supporter/confirm-email-change", requireSupporterAuth, withSupporter(confirmSupporterEmailChange));
|
||||
app.post("/supporter/redeem-premium", requireSupporterAuth, withSupporter(redeemPremium));
|
||||
|
||||
@@ -60,3 +60,57 @@ export async function decryptCode(encoded) {
|
||||
export function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Persönlicher Zugangscode der Supporter (21.08.2026)
|
||||
|
||||
BEWUSST NICHT sha256Hex() wie bei den Einmalcodes oben: ein Einmalcode
|
||||
lebt 15 Minuten und ist zufällig, da genügt ein schneller Hash. Ein
|
||||
selbst gewählter, dauerhafter Code ist dagegen oft kurz und erratbar —
|
||||
dagegen hilft nur ein absichtlich LANGSAMES Verfahren, das massenhaftes
|
||||
Durchprobieren unbezahlbar macht. Dafür ist scrypt gemacht (in Node
|
||||
eingebaut, keine zusätzliche Abhängigkeit nötig).
|
||||
|
||||
Jeder Code bekommt zusätzlich einen eigenen Zufalls-Salt: dadurch haben
|
||||
zwei Personen mit demselben Code trotzdem völlig verschiedene Hashes,
|
||||
und vorberechnete Tabellen sind wertlos.
|
||||
|
||||
Gespeichertes Format: "scrypt$<salt-hex>$<hash-hex>"
|
||||
===================================================================== */
|
||||
import { scrypt as _scrypt, randomBytes, timingSafeEqual as _timingSafeEqual } from "node:crypto";
|
||||
|
||||
const SCRYPT_KEYLEN = 64;
|
||||
// N=16384 ist der Node-Standard und ein guter Kompromiss: für eine einzelne
|
||||
// Anmeldung kaum spürbar, für Angreifer aber millionenfach teuer.
|
||||
const SCRYPT_OPTIONEN = { N: 16384, r: 8, p: 1 };
|
||||
|
||||
function scryptAsync(code, salt) {
|
||||
return new Promise((resolve, reject) => {
|
||||
_scrypt(code, salt, SCRYPT_KEYLEN, SCRYPT_OPTIONEN, (err, key) => (err ? reject(err) : resolve(key)));
|
||||
});
|
||||
}
|
||||
|
||||
/** Erzeugt den zu speichernden Wert für einen selbst gewählten Zugangscode. */
|
||||
export async function hashAccessCode(code) {
|
||||
const salt = randomBytes(16);
|
||||
const key = await scryptAsync(String(code), salt);
|
||||
return `scrypt$${salt.toString("hex")}$${key.toString("hex")}`;
|
||||
}
|
||||
|
||||
/** Prüft einen eingegebenen Code gegen den gespeicherten Wert.
|
||||
Der Vergleich läuft zeitkonstant (timingSafeEqual), damit sich aus der
|
||||
Antwortdauer nicht ableiten lässt, wie viele Zeichen gestimmt haben. */
|
||||
export async function verifyAccessCode(code, gespeichert) {
|
||||
if (!gespeichert || typeof gespeichert !== "string") return false;
|
||||
const teile = gespeichert.split("$");
|
||||
if (teile.length !== 3 || teile[0] !== "scrypt") return false;
|
||||
try {
|
||||
const salt = Buffer.from(teile[1], "hex");
|
||||
const erwartet = Buffer.from(teile[2], "hex");
|
||||
const tatsaechlich = await scryptAsync(String(code), salt);
|
||||
if (erwartet.length !== tatsaechlich.length) return false;
|
||||
return _timingSafeEqual(erwartet, tatsaechlich);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,27 @@ export async function sendLoginCode({ to, name, code }) {
|
||||
});
|
||||
}
|
||||
|
||||
/* Willkommens-E-Mail nach der Registrierung (21.08.2026). Enthält bewusst
|
||||
KEINEN Code — man hat sich ja gerade selbst einen gesetzt und ist schon
|
||||
eingeloggt. Sie dient als Beleg, dass die Adresse stimmt, und als
|
||||
Erinnerung, wo man wieder reinkommt. */
|
||||
export async function sendWelcomeMail({ to, name }) {
|
||||
return send({
|
||||
to,
|
||||
subject: "Willkommen in der DogiCrew 🩵",
|
||||
html: WRAP(
|
||||
`Willkommen, ${name}! 🩵`,
|
||||
`<p style="line-height:1.6;">Dein Konto ist fertig. Du kommst ab jetzt jederzeit mit dieser
|
||||
E-Mail-Adresse und deinem selbst gewählten Zugangscode rein.</p>
|
||||
<p style="line-height:1.6;">Solltest du deinen Code mal vergessen, gibt es auf der Anmeldeseite
|
||||
„Code vergessen?“ — darüber bekommst du hier per E-Mail einen Einmalcode und kannst dir
|
||||
einen neuen Zugangscode setzen.</p>
|
||||
<p style="color:#9aa4b2;font-size:13px;">Hast du dich nicht angemeldet? Dann ignoriere diese
|
||||
E-Mail einfach — ohne deinen Zugangscode kommt niemand in das Konto.</p>`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendMilestoneUnlocked({ to, name, premiumLabel }) {
|
||||
return send({
|
||||
to,
|
||||
|
||||
@@ -61,11 +61,22 @@ export async function getEventsAdmin(req, res, session) {
|
||||
return json(res, { ok: true, events: [ladeSlot(1), ladeSlot(2)] });
|
||||
}
|
||||
|
||||
/** NUR Bild-Upload, kein JSON-Body (multipart, siehe index.js multer-Middleware). */
|
||||
/** NUR Bild-Upload, kein JSON-Body (multipart, siehe index.js multer-Middleware).
|
||||
Bugfix 21.08.2026 (Nutzer-Report: "wieso seh ich das bild nicht" bei
|
||||
"Event 1" in der Verwaltung): vorher wurde hier nur der RELATIVE Pfad
|
||||
("/uploads/events/...") zurückgegeben. verwaltung.html UND index.html
|
||||
laufen aber auf dogfather-universe.com (dogiweb-Dienst), während die
|
||||
hochgeladene Datei tatsächlich unter postfach.dogfather-universe.com
|
||||
(dieser Dienst, dogiintern, siehe /uploads-Static-Route in index.js)
|
||||
liegt -- der Browser hat den relativen Pfad also fälschlich gegen die
|
||||
falsche Domain aufgelöst und ein kaputtes Bild-Symbol angezeigt, sowohl
|
||||
in der Verwaltungs-Vorschau als auch (sobald ein Slot aktiviert wird)
|
||||
auf der echten Startseite. Jetzt vollständige Adresse, damit das Bild
|
||||
unabhängig davon lädt, von welcher Domain aus die Seite gerade läuft. */
|
||||
export async function uploadEventImage(req, res, session) {
|
||||
if (!can(session, "EVENTS_MANAGE")) return forbidden(res);
|
||||
if (!req.file) return json(res, { ok: false, error: "Keine Bilddatei erhalten." }, 400);
|
||||
const url = `/uploads/events/${req.file.filename}`;
|
||||
const url = `https://postfach.dogfather-universe.com/uploads/events/${req.file.filename}`;
|
||||
logAction(session.actor, "events.image_uploaded", req.file.filename, null);
|
||||
return json(res, { ok: true, url });
|
||||
}
|
||||
|
||||
@@ -7,14 +7,15 @@
|
||||
Setzt Dogfather_VanVan_Supporter_Abo.odt Abschnitt 5, 6, 9-18, 21-25 um.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { generateId, nowIso } from "../lib/crypto.js";
|
||||
import { generateId, nowIso, hashAccessCode, verifyAccessCode } from "../lib/crypto.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { isLockedOut, recordFailedAttempt, clearAttempts } from "../lib/auth.js";
|
||||
import {
|
||||
normalizeEmail, normalizeTikTokUsername, issueAuthCode, verifyAuthCode,
|
||||
createSupporterSession, endSupporterSession,
|
||||
} from "../lib/supporter-auth.js";
|
||||
import { EmailNotConfiguredError, sendVerifyEmailCode, sendLoginCode } from "../lib/supporter-mail.js";
|
||||
import { EmailNotConfiguredError, sendVerifyEmailCode, sendLoginCode, sendWelcomeMail } from "../lib/supporter-mail.js";
|
||||
import { computeProgress, timelineForCurrentCycle } from "../lib/supporter-cycle.js";
|
||||
import { verifyGoogleIdToken, GoogleAuthNotConfiguredError } from "../lib/google-auth.js";
|
||||
|
||||
@@ -41,40 +42,146 @@ function emailErrorResponse(res, err) {
|
||||
|
||||
/* ---------- Registrierung & Login (öffentlich, keine Session) ---------- */
|
||||
|
||||
/* Mindestlänge für den selbst gewählten Zugangscode. Bewusst nur 6 Zeichen
|
||||
(statt der sonst üblichen 8+): der Bereich enthält keine Zahlungsdaten,
|
||||
und ein zu strenger Zwang würde hier mehr Leute abschrecken als schützen.
|
||||
Gegen Durchprobieren schützt die Sperre nach mehreren Fehlversuchen
|
||||
(siehe loginSupporterMitCode) plus das langsame scrypt-Verfahren. */
|
||||
const MIN_CODE_LAENGE = 6;
|
||||
|
||||
function pruefeCode(code) {
|
||||
const sauber = String(code || "");
|
||||
if (sauber.length < MIN_CODE_LAENGE) {
|
||||
return `Dein Zugangscode muss mindestens ${MIN_CODE_LAENGE} Zeichen lang sein.`;
|
||||
}
|
||||
if (sauber.length > 200) return "Dein Zugangscode ist zu lang.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/* Registrierung — seit 21.08.2026 (Nutzer-Wunsch: "die leute sollen nach der
|
||||
registrierung einen festen code erstellen können für sich selbst und
|
||||
fertig, mit dem sie rein kommen"): man legt hier direkt seinen eigenen,
|
||||
dauerhaften Zugangscode fest und ist danach SOFORT eingeloggt. Vorher lief
|
||||
auch die Registrierung über einen Einmalcode per E-Mail — ein Extraschritt,
|
||||
der nichts brachte, weil man denselben Code danach nie wieder brauchte.
|
||||
|
||||
Die E-Mail-Adresse gilt dabei als bestätigt (email_verified = 1). Ohne das
|
||||
käme man später nicht mehr an den "Code vergessen?"-Weg heran, der ja
|
||||
genau über diese Adresse läuft — und ein zusätzlicher Bestätigungsschritt
|
||||
wäre wieder genau der Umweg, der hier abgeschafft werden sollte. Zur
|
||||
Kontrolle geht eine Willkommens-E-Mail raus; schlägt deren Versand fehl,
|
||||
ist das KEIN Grund die Registrierung scheitern zu lassen (das Konto ist
|
||||
ja schon nutzbar) — sie wird nur im Protokoll vermerkt. */
|
||||
export async function registerSupporter(req, res) {
|
||||
const body = req.body || {};
|
||||
const displayName = String(body.displayName || "").trim().slice(0, 60);
|
||||
const tiktokUsername = normalizeTikTokUsername(body.tiktokUsername);
|
||||
const email = normalizeEmail(body.email);
|
||||
const accessCode = String(body.accessCode || "");
|
||||
|
||||
if (!displayName) return bad(res, "Bitte gib einen Namen oder Anzeigenamen an.");
|
||||
if (!email || !email.includes("@")) return bad(res, "Bitte gib eine gültige E-Mail-Adresse an.");
|
||||
const codeFehler = pruefeCode(accessCode);
|
||||
if (codeFehler) return bad(res, codeFehler);
|
||||
|
||||
const existing = db.prepare(`SELECT id, email_verified FROM supporter_users WHERE email = ?`).get(email);
|
||||
const existing = db.prepare(`SELECT id, access_code_hash FROM supporter_users WHERE email = ?`).get(email);
|
||||
if (existing && existing.access_code_hash) {
|
||||
return bad(res, "Für diese E-Mail-Adresse gibt es schon ein Konto. Bitte logg dich einfach ein.", 409);
|
||||
}
|
||||
|
||||
const codeHash = await hashAccessCode(accessCode);
|
||||
let supporterId;
|
||||
if (existing) {
|
||||
if (existing.email_verified) {
|
||||
return bad(res, "Für diese E-Mail-Adresse existiert bereits ein Supporter-Konto. Bitte einloggen.", 409);
|
||||
}
|
||||
// Konto war angelegt, hatte aber noch keinen eigenen Code (z.B. ein
|
||||
// abgebrochener Versuch aus der Zeit davor) -> jetzt vervollständigen.
|
||||
supporterId = existing.id;
|
||||
db.prepare(`UPDATE supporter_users SET display_name = ?, tiktok_username = ? WHERE id = ?`).run(displayName, tiktokUsername, supporterId);
|
||||
db.prepare(
|
||||
`UPDATE supporter_users
|
||||
SET display_name = ?, tiktok_username = ?, email_verified = 1,
|
||||
access_code_hash = ?, access_code_set_at = ?
|
||||
WHERE id = ?`
|
||||
).run(displayName, tiktokUsername, codeHash, nowIso(), supporterId);
|
||||
} else {
|
||||
supporterId = generateId();
|
||||
db.prepare(
|
||||
`INSERT INTO supporter_users (id, display_name, tiktok_username, email, email_verified, created_at)
|
||||
VALUES (?, ?, ?, ?, 0, ?)`
|
||||
).run(supporterId, displayName, tiktokUsername, email, nowIso());
|
||||
`INSERT INTO supporter_users
|
||||
(id, display_name, tiktok_username, email, email_verified, created_at, access_code_hash, access_code_set_at, last_login_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?)`
|
||||
).run(supporterId, displayName, tiktokUsername, email, nowIso(), codeHash, nowIso(), nowIso());
|
||||
logAction("system", "supporter.registered", supporterId, { email });
|
||||
}
|
||||
|
||||
const code = await issueAuthCode(supporterId, "verify_email");
|
||||
// Willkommens-E-Mail: nice-to-have, kein Blocker (siehe Kommentar oben).
|
||||
try {
|
||||
await sendVerifyEmailCode({ to: email, name: displayName, code });
|
||||
await sendWelcomeMail({ to: email, name: displayName });
|
||||
} catch (err) {
|
||||
return emailErrorResponse(res, err);
|
||||
logAction("system", "supporter.welcome_mail_failed", supporterId, { fehler: String(err?.message || err).slice(0, 200) });
|
||||
}
|
||||
return json(res, { ok: true, needsVerification: true });
|
||||
|
||||
const token = await createSupporterSession(supporterId);
|
||||
return json(res, { ok: true, token });
|
||||
}
|
||||
|
||||
/* Einloggen mit E-Mail + selbst gewähltem Code — der neue Normalweg.
|
||||
Schutz gegen Durchprobieren: nach mehreren Fehlversuchen ist die
|
||||
Kombination für eine Weile gesperrt (dieselbe Mechanik wie beim
|
||||
Team-Login, siehe lib/auth.js). Der Schlüssel enthält bewusst die
|
||||
E-Mail-Adresse, damit ein Angreifer nicht durch Sperren FREMDER Konten
|
||||
Schaden anrichten kann, ohne deren Adresse zu kennen. */
|
||||
export async function loginSupporterMitCode(req, res) {
|
||||
const body = req.body || {};
|
||||
const email = normalizeEmail(body.email);
|
||||
const accessCode = String(body.accessCode || "");
|
||||
if (!email || !accessCode) return bad(res, "Bitte E-Mail-Adresse und Zugangscode eingeben.");
|
||||
|
||||
const sperrSchluessel = `supporter:${email}`;
|
||||
if (isLockedOut(sperrSchluessel)) {
|
||||
return bad(res, "Zu viele Fehlversuche. Bitte versuch es in ein paar Minuten noch einmal.", 429);
|
||||
}
|
||||
|
||||
const supporter = db
|
||||
.prepare(`SELECT id, display_name, access_code_hash FROM supporter_users WHERE email = ? AND status = 'active'`)
|
||||
.get(email);
|
||||
|
||||
// Konto existiert, wurde aber vor der Umstellung angelegt und hat noch
|
||||
// keinen eigenen Code -> gezielter Hinweis statt "falsche Daten".
|
||||
if (supporter && !supporter.access_code_hash) {
|
||||
return json(
|
||||
res,
|
||||
{ ok: false, code: "KEIN_CODE_GESETZT", error: "Für dieses Konto ist noch kein Zugangscode festgelegt. Nutze bitte „Code vergessen?“, um dir einen zu setzen." },
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
const passt = supporter ? await verifyAccessCode(accessCode, supporter.access_code_hash) : false;
|
||||
if (!passt) {
|
||||
recordFailedAttempt(sperrSchluessel);
|
||||
// Bewusst dieselbe Meldung, egal ob die E-Mail unbekannt ist oder nur
|
||||
// der Code nicht stimmt — sonst könnte man von außen durchprobieren,
|
||||
// welche Adressen überhaupt registriert sind.
|
||||
return bad(res, "E-Mail-Adresse oder Zugangscode stimmt nicht.", 401);
|
||||
}
|
||||
|
||||
clearAttempts(sperrSchluessel);
|
||||
db.prepare(`UPDATE supporter_users SET last_login_at = ? WHERE id = ?`).run(nowIso(), supporter.id);
|
||||
const token = await createSupporterSession(supporter.id);
|
||||
return json(res, { ok: true, token });
|
||||
}
|
||||
|
||||
/* Neuen Zugangscode setzen — für eingeloggte Leute (freiwillig ändern) UND
|
||||
als letzter Schritt des "Code vergessen?"-Weges (dort ist man durch den
|
||||
E-Mail-Einmalcode bereits eingeloggt). */
|
||||
export async function setSupporterAccessCode(req, res, supporter) {
|
||||
const body = req.body || {};
|
||||
const neuerCode = String(body.accessCode || "");
|
||||
const fehler = pruefeCode(neuerCode);
|
||||
if (fehler) return bad(res, fehler);
|
||||
|
||||
const codeHash = await hashAccessCode(neuerCode);
|
||||
db.prepare(`UPDATE supporter_users SET access_code_hash = ?, access_code_set_at = ? WHERE id = ?`).run(codeHash, nowIso(), supporter.id);
|
||||
clearAttempts(`supporter:${supporter.email}`);
|
||||
logAction("system", "supporter.access_code_set", supporter.id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function verifySupporterEmail(req, res) {
|
||||
@@ -96,16 +203,24 @@ export async function verifySupporterEmail(req, res) {
|
||||
return json(res, { ok: true, token });
|
||||
}
|
||||
|
||||
/* Seit 21.08.2026 ist das NICHT mehr der normale Login-Weg, sondern der
|
||||
"Code vergessen?"-Weg: hierüber bekommt man einen Einmalcode per E-Mail,
|
||||
ist danach eingeloggt und kann sich einen neuen dauerhaften Zugangscode
|
||||
setzen (siehe setSupporterAccessCode). */
|
||||
export async function requestSupporterLogin(req, res) {
|
||||
const body = req.body || {};
|
||||
const email = normalizeEmail(body.email);
|
||||
if (!email) return bad(res, "Bitte gib deine E-Mail-Adresse an.");
|
||||
|
||||
const supporter = db
|
||||
.prepare(`SELECT id, display_name, email_verified FROM supporter_users WHERE email = ? AND status = 'active'`)
|
||||
.prepare(`SELECT id, display_name FROM supporter_users WHERE email = ? AND status = 'active'`)
|
||||
.get(email);
|
||||
// Bewusst IMMER ok:true nach außen (keine Auskunft, ob ein Konto existiert).
|
||||
if (!supporter || !supporter.email_verified) return json(res, { ok: true });
|
||||
// Die frühere zusätzliche Bedingung "nur wenn email_verified" ist hier
|
||||
// entfallen: genau die Konten aus der Zeit vor der Umstellung (die nie
|
||||
// bestätigt wurden und noch keinen eigenen Code haben) brauchen diesen Weg
|
||||
// am dringendsten — sonst kämen sie gar nicht mehr rein.
|
||||
if (!supporter) return json(res, { ok: true });
|
||||
|
||||
const code = await issueAuthCode(supporter.id, "login");
|
||||
try {
|
||||
|
||||
@@ -27,12 +27,27 @@ function forbidden(res) {
|
||||
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
}
|
||||
|
||||
/* Erlaubte Auswahlbilder (21.08.2026). Muss mit der Liste in
|
||||
assets/js/data-stimmen-avatare.js übereinstimmen.
|
||||
|
||||
Warum überhaupt eine Liste auf dem Server? Weil /testimonials/submit
|
||||
öffentlich und ohne Login erreichbar ist. Ohne diese Prüfung könnte man
|
||||
in das Feld einen beliebigen Text schreiben (z.B. eine fremde Bild-
|
||||
Adresse oder etwas, das später als HTML/Adresse ausgewertet wird) und so
|
||||
auf einer freigegebenen Stimme fremde Inhalte einbetten. Es werden
|
||||
deshalb ausschließlich diese festen Ids angenommen. */
|
||||
const ERLAUBTE_AVATARE = [
|
||||
"husky-hoodie", "hase-hoodie", "husky-kopf", "husky-kosmos",
|
||||
"hase-kosmos", "casper", "casper-blick", "logo",
|
||||
];
|
||||
|
||||
function oeffentlichesFeld(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
tiktokUsername: row.tiktok_username,
|
||||
nachricht: row.nachricht,
|
||||
avatar: row.avatar || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,6 +57,7 @@ function vollesFeld(row) {
|
||||
name: row.name,
|
||||
tiktokUsername: row.tiktok_username,
|
||||
nachricht: row.nachricht,
|
||||
avatar: row.avatar || null,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
entschiedenAm: row.entschieden_am,
|
||||
@@ -63,15 +79,57 @@ export async function submitTestimonial(req, res) {
|
||||
if (!nachricht) return json(res, { ok: false, error: "Bitte schreib eine Nachricht." }, 400);
|
||||
if (nachricht.length < 10) return json(res, { ok: false, error: "Die Nachricht ist noch sehr kurz -- magst du noch etwas mehr schreiben?" }, 400);
|
||||
|
||||
// Selbst gewähltes Auswahlbild. Unbekannte Werte werden still verworfen
|
||||
// (statt die Einreichung abzulehnen) — die Nachricht ist das Wichtige,
|
||||
// und ein fehlendes Bild ist kein Grund, jemanden abzuweisen.
|
||||
const gewaehlt = String(body.avatar || "").trim();
|
||||
const avatar = ERLAUBTE_AVATARE.includes(gewaehlt) ? gewaehlt : null;
|
||||
|
||||
const id = generateId();
|
||||
db.prepare(
|
||||
`INSERT INTO testimonials (id, name, tiktok_username, nachricht, status, created_at) VALUES (?, ?, ?, ?, 'eingereicht', ?)`
|
||||
).run(id, name, tiktokUsername, nachricht, nowIso());
|
||||
`INSERT INTO testimonials (id, name, tiktok_username, nachricht, avatar, status, created_at) VALUES (?, ?, ?, ?, ?, 'eingereicht', ?)`
|
||||
).run(id, name, tiktokUsername, nachricht, avatar, nowIso());
|
||||
|
||||
logAction("anonym", "testimonial.submitted", id, { name });
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/** Verwaltung: Profilbild einer Stimme setzen oder entfernen.
|
||||
Erlaubt hier ZUSÄTZLICH zu den Auswahlbildern eine hochgeladene Datei
|
||||
(siehe uploadTestimonialAvatar) — anders als beim öffentlichen Formular
|
||||
ist hier eine angemeldete, berechtigte Person am Werk. */
|
||||
export async function setTestimonialAvatar(req, res, session) {
|
||||
if (!can(session, "TESTIMONIALS_MANAGE")) return forbidden(res);
|
||||
const id = String(req.body?.id || "");
|
||||
const roh = String(req.body?.avatar || "").trim();
|
||||
|
||||
let avatar = null;
|
||||
if (roh) {
|
||||
const istEigenesFoto = roh.startsWith("https://postfach.dogfather-universe.com/uploads/testimonials/");
|
||||
if (!ERLAUBTE_AVATARE.includes(roh) && !istEigenesFoto) {
|
||||
return json(res, { ok: false, error: "Unbekanntes Profilbild." }, 400);
|
||||
}
|
||||
avatar = roh;
|
||||
}
|
||||
|
||||
const info = db.prepare(`UPDATE testimonials SET avatar = ? WHERE id = ?`).run(avatar, id);
|
||||
if (info.changes === 0) return json(res, { ok: false, error: "Nicht gefunden." }, 404);
|
||||
logAction(session.actor, "testimonial.avatar_changed", id, { avatar });
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/** NUR Datei-Upload (multipart, siehe index.js). Für den Fall, dass jemand
|
||||
Dogi sein Foto auf anderem Weg geschickt hat (TikTok, WhatsApp …) —
|
||||
das öffentliche Formular selbst nimmt bewusst KEINE Dateien an, sonst
|
||||
könnte ohne Anmeldung beliebiges Material hochgeladen werden. */
|
||||
export async function uploadTestimonialAvatar(req, res, session) {
|
||||
if (!can(session, "TESTIMONIALS_MANAGE")) return forbidden(res);
|
||||
if (!req.file) return json(res, { ok: false, error: "Keine Bilddatei erhalten." }, 400);
|
||||
const url = `https://postfach.dogfather-universe.com/uploads/testimonials/${req.file.filename}`;
|
||||
logAction(session.actor, "testimonial.avatar_uploaded", req.file.filename, null);
|
||||
return json(res, { ok: true, url });
|
||||
}
|
||||
|
||||
/** Öffentlich, keine Session: nur freigegebene Stimmen, neueste zuerst. */
|
||||
export async function getApprovedTestimonialsPublic(req, res) {
|
||||
const rows = db
|
||||
|
||||
@@ -58,6 +58,80 @@
|
||||
.poll-bar-track { height: 12px; border-radius: 999px; background: rgba(255,255,255,.06); overflow: hidden; border: 1px solid var(--border); }
|
||||
.poll-bar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--accent-2), var(--accent)); transition: width .4s ease; }
|
||||
|
||||
/* =====================================================================
|
||||
Abschnitts-Kopf + Abmelde-Knopf (21.08.2026, Nutzer-Wunsch: "ich will
|
||||
da einen richtig geilen abmelde button").
|
||||
|
||||
Farbwahl bewusst NICHT Gold: Gold ist auf der ganzen Seite die Farbe
|
||||
für "das ist deine Belohnung / das willst du" (Prämien, Preis, Abo).
|
||||
Ausloggen ist das Gegenteil — ein Weggehen. Deshalb ein gedecktes
|
||||
Rosé/Rot aus derselben Edelstein-Familie wie die Verwaltungsseite:
|
||||
eindeutig unterscheidbar, aber nicht das grelle Warnrot einer
|
||||
Fehlermeldung (Projektregel "augenschonend"). Der Glanzstreifen läuft
|
||||
nur beim Überfahren durch, nicht dauerhaft — ein permanent blinkender
|
||||
Knopf in der Seitenmitte würde beim Lesen stören.
|
||||
===================================================================== */
|
||||
.supp-abschnitt-kopf {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 1rem 1.4rem; flex-wrap: wrap;
|
||||
/* Nutzer-Hinweis 21.08.2026: "der abstand soll perfekter sein, teilweise
|
||||
berühren die sich wenn man mit der maus über die kacheln geht."
|
||||
Ursache war das margin:0 am h2 unten — dadurch lag der Kopf direkt auf
|
||||
der Karte. Der reine Kasten-Abstand reicht hier aber nicht als Maß:
|
||||
der Knopf wirft beim Überfahren einen Schatten von rund 32px nach
|
||||
unten, und die Kacheln heben sich ihrerseits 4px an und leuchten
|
||||
26px weit nach oben — beides zusammen berührte sich. Nachgemessen:
|
||||
mit diesem Wert bleiben auch im ungünstigsten Fall (beide Hover-
|
||||
Zustände gleichzeitig) deutlich sichtbare Luft dazwischen. */
|
||||
margin: 0 0 1.9rem;
|
||||
}
|
||||
/* margin:0 nötig, damit die Überschrift in der Flex-Zeile sauber auf einer
|
||||
Linie mit dem Knopf sitzt — der Abstand nach unten kommt stattdessen vom
|
||||
Container oben. */
|
||||
.supp-abschnitt-kopf h2 { margin: 0; }
|
||||
|
||||
.supp-logout {
|
||||
position: relative; isolation: isolate; overflow: hidden;
|
||||
display: inline-flex; align-items: center; gap: .55rem;
|
||||
padding: .72rem 1.35rem; border-radius: 999px;
|
||||
font-weight: 800; font-size: .92rem; letter-spacing: .01em;
|
||||
cursor: pointer; white-space: nowrap;
|
||||
color: #ffd9df;
|
||||
border: 1.5px solid rgba(255,138,164,.45);
|
||||
background: linear-gradient(140deg, rgba(74,26,38,.95), rgba(38,20,26,.95));
|
||||
box-shadow: 0 8px 22px -14px rgba(255,110,140,.8), inset 0 1px 0 rgba(255,255,255,.06);
|
||||
transition: transform .18s ease, box-shadow .22s ease, border-color .22s ease, color .22s ease;
|
||||
}
|
||||
/* Durchlaufender Glanz — startet außerhalb und wandert beim Hover einmal
|
||||
quer über den Knopf. */
|
||||
.supp-logout-glanz {
|
||||
position: absolute; inset: 0; z-index: 1; pointer-events: none;
|
||||
background: linear-gradient(100deg, transparent 34%, rgba(255,255,255,.42) 50%, transparent 66%);
|
||||
background-size: 260% 100%;
|
||||
background-position: 140% 0;
|
||||
transition: background-position .75s ease;
|
||||
}
|
||||
.supp-logout > *:not(.supp-logout-glanz) { position: relative; z-index: 2; }
|
||||
.supp-logout-icon { font-size: 1.02rem; line-height: 1; }
|
||||
.supp-logout:hover {
|
||||
transform: translateY(-2px);
|
||||
color: #fff;
|
||||
border-color: rgba(255,158,180,.85);
|
||||
box-shadow: 0 14px 30px -12px rgba(255,110,140,.75), 0 0 22px rgba(255,110,140,.28), inset 0 1px 0 rgba(255,255,255,.1);
|
||||
}
|
||||
.supp-logout:hover .supp-logout-glanz { background-position: -60% 0; }
|
||||
.supp-logout:active { transform: translateY(0) scale(.985); }
|
||||
.supp-logout:focus-visible { outline: 2px solid rgba(255,158,180,.9); outline-offset: 3px; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.supp-logout, .supp-logout-glanz { transition: none; }
|
||||
.supp-logout:hover { transform: none; }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
/* Auf schmalen Handys volle Breite statt gequetscht neben der Überschrift. */
|
||||
.supp-abschnitt-kopf { align-items: stretch; }
|
||||
.supp-logout { width: 100%; justify-content: center; }
|
||||
}
|
||||
|
||||
/* Gemeinsamer Karten-Rahmen mit Gold-Schimmerlinie oben — dieselbe
|
||||
Machart wie .abo-preis-box auf abonnieren.html, damit der Bereich
|
||||
erkennbar zusammengehört. */
|
||||
@@ -322,12 +396,12 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-abonnieren.jpg');--page-bg-mobile:url('/assets/img/bg-abonnieren-mobile.jpg');">
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-supporter.jpg');--page-bg-mobile:url('/assets/img/bg-supporter-mobile.jpg');">
|
||||
<div id="site-header"></div>
|
||||
|
||||
<main>
|
||||
<section class="hero page-bg">
|
||||
<div class="hero-banner"><img src="/assets/img/bg-abonnieren.jpg" alt="Dogfather Supporter-Abo" loading="eager" /></div>
|
||||
<div class="hero-banner"><img src="/assets/img/bg-supporter.jpg" alt="Dogfather Supporter-Abo" loading="eager" /></div>
|
||||
<div class="container">
|
||||
<span class="eyebrow" data-i18n="su_eyebrow">👑 Supporter-Bereich</span>
|
||||
<h1 id="supp-begruessung" data-i18n="su_lade">Dein Supporter-Bereich</h1>
|
||||
@@ -351,7 +425,20 @@
|
||||
<p class="abo-fehler" id="poll-fehler"></p>
|
||||
</div>
|
||||
|
||||
<h2 data-i18n="su_fenster1_h2">Mein Abo</h2>
|
||||
<!-- Nutzer-Wunsch 21.08.2026: "ich will da einen richtig geilen abmelde
|
||||
button." Sitzt bewusst HIER oben neben der Überschrift statt wie
|
||||
vorher ganz unten unter "Abo verwalten" — dort war er als schlichter
|
||||
Umriss-Knopf kaum zu finden. Der alte wurde entfernt statt zusätzlich
|
||||
stehen zu lassen: zwei Abmelde-Knöpfe auf einer Seite wären genau die
|
||||
Dopplung, die schon bei "2 mal registrieren" gestört hat. -->
|
||||
<div class="supp-abschnitt-kopf">
|
||||
<h2 data-i18n="su_fenster1_h2">Mein Abo</h2>
|
||||
<button type="button" class="supp-logout" id="btn-logout">
|
||||
<span class="supp-logout-glanz" aria-hidden="true"></span>
|
||||
<span class="supp-logout-icon" aria-hidden="true">⏻</span>
|
||||
<span class="supp-logout-text" data-i18n="su_btn_logout">Ausloggen</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card supp-card">
|
||||
<div class="supp-stats">
|
||||
<div class="supp-stat"><span class="supp-stat-icon" aria-hidden="true">🪪</span><div><div class="label" data-i18n="su_stat_status">Status</div><div class="value" id="stat-status">–</div></div></div>
|
||||
@@ -360,9 +447,20 @@
|
||||
<div class="supp-stat supp-stat-clickable" id="stat-card-monate" tabindex="0" role="button" aria-haspopup="dialog"><span class="supp-stat-icon" aria-hidden="true">🗓️</span><div><div class="label" data-i18n="su_stat_monate">Bezahlte Monate gesamt</div><div class="value" id="stat-monate">–</div><div class="supp-stat-hinweis" data-i18n="su_stat_details_hinweis">Details ansehen</div></div></div>
|
||||
<div class="supp-stat"><span class="supp-stat-icon" aria-hidden="true">🔄</span><div><div class="label" data-i18n="su_stat_zyklus">Aktueller Zyklus</div><div class="value" id="stat-zyklus">–</div></div></div>
|
||||
</div>
|
||||
<!-- Kein aktives Abo. Der frühere Knopf "Jetzt abonnieren" führte auf
|
||||
abonnieren.html — die schickt eingeloggte Leute seit 21.08.2026
|
||||
aber direkt wieder hierher zurück (siehe pruefeStatus() dort), das
|
||||
wäre also eine Schleife gewesen. Solange die Bezahlung ohnehin
|
||||
gesperrt ist, steht hier deshalb derselbe "Coming soon…"-Hinweis
|
||||
wie auf der Anmeldeseite — ehrlicher und ohne Sackgasse. Sobald
|
||||
PayPal live ist, wird hieraus wieder ein echter Knopf. -->
|
||||
<div id="supp-keine-abo" style="display:none;margin-top:1.2rem;">
|
||||
<p class="small" data-i18n="su_kein_abo_text">Du hast noch kein aktives Abo.</p>
|
||||
<a class="btn btn-primary" href="abonnieren.html" data-i18n="su_kein_abo_btn">Jetzt abonnieren</a>
|
||||
<div class="btn-gold-locked" style="max-width:320px;margin-top:.6rem;cursor:default;">
|
||||
<span class="btn-gold-locked-lock" aria-hidden="true">🔒</span>
|
||||
<span class="btn-gold-locked-text" data-i18n="su_kein_abo_soon">Coming soon…</span>
|
||||
</div>
|
||||
<p class="small muted" style="margin-top:.7rem;" data-i18n="su_kein_abo_hinweis">Alles hier ist schon für dich vorbereitet — sobald das Abo startet, kannst du es mit einem Klick aktivieren.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -388,9 +486,10 @@
|
||||
<button type="button" class="btn btn-outline" id="btn-save-profile" data-i18n="su_btn_profil_speichern">Speichern</button>
|
||||
<p class="abo-erfolg" id="profil-erfolg" data-i18n="su_profil_gespeichert">Gespeichert.</p>
|
||||
|
||||
<!-- "Ausloggen" ist von hier nach oben neben die Überschrift "Mein Abo"
|
||||
gewandert (21.08.2026) — siehe dort. -->
|
||||
<div class="btn-row" style="margin-top:1.6rem;">
|
||||
<button type="button" class="btn btn-outline" id="btn-cancel" data-i18n="su_btn_kuendigen">Abo kündigen</button>
|
||||
<button type="button" class="btn btn-outline" id="btn-logout" data-i18n="su_btn_logout">Ausloggen</button>
|
||||
</div>
|
||||
<p class="abo-fehler" id="verwalten-fehler"></p>
|
||||
</div>
|
||||
@@ -550,13 +649,29 @@
|
||||
document.getElementById("supp-begruessung").textContent = `Willkommen, ${data.profile.displayName}!`;
|
||||
|
||||
const sub = data.subscription;
|
||||
/* Läuft überhaupt ein Abo? Entscheidet, ob der nächste Meilenstein als
|
||||
"läuft schon" (✨ + Countdown) oder als gesperrt (🔒) dargestellt wird.
|
||||
Dieselbe Statusliste wie beim Nav-Knopf in main.js — "cancelled" zählt
|
||||
bewusst noch dazu, weil der Zugang bis zum Ende des bezahlten Zeitraums
|
||||
bestehen bleibt und in dieser Zeit auch noch Monate dazukommen können. */
|
||||
const aboLaeuft = ["active", "pending", "suspended", "cancelled"].includes(sub.status);
|
||||
document.getElementById("stat-status").innerHTML =
|
||||
`<span class="supp-badge ${sub.status}">${STATUS_LABELS[sub.status] || sub.status}</span>`;
|
||||
document.getElementById("stat-preis").textContent = sub.monthlyPrice;
|
||||
/* Nutzer-Wunsch 21.08.2026: "da soll noch kein preis stehen bitte."
|
||||
Der Preis ist überall sonst bewusst noch verborgen (auf abonnieren.html
|
||||
steht an seiner Stelle "Coming soon…", siehe .abo-preis-zahl dort) —
|
||||
über diese Kachel wäre er sonst durch die Hintertür doch schon
|
||||
öffentlich gewesen. Wer bereits zahlt, MUSS seinen Preis dagegen sehen
|
||||
können, deshalb nur ausblenden solange kein Abo läuft. */
|
||||
document.getElementById("stat-preis").textContent = aboLaeuft ? sub.monthlyPrice : "Coming soon…";
|
||||
document.getElementById("stat-naechste").textContent = fmtDatum(sub.nextPaymentAt);
|
||||
document.getElementById("stat-monate").textContent = sub.totalPaidMonths;
|
||||
document.getElementById("stat-zyklus").textContent = `Zyklus ${data.progress.cycleNumber}`;
|
||||
document.getElementById("supp-keine-abo").style.display = sub.status === "none" ? "" : "none";
|
||||
// Nutzer-Wunsch 21.08.2026: Kündigen-Knopf ergibt nur Sinn, wenn
|
||||
// überhaupt ein (noch nicht schon gekündigtes) Abo existiert -- sonst
|
||||
// sieht man einen Knopf für etwas, das es gar nicht gibt.
|
||||
document.getElementById("btn-cancel").style.display = ["none", "cancelled", "expired"].includes(sub.status) ? "none" : "";
|
||||
|
||||
if (sub.status === "cancelled") {
|
||||
const hinweis = document.getElementById("supp-kuendigungs-hinweis");
|
||||
@@ -571,10 +686,17 @@
|
||||
const zeitleisteEl = document.getElementById("supp-zeitleiste");
|
||||
zeitleisteEl.innerHTML = t.milestones
|
||||
.map((m) => {
|
||||
const klasse = m.reached ? "erreicht" : m.isNext ? "naechste" : "";
|
||||
const status = m.reached ? "Freigeschaltet" : m.isNext ? `Noch ${m.monthsRemaining} Monate` : "Gesperrt";
|
||||
// Nutzer-Wunsch 21.08.2026: "die erste kiste soll blockiert sein wie
|
||||
// die anderen, denn das ist ja verbunden mit dem abo." Ohne laufendes
|
||||
// Abo läuft auch kein Countdown -- die Rechnung "noch 6 Monate" wäre
|
||||
// schlicht falsch, weil der erste dieser Monate noch gar nicht
|
||||
// angefangen hat. Deshalb: ohne Abo ist ALLES gesperrt, inklusive des
|
||||
// ersten Meilensteins. Siehe aboLaeuft weiter oben.
|
||||
const naechste = m.isNext && aboLaeuft;
|
||||
const klasse = m.reached ? "erreicht" : naechste ? "naechste" : "";
|
||||
const status = m.reached ? "Freigeschaltet" : naechste ? `Noch ${m.monthsRemaining} Monate` : "Gesperrt";
|
||||
return `<div class="supp-meilenstein ${klasse}">
|
||||
<div class="icon-medaille">${m.reached ? "🎁" : m.isNext ? "✨" : "🔒"}</div>
|
||||
<div class="icon-medaille">${m.reached ? "🎁" : naechste ? "✨" : "🔒"}</div>
|
||||
<div class="monat">Monat ${m.month}</div>
|
||||
<div class="titel">${REDEEM_LABELS[m.type]}</div>
|
||||
<div class="supp-premium-status">${status}</div>
|
||||
@@ -775,6 +897,9 @@
|
||||
|
||||
function modalZahlungHtml(data) {
|
||||
const sub = data.subscription;
|
||||
// Gleiche Regel wie auf der Preis-Kachel: ohne laufendes Abo bleibt der
|
||||
// Preis verborgen (siehe Kommentar bei stat-preis).
|
||||
const aboLaeuftZahlung = ["active", "pending", "suspended", "cancelled"].includes(sub.status);
|
||||
const payments = data.payments || [];
|
||||
const gesamtBezahlt = payments
|
||||
.filter((p) => p.payment_status === "completed")
|
||||
@@ -801,7 +926,10 @@
|
||||
</div>
|
||||
<div class="supp-modal-grid">
|
||||
<div><div class="supp-modal-label">Nächste Zahlung</div><div class="supp-modal-value">${fmtDatum(sub.nextPaymentAt)}</div></div>
|
||||
<div><div class="supp-modal-label">Betrag</div><div class="supp-modal-value">${sub.monthlyPrice}</div></div>
|
||||
<!-- Preis auch hier verbergen, solange kein Abo läuft (21.08.2026) —
|
||||
sonst wäre er über den Klick auf "Nächste Zahlung" doch wieder
|
||||
sichtbar, obwohl er auf der Kachel daneben schon verborgen ist. -->
|
||||
<div><div class="supp-modal-label">Betrag</div><div class="supp-modal-value">${aboLaeuftZahlung ? sub.monthlyPrice : "Coming soon…"}</div></div>
|
||||
<div><div class="supp-modal-label">Abo läuft seit</div><div class="supp-modal-value">${fmtDatum(sub.startedAt)}</div></div>
|
||||
<div><div class="supp-modal-label">Zahlungsmethode</div><div class="supp-modal-value">PayPal</div></div>
|
||||
${sub.cancelledAt ? `<div><div class="supp-modal-label">Gekündigt am</div><div class="supp-modal-value">${fmtDatum(sub.cancelledAt)}</div></div>` : ""}
|
||||
@@ -816,15 +944,20 @@
|
||||
function modalFortschrittHtml(data) {
|
||||
const t = data.timeline;
|
||||
const p = data.progress;
|
||||
const naechster = t.milestones.find((m) => m.isNext);
|
||||
// Gleiche Regel wie in der Zeitleiste oben (21.08.2026): ohne laufendes
|
||||
// Abo ist auch der erste Meilenstein gesperrt, sonst stünden hier und
|
||||
// dort widersprüchliche Angaben.
|
||||
const aboLaeuftModal = ["active", "pending", "suspended", "cancelled"].includes(data.subscription.status);
|
||||
const naechster = aboLaeuftModal ? t.milestones.find((m) => m.isNext) : null;
|
||||
const prozent = Math.round((t.currentCycleMonth / 30) * 100);
|
||||
|
||||
const meilensteineHtml = t.milestones
|
||||
.map((m) => {
|
||||
const klasse = m.reached ? "erreicht" : m.isNext ? "naechste" : "";
|
||||
const status = m.reached ? "Freigeschaltet" : m.isNext ? `noch ${m.monthsRemaining} Monate` : "gesperrt";
|
||||
const naechste = m.isNext && aboLaeuftModal;
|
||||
const klasse = m.reached ? "erreicht" : naechste ? "naechste" : "";
|
||||
const status = m.reached ? "Freigeschaltet" : naechste ? `noch ${m.monthsRemaining} Monate` : "gesperrt";
|
||||
return `<div class="supp-modal-meilenstein ${klasse}">
|
||||
<span class="icon-medaille-mini" aria-hidden="true">${m.reached ? "🎁" : m.isNext ? "✨" : "🔒"}</span>
|
||||
<span class="icon-medaille-mini" aria-hidden="true">${m.reached ? "🎁" : naechste ? "✨" : "🔒"}</span>
|
||||
<div>
|
||||
<div class="titel">${REDEEM_LABELS[m.type]}</div>
|
||||
<div class="small" style="opacity:.75;">Monat ${m.month} · ${status}</div>
|
||||
@@ -847,7 +980,12 @@
|
||||
: ""
|
||||
}
|
||||
${
|
||||
naechster
|
||||
!aboLaeuftModal
|
||||
? /* Ohne Abo darf hier NICHT "Zyklus komplett" stehen (das wäre der
|
||||
Fall, wenn wirklich alle Meilensteine erreicht wären) — es hat
|
||||
ja noch gar nichts angefangen. */
|
||||
`<p class="small" style="margin-top:.9rem;">Alle Meilensteine sind noch gesperrt — sie starten, sobald dein Abo läuft.</p>`
|
||||
: naechster
|
||||
? `<p class="small" style="margin-top:.9rem;">Nächster Meilenstein: <strong>${REDEEM_LABELS[naechster.type]}</strong> in ${naechster.monthsRemaining} Monat${naechster.monthsRemaining === 1 ? "" : "en"}.</p>`
|
||||
: `<p class="small" style="margin-top:.9rem;">Dieser Zyklus ist komplett — alle Prämien freigeschaltet! 🎉</p>`
|
||||
}
|
||||
@@ -870,7 +1008,16 @@
|
||||
// vorherige Zeile im selben Klick trotzdem schon das Overlay
|
||||
// sichtbar gemacht hätte. Zusätzlich try/catch um den Builder-Aufruf
|
||||
// selbst, mit sichtbarer Konsolen-Meldung statt stillem Fehlschlag.
|
||||
if (!letzteDaten || letzteDaten.subscription?.status === "none") return;
|
||||
/* Nutzer-Wunsch 21.08.2026: "sie sollen ALLES SEHEN KÖNNEN." Die
|
||||
frühere Bedingung blockierte das Detail-Fenster komplett, solange
|
||||
kein Abo lief — die Kacheln laden aber sichtbar mit "Details
|
||||
ansehen ›" zum Klicken ein, es passierte dann nur einfach nichts.
|
||||
Beide Fenster kommen mit "kein Abo" sauber zurecht: die
|
||||
Zahlungsübersicht zeigt "Noch keine Zahlungen erfasst.", die
|
||||
Fortschritts-Ansicht alle Meilensteine gesperrt plus passenden
|
||||
Hinweis (siehe modalFortschrittHtml). Deshalb bleibt hier nur noch
|
||||
die Absicherung gegen fehlende Daten. */
|
||||
if (!letzteDaten) return;
|
||||
try {
|
||||
oeffneModal(builder(letzteDaten));
|
||||
} catch (err) {
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821n" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821n" />
|
||||
</head>
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-dogfather.jpg');--page-bg-mobile:url('/assets/img/bg-dogfather-mobile.jpg');">
|
||||
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-modis.jpg');--page-bg-mobile:url('/assets/img/bg-modis.jpg');">
|
||||
<div id="site-header"></div>
|
||||
|
||||
<main>
|
||||
<section class="hero page-bg">
|
||||
<div class="hero-banner"><img src="/assets/img/bg-dogfather.jpg" alt="" loading="eager" /></div>
|
||||
<div class="hero-banner"><img src="/assets/img/bg-modis.jpg" alt="" loading="eager" /></div>
|
||||
<div class="container">
|
||||
<span class="eyebrow" data-i18n="tm_eyebrow">🩵 Team Dogi</span>
|
||||
<h1 data-i18n="tm_h1" data-site-text-key="seitentitel_modi">Das Modi-Team</h1>
|
||||
|
||||