Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61729d4f12 | ||
|
|
59343b459c | ||
|
|
e87aca3019 | ||
|
|
e331965bf1 | ||
|
|
9af0698056 | ||
|
|
84bf6e2a68 | ||
|
|
648a28ec11 | ||
|
|
50ba908317 | ||
|
|
5c049ce2d7 | ||
|
|
ea8fe6c6ab | ||
|
|
20f9b516fe | ||
|
|
a28cfbf08a | ||
|
|
1ab188dcb7 | ||
|
|
d23f04ad2a |
@@ -3,4 +3,5 @@
|
||||
.wrangler
|
||||
cloudflare-worker
|
||||
wrangler.toml
|
||||
gate-worker.js
|
||||
node_modules
|
||||
|
||||
@@ -5,3 +5,7 @@ node_modules/
|
||||
cloudflare-worker/.wrangler/
|
||||
cloudflare-worker/node_modules/
|
||||
.wrangler/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
server/node_modules/
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Seite nicht gefunden — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="DOGFATHER UNIVERSE — Streamer. Community. HasiDog. Manager." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/404.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/404.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Seite nicht gefunden — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="DOGFATHER UNIVERSE — Streamer. Community. HasiDog. Manager." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>Seite nicht gefunden — DOGFATHER UNIVERSE</title>
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
|
||||
@@ -1,33 +1,25 @@
|
||||
# DOGFATHER UNIVERSE — Go-Live-Anleitung
|
||||
|
||||
## ✅ Status: bereits live
|
||||
## ✅ Status: bereits live, eigene Domain seit 03.08.2026 angeschlossen
|
||||
|
||||
Die Website läuft schon — kostenlos, sofort teilbar, auf deinem eigenen Cloudflare-Account:
|
||||
Die Website läuft — kostenlos, auf deinem eigenen Cloudflare-Account, unter drei Adressen
|
||||
gleichzeitig (alle zeigen auf denselben Worker):
|
||||
|
||||
- **Website:** https://dogfather-universe.dogfather1608.workers.dev
|
||||
- **Eigene Domain:** https://dogfather-universe.com
|
||||
- **www-Variante:** https://www.dogfather-universe.com
|
||||
- **workers.dev (bleibt zusätzlich aktiv):** https://dogfather-universe.dogfather1608.workers.dev
|
||||
- **Bewerbungs-Postfach-Worker:** https://dogfather-universe-postfach.dogfather1608.workers.dev
|
||||
|
||||
Beide laufen als Cloudflare Worker (Static Assets + API-Worker), im kostenlosen Free-Tier. Du
|
||||
kannst den Link zur Website ab sofort verschicken. Sobald du eine eigene Domain hast, ist der
|
||||
Umzug dorthin reine Konfiguration — kein Code-Umbau, kein erneutes Deployen von Grund auf.
|
||||
Domain-Anbindung lief über `routes` mit `custom_domain = true` in `wrangler.toml` — da die Zone
|
||||
`dogfather-universe.com` schon vorher auf Cloudflare lag, waren DNS + SSL-Zertifikat sofort beim
|
||||
Deploy automatisch aktiv, kein manueller DNS-Schritt nötig. Alle
|
||||
`REPLACE-WITH-YOUR-DOMAIN.tld`-Platzhalter im Code sind bereits durch die echte Domain ersetzt
|
||||
(Social-Media-Vorschaubilder, Sitemap, robots.txt).
|
||||
|
||||
## Eigene Domain anschließen (sobald vorhanden)
|
||||
|
||||
1. Im [Cloudflare-Dashboard](https://dash.cloudflare.com) → **Workers & Pages** →
|
||||
`dogfather-universe` → Tab **Settings → Domains & Routes** → **Add** → eigene Domain eintragen.
|
||||
Läuft die Domain schon über Cloudflare, ist das in der Regel sofort aktiv (automatisches HTTPS).
|
||||
2. Domain-Platzhalter im Code ersetzen (nur relevant für Social-Media-Vorschaubilder, nicht für
|
||||
die Funktion der Seite selbst — die läuft so oder so):
|
||||
|
||||
```bash
|
||||
cd DogiHompage
|
||||
find . -type f \( -name "*.html" -o -name "*.xml" -o -name "*.txt" \) \
|
||||
-exec sed -i 's/REPLACE-WITH-YOUR-DOMAIN.tld/deine-domain.de/g' {} +
|
||||
npx wrangler deploy
|
||||
```
|
||||
3. Optional: Social-Preview-Bild `assets/img/og-cover.jpg` (1200×630px) ergänzen.
|
||||
4. CORS enger fassen: in `cloudflare-worker/src/worker.js` → `corsHeaders()` die eigene Domain statt
|
||||
`"*"` eintragen, dann `cd cloudflare-worker && npx wrangler deploy`.
|
||||
**Noch offen:** `assets/img/og-cover.jpg` (1200×630px Social-Preview-Bild) fehlt noch — optional,
|
||||
nicht blockierend. CORS im Postfach-Worker ist weiterhin bewusst offen (`"*"`) statt auf die
|
||||
Domain eingeschränkt, siehe TODO-Kommentar in `cloudflare-worker/src/lib/http.js` (eigener,
|
||||
größerer Umbau nötig, um keine Login-/Zahlungsendpunkte zu riskieren).
|
||||
|
||||
## Änderungen künftig live schalten
|
||||
|
||||
|
||||
@@ -0,0 +1,673 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="dogfather">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DogiCrew 🩵 — DOGFATHER UNIVERSE</title>
|
||||
<meta name="description" content="Unterstütze Dogfather dauerhaft mit der DogiCrew 🩵 und sammle exklusive Treueprämien — die Teddy-Kollektion entsteht in Kooperation mit VanVan." />
|
||||
<link rel="icon" type="image/png" href="assets/img/favicon.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#0b0d10" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="DogiCrew 🩵 — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Unterstütze Dogfather dauerhaft mit der DogiCrew 🩵 und sammle exklusive Treueprämien — die Teddy-Kollektion entsteht in Kooperation mit VanVan." />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/abonnieren.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="DogiCrew 🩵 — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="Unterstütze Dogfather dauerhaft mit der DogiCrew 🩵 und sammle exklusive Treueprämien — die Teddy-Kollektion entsteht in Kooperation mit VanVan." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<style>
|
||||
/* =====================================================================
|
||||
Preis-/Anmelde-Kiste — komplett neu gestaltet (Nutzer-Feedback
|
||||
03.08.2026: "das sieht ja richtig kake aus"). Ziel: dasselbe edle
|
||||
Gold-Motiv wie beim Nav-Abo-Knopf/.btn-silver, klare visuelle
|
||||
Hierarchie (Preis dominiert, Bedingungen dezent, Formular großzügig),
|
||||
spürbar mehr Luft zwischen den Elementen statt der bisherigen dichten
|
||||
Textwand. Bewusst KEIN max-width auf der Box selbst — soll exakt so
|
||||
breit sein wie der umgebende .container (1180px), siehe Kommentar
|
||||
unten bei .abo-preis-box.
|
||||
===================================================================== */
|
||||
.abo-preis-box {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
padding: 2.6rem clamp(1.4rem, 5vw, 3.2rem) 3rem;
|
||||
border: 1.5px solid rgba(212,175,55,.45);
|
||||
background:
|
||||
radial-gradient(circle at 22% 0%, rgba(212,175,55,.16), transparent 55%),
|
||||
radial-gradient(circle at 82% 100%, rgba(139,92,246,.12), transparent 55%),
|
||||
linear-gradient(180deg, rgba(23,27,33,.96), rgba(15,17,21,.98));
|
||||
box-shadow: 0 0 0 1px rgba(212,175,55,.06) inset, 0 30px 60px -30px rgba(0,0,0,.6);
|
||||
}
|
||||
/* Feiner, ruhig schimmernder Goldrand oben — reines Deko-Element, kein
|
||||
rotierendes Objekt (siehe dokumentierte Lektion: rotierende Ringe auf
|
||||
nicht-runden Formen verzerren sich, deshalb hier nur ein sanftes
|
||||
Auf-und-Ab-Glühen statt einer Drehung). */
|
||||
.abo-preis-box::before {
|
||||
content: "";
|
||||
position: absolute; top: 0; left: 10%; right: 10%; height: 1px; z-index: 2;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,214,102,.9), transparent);
|
||||
animation: abo-goldglanz 4.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes abo-goldglanz { 0%, 100% { opacity: .35; } 50% { opacity: 1; } }
|
||||
|
||||
/* Logo als leuchtendes Wasserzeichen im Kartenhintergrund (Nutzer-Wunsch
|
||||
03.08.2026: "mein Logo integriert, als Hintergrund, richtig geil,
|
||||
soll so geil dass man nicht davon wegkommt"). Freigestelltes Logo
|
||||
(logo-dogfather-transparent.png, siehe gate.html-Lektion: Original
|
||||
hatte einen weißen Hintergrund eingebrannt, hier bereits freigestellt)
|
||||
— groß, mittig, mit pulsierender Gold/Lila-Glut dahinter. Sitzt per
|
||||
z-index HINTER dem echten Karteninhalt (Preis/Formular), damit nichts
|
||||
an Lesbarkeit verliert; .abo-preis-box hat dafür isolation:isolate +
|
||||
overflow:hidden bekommen, damit die Glut nicht über den Kartenrand
|
||||
hinausblutet (dieselbe dokumentierte Lektion wie beim TikTok-Button-
|
||||
Glow). Bewusst KEINE Rotation des Logos selbst (nur Skalieren/
|
||||
Verblassen) — ein Markenlogo, das sich dreht, würde zeitweise auf dem
|
||||
Kopf stehen. */
|
||||
.abo-preis-box .abo-logo-glut {
|
||||
position: absolute; inset: -10%; z-index: -1; pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at 50% 28%, rgba(255,233,168,.4) 0%, rgba(212,175,55,.16) 32%, transparent 62%),
|
||||
radial-gradient(circle at 22% 75%, rgba(139,92,246,.22), transparent 55%),
|
||||
radial-gradient(circle at 80% 80%, rgba(143,217,234,.16), transparent 55%);
|
||||
animation: abo-logo-glut-puls 5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes abo-logo-glut-puls { 0%, 100% { opacity: .75; } 50% { opacity: 1; } }
|
||||
.abo-preis-box .abo-logo-wz {
|
||||
/* Bewusst NICHT inset:0 über die ganze (sehr hohe) Karte — dadurch
|
||||
wären background-position/mask-Prozentwerte relativ zur kompletten
|
||||
Kartenhöhe (inkl. Formular) berechnet und kaum kontrollierbar. Eine
|
||||
feste Zone oben (deckt Badge/Preis/Bedingungen ab) macht Position
|
||||
und Ausblenden vorhersagbar und garantiert, dass die Eingabefelder
|
||||
darunter sauber lesbar bleiben (Nutzer-Feedback: Logo lag vorher
|
||||
über dem Namensfeld). */
|
||||
position: absolute; top: 0; left: 0; right: 0; height: 420px; z-index: -1; pointer-events: none;
|
||||
background-image: url('assets/img/logo-dogfather-transparent.png');
|
||||
background-repeat: no-repeat;
|
||||
background-position: center 6%;
|
||||
background-size: min(62%, 320px) auto;
|
||||
opacity: .4;
|
||||
filter: drop-shadow(0 0 28px rgba(255,233,168,.65)) drop-shadow(0 0 56px rgba(139,92,246,.4));
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 45%, transparent 92%);
|
||||
mask-image: linear-gradient(to bottom, #000 0%, #000 45%, transparent 92%);
|
||||
animation: abo-logo-wz-atem 6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes abo-logo-wz-atem {
|
||||
0%, 100% { opacity: .32; transform: scale(.99); }
|
||||
50% { opacity: .48; transform: scale(1.03); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.abo-preis-box .abo-logo-glut, .abo-preis-box .abo-logo-wz, .abo-preis-box::before { animation: none; }
|
||||
}
|
||||
|
||||
.abo-preis-box .eyebrow {
|
||||
display: inline-flex; align-items: center; gap: .5rem;
|
||||
color: #ffe9a8; border-color: rgba(255,214,102,.5);
|
||||
font-size: 1.3rem; letter-spacing: .1em;
|
||||
}
|
||||
.abo-preis-zahl {
|
||||
font-size: clamp(2.6rem, 5vw, 3.4rem); font-weight: 800; color: #ffe9a8;
|
||||
margin: .6rem 0 .2rem; letter-spacing: -.01em;
|
||||
text-shadow: 0 0 28px rgba(255,214,102,.35);
|
||||
}
|
||||
.abo-preis-zahl small { font-size: 1.05rem; font-weight: 600; color: var(--text-muted); letter-spacing: 0; text-shadow: none; }
|
||||
|
||||
.abo-bedingungen {
|
||||
list-style: none; padding: 0; text-align: left; max-width: 460px;
|
||||
margin: 1.6rem auto 0; display: flex; flex-direction: column; gap: .55rem;
|
||||
}
|
||||
.abo-bedingungen li {
|
||||
display: flex; align-items: flex-start; gap: .6rem;
|
||||
color: var(--text-muted); font-size: .96rem; line-height: 1.5;
|
||||
}
|
||||
.abo-bedingungen li::before {
|
||||
content: "✓"; flex-shrink: 0; width: 1.35rem; height: 1.35rem; margin-top: .05rem;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: 50%; font-size: .78rem; font-weight: 800;
|
||||
color: #0b0d10; background: linear-gradient(135deg, #ffe9a8, #d4af37);
|
||||
}
|
||||
|
||||
/* Widerrufsrecht-Checkbox (siehe agb.html Ziffer 6): NICHT vorausgewählt,
|
||||
sperrt die PayPal-Zahlungsoptionen optisch + funktional (pointer-events),
|
||||
bis sie aktiv angehakt wurde. */
|
||||
.abo-widerruf-check {
|
||||
display: flex; align-items: flex-start; gap: .65rem; text-align: left;
|
||||
max-width: 460px; margin: 0 auto 1.1rem; padding: .85rem .95rem; border-radius: 12px;
|
||||
background: rgba(255,255,255,.03); border: 1.5px solid rgba(139,92,246,.35);
|
||||
font-size: .85rem; line-height: 1.5; color: var(--text-muted); cursor: pointer;
|
||||
}
|
||||
.abo-widerruf-check input[type="checkbox"] {
|
||||
flex-shrink: 0; width: 1.15rem; height: 1.15rem; margin-top: .15rem; cursor: pointer; accent-color: #8b5cf6;
|
||||
}
|
||||
.abo-widerruf-check a { color: var(--accent); }
|
||||
#paypal-button-container.paypal-gesperrt { opacity: .35; filter: grayscale(1); pointer-events: none; min-height: 45px; }
|
||||
#paypal-gesperrt-hinweis { text-align: center; margin-top: .5rem; }
|
||||
|
||||
.abo-panel { max-width: 440px; margin: 2.2rem auto 0; }
|
||||
.abo-panel[hidden] { display: none; }
|
||||
|
||||
.abo-field { margin-bottom: 1.2rem; text-align: left; }
|
||||
.abo-field label {
|
||||
display: block; font-size: .82rem; font-weight: 600; letter-spacing: .02em;
|
||||
color: var(--text-muted); margin-bottom: .45rem;
|
||||
}
|
||||
.abo-field input {
|
||||
display: block; width: 100%; box-sizing: border-box;
|
||||
padding: .9rem 1.05rem; border-radius: 12px;
|
||||
background: rgba(255,255,255,.035); border: 1.5px solid var(--border); color: var(--text);
|
||||
font-size: 1.02rem; transition: border-color .15s ease, box-shadow .15s ease, background .15s ease;
|
||||
}
|
||||
.abo-field input::placeholder { color: rgba(154,164,178,.55); }
|
||||
.abo-field input:focus {
|
||||
outline: none; border-color: rgba(212,175,55,.7); background: rgba(255,255,255,.055);
|
||||
box-shadow: 0 0 0 4px rgba(212,175,55,.12);
|
||||
}
|
||||
|
||||
/* Registrieren/Login-Knöpfe: eigener Gold-Foil-Stil statt des generischen
|
||||
.btn-primary (Nutzer-Feedback 03.08.2026: "viel spezieller" + Text
|
||||
zentriert). Gleiche sichere Technik wie überall im Projekt — die
|
||||
Fläche schimmert per background-position, NICHT per rotierendem Ring
|
||||
(der würde auf dieser Pillenform verzerren, siehe dokumentierte
|
||||
TikTok-Button-Lektion). */
|
||||
.abo-panel .btn {
|
||||
position: relative; isolation: isolate; overflow: hidden;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 1rem 1.4rem; border-radius: 14px; font-weight: 800; font-size: 1.04rem;
|
||||
color: #1a1408; text-decoration: none; cursor: pointer;
|
||||
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);
|
||||
transition: transform .2s ease, box-shadow .2s ease;
|
||||
}
|
||||
.abo-panel .btn::before { content: none; }
|
||||
.abo-panel .btn::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-panel .btn:hover {
|
||||
transform: translateY(-3px) scale(1.015);
|
||||
box-shadow: 0 16px 34px -10px rgba(212,175,55,.65), 0 0 26px rgba(212,175,55,.4);
|
||||
}
|
||||
@keyframes abo-btn-shine { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
|
||||
@keyframes abo-btn-glare { 0% { background-position: -80% 0; } 100% { background-position: 180% 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { .abo-panel .btn { animation: none; } .abo-panel .btn::after { animation: none; } }
|
||||
|
||||
.abo-fehler { color: #ff9a9a; font-size: .88rem; margin: .8rem 0 0; display: none; }
|
||||
.abo-fehler.zeige { display: block; }
|
||||
.abo-erfolg { color: #baf7d6; font-size: .88rem; margin: .8rem 0 0; display: none; }
|
||||
.abo-erfolg.zeige { display: block; }
|
||||
|
||||
.abo-switch { text-align: center; margin-top: 1.4rem; font-size: .92rem; color: var(--text-muted); }
|
||||
.abo-switch a { color: var(--accent); cursor: pointer; font-weight: 600; }
|
||||
.abo-switch a:hover { text-decoration: underline; }
|
||||
|
||||
.abo-oder {
|
||||
display: flex; align-items: center; gap: .8rem;
|
||||
color: var(--text-muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em;
|
||||
margin: .3rem 0 1.4rem;
|
||||
}
|
||||
.abo-oder::before, .abo-oder::after { content: ""; flex: 1; height: 1px; background: var(--border); }
|
||||
|
||||
/* Google liefert seinen Knopf als eigenes iframe aus — die Innenfarben
|
||||
(Schwarz/Weiß/Blau) lassen sich aus Sicherheitsgründen NICHT per CSS
|
||||
umfärben. "Größer und passend zur Seite" wird deshalb über einen
|
||||
eigenen Goldrahmen drumherum gelöst (derselbe Schimmer-Trick wie beim
|
||||
Preis-Rahmen) plus einer Vergrößerung des Google-Knopfs selbst per
|
||||
transform:scale — der Knopf bleibt dabei voll funktionsfähig, es wird
|
||||
nur optisch skaliert. */
|
||||
.abo-google-frame {
|
||||
display: flex; justify-content: center; margin: 0 auto 1.3rem; padding: .55rem 0;
|
||||
border-radius: 999px; border: 1.5px solid transparent;
|
||||
background: linear-gradient(rgba(20,23,29,.5), rgba(20,23,29,.5)) padding-box,
|
||||
linear-gradient(120deg, #d4af37, #ffe9a8, #d4af37, #fff2c9) border-box;
|
||||
background-size: 100% 100%, 260% 100%;
|
||||
animation: abo-btn-shine 6s ease-in-out infinite;
|
||||
box-shadow: 0 10px 26px -12px rgba(212,175,55,.5);
|
||||
}
|
||||
.abo-google-frame > div { transform: scale(1.18); transform-origin: center; }
|
||||
|
||||
.abo-vorteile { max-width: 520px; margin: 1.6rem auto 0; display: flex; flex-direction: column; gap: .6rem; }
|
||||
.abo-vorteile li { display: flex; gap: .65rem; align-items: baseline; color: var(--text-muted); font-size: .97rem; line-height: 1.5; }
|
||||
.abo-vorteile li::before { content: "👑"; flex-shrink: 0; }
|
||||
</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');">
|
||||
<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="container">
|
||||
<span class="eyebrow" data-i18n="ab_eyebrow">👑 DogiCrew 🩵</span>
|
||||
<h1 data-i18n="ab_h1">Mehr als ein Abo — deine Unterstützung wird Teil unserer Geschichte</h1>
|
||||
<p class="lead worlds" data-i18n="ab_lead">Kein gewöhnlicher Shop. Ein exklusives Dankeschön- und Treueprogramm für alle, die Dogfather dauerhaft unterstützen.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<div class="card abo-preis-box">
|
||||
<div class="abo-logo-glut" aria-hidden="true"></div>
|
||||
<div class="abo-logo-wz" aria-hidden="true"></div>
|
||||
<span class="eyebrow" data-i18n="ab_preis_eyebrow">DogiCrew 🩵</span>
|
||||
<div class="abo-preis-zahl">4,99 € <small data-i18n="ab_preis_pro_monat">/ Monat</small></div>
|
||||
<p class="small muted" style="margin-top:-.6rem;margin-bottom:1rem;">Keine gesondert ausgewiesene MwSt. gemäß Kleinunternehmerregelung (Art. 57 des luxemburgischen TVA-Gesetzes) — keine weiteren Kosten.</p>
|
||||
<ul class="abo-bedingungen">
|
||||
<li data-i18n="ab_bed1">Monatliche automatische Zahlung über PayPal</li>
|
||||
<li data-i18n="ab_bed2">Keine Mindestlaufzeit, jederzeit kündbar</li>
|
||||
<li data-i18n="ab_bed3">Kündigung zum Ende des bereits bezahlten Zeitraums</li>
|
||||
<li data-i18n="ab_bed4">Keine versteckten Zusatzkosten</li>
|
||||
</ul>
|
||||
|
||||
<!-- Zustand 1: lädt (kurz, bis localStorage/Session geprüft ist) -->
|
||||
<div class="abo-panel" id="panel-lade">
|
||||
<p class="small" data-i18n="ab_laedt">Einen Moment …</p>
|
||||
</div>
|
||||
|
||||
<!-- Professioneller Login: Google, sitzt über Registrierung/Login und
|
||||
wird zusammen mit ihnen ein-/ausgeblendet (siehe Skript unten).
|
||||
Ohne konfigurierte GOOGLE_CLIENT_ID bleibt der Bereich leer/
|
||||
unsichtbar — der normale E-Mail-Code-Weg funktioniert immer. -->
|
||||
<div class="abo-panel" id="panel-social-login" hidden>
|
||||
<!-- Datenschutz: Googles Skript (und damit eine Verbindung/IP-Übertragung
|
||||
zu Google) wird bewusst NICHT automatisch beim Seitenaufruf geladen,
|
||||
sondern erst nach explizitem Klick hier — echtes Opt-in statt
|
||||
versteckter Drittanbieter-Verbindung vor jeder Zustimmung. -->
|
||||
<div id="google-consent-wrap" style="text-align:center;margin-bottom:1.1rem;" hidden>
|
||||
<button type="button" id="google-consent-btn" class="btn btn-primary" style="width:100%;display:inline-flex;align-items:center;justify-content:center;gap:.6rem;">
|
||||
<svg width="20" height="20" viewBox="0 0 48 48" aria-hidden="true" style="flex-shrink:0;">
|
||||
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12s5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24s8.955,20,20,20s20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"/>
|
||||
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"/>
|
||||
<path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"/>
|
||||
<path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"/>
|
||||
</svg>
|
||||
<span data-i18n="ab_google_consent_btn">Mit Google anmelden</span>
|
||||
</button>
|
||||
<p class="small muted" style="margin-top:.5rem;" data-i18n="ab_google_consent_hint">Beim Klick wird eine Verbindung zu Google hergestellt (deine IP-Adresse wird dabei übertragen).</p>
|
||||
</div>
|
||||
<div class="abo-google-frame" id="google-frame-wrap" hidden>
|
||||
<div id="google-signin-button"></div>
|
||||
</div>
|
||||
<div class="abo-oder" data-i18n="ab_oder_email">oder mit E-Mail</div>
|
||||
</div>
|
||||
|
||||
<!-- Zustand 2: Registrierung -->
|
||||
<div class="abo-panel" id="panel-register" hidden>
|
||||
<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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" style="width:100%;" id="btn-register" data-i18n="ab_btn_register">Registrieren</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) -->
|
||||
<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" />
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" style="width:100%;" id="btn-login-request" data-i18n="ab_btn_login_code">Login-Code anfordern</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>
|
||||
</div>
|
||||
|
||||
<!-- Zustand 4: Code-Eingabe (nach Registrierung ODER Login-Anfrage) -->
|
||||
<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">
|
||||
<label for="code-input" data-i18n="ab_feld_code">Code</label>
|
||||
<input type="text" id="code-input" inputmode="numeric" maxlength="6" style="letter-spacing:4px;font-size:1.3rem;text-align:center;" />
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" style="width:100%;" id="btn-code-verify" data-i18n="ab_btn_code_verify">Bestätigen</button>
|
||||
<p class="abo-fehler" id="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) —
|
||||
das Geld landet dabei direkt auf Dogis eigenem PayPal-Konto. -->
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Zustand 6: bereits Supporter -->
|
||||
<div class="abo-panel" id="panel-already" hidden>
|
||||
<p class="abo-erfolg zeige" data-i18n="ab_bereits_supporter">Du bist bereits Supporter! 🩵</p>
|
||||
<a class="btn btn-primary" style="width:100%;" href="supporter.html" data-i18n="ab_btn_zum_bereich">Zum Supporter-Bereich</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="container grid grid-2">
|
||||
<div class="card">
|
||||
<p data-i18n="ab_text1">Dieses Abonnement ist kein gewöhnlicher Shop und kein einfacher Kauf. Es ist für die Menschen gedacht, die Dogfather dauerhaft unterstützen und ein besonderer Teil dieser gemeinsamen Reise sein möchten.</p>
|
||||
<p data-i18n="ab_text2">Für 4,99 € im Monat erhältst du Zugang zu einem exklusiven Supporter-Bereich, besonderen Inhalten und einer persönlichen Treuekollektion. Mit jedem erfolgreich bezahlten Meilenstein wächst deine Sammlung: vom kleinen Hasen-Teddy über besondere Jubiläumsprämien bis zum exklusiven Hoodie nach 30 Monaten. Danach beginnt für dich eine neue Kollektion — mit neuen Designs, neuen Erinnerungen und derselben Bedeutung.</p>
|
||||
<ul class="abo-vorteile">
|
||||
<li data-i18n="ab_vorteil1">Exklusiver Supporter-Bereich</li>
|
||||
<li data-i18n="ab_vorteil2">Kleiner Hasen-Teddy nach 6 bezahlten Monaten</li>
|
||||
<li data-i18n="ab_vorteil3">Mittelgroßer Hasen-Teddy nach 12 bezahlten Monaten</li>
|
||||
<li data-i18n="ab_vorteil4">Großer Hasen-Teddy nach 18 bezahlten Monaten</li>
|
||||
<li data-i18n="ab_vorteil5">Tasse & Autogrammkarte nach 24 bezahlten Monaten</li>
|
||||
<li data-i18n="ab_vorteil6">Exklusiver Hoodie nach 30 bezahlten Monaten</li>
|
||||
<li data-i18n="ab_vorteil7">Neue Supporter-Kollektion nach jedem abgeschlossenen Zyklus</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 data-i18n="ab_vanvan_h2">Die Teddy-Kollektion — in Kooperation mit VanVan</h2>
|
||||
<p data-i18n="ab_vanvan_text">Die DogiCrew 🩵 gehört Dogfather. Die Hasen-Teddys und die dazugehörigen Sammlerkollektionen entstehen jedoch in Zusammenarbeit mit VanVan — der rechten Hand von Dogfather, seiner wichtigsten Vertrauensperson und einem festen Bestandteil der Team-Dogi-Familie. Sie stehen für ihre besondere Rolle, den Zusammenhalt innerhalb der Community und die Verbindung zwischen Dogfather, VanVan und allen Menschen, die diesen gemeinsamen Weg dauerhaft unterstützen.</p>
|
||||
<div style="display:flex;justify-content:flex-end;margin-top:1.4rem;">
|
||||
<a class="btn-silver" href="#" aria-disabled="true" onclick="return false;"><span class="btn-silver-spark" aria-hidden="true">✨</span><span class="btn-silver-text" data-i18n="ab_kollektion_coming_soon">Coming soon…</span><span class="btn-silver-spark" aria-hidden="true">✨</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-abonnieren.js"></script>
|
||||
<script src="assets/js/supporter.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const S = window.DogiSupporter;
|
||||
const panels = ["panel-lade", "panel-register", "panel-login", "panel-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"];
|
||||
let publicConfig = {
|
||||
googleClientId: null,
|
||||
paypalClientId: null, paypalPlanId: null,
|
||||
};
|
||||
|
||||
function irgendeinSocialLoginAktiv() {
|
||||
return !!publicConfig.googleClientId;
|
||||
}
|
||||
|
||||
function zeige(id) {
|
||||
panels.forEach((p) => { document.getElementById(p).hidden = p !== id; });
|
||||
const socialArea = document.getElementById("panel-social-login");
|
||||
socialArea.hidden = !(irgendeinSocialLoginAktiv() && SOCIAL_LOGIN_PANELS.includes(id));
|
||||
// Bugfix/Rechts-Update 04.08.2026: PayPal-Zahlungsoptionen werden NICHT
|
||||
// mehr automatisch beim Anzeigen dieser Kiste geladen — erst nach
|
||||
// aktivem Ankreuzen der Widerrufsrecht-Checkbox (siehe
|
||||
// widerrufCheckbox-Listener unten). Bei jedem erneuten Aufruf dieser
|
||||
// Kiste wird die Checkbox zurückgesetzt, die Zustimmung gilt immer nur
|
||||
// für den jeweils aktuellen Besuch.
|
||||
if (id === "panel-subscribe") {
|
||||
const check = document.getElementById("widerruf-consent");
|
||||
if (check) check.checked = false;
|
||||
document.getElementById("paypal-button-container").classList.add("paypal-gesperrt");
|
||||
document.getElementById("paypal-gesperrt-hinweis").hidden = false;
|
||||
}
|
||||
}
|
||||
function fehlerZeigen(el, msg) {
|
||||
el.textContent = msg;
|
||||
el.classList.add("zeige");
|
||||
}
|
||||
function fehlerVerstecken(el) {
|
||||
el.classList.remove("zeige");
|
||||
}
|
||||
|
||||
let codePurpose = "verify_email"; // oder "login"
|
||||
let codeEmail = "";
|
||||
|
||||
async function pruefeStatus() {
|
||||
if (!S.getToken()) {
|
||||
zeige("panel-register");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await S.apiFetch("/supporter/me", {}, true);
|
||||
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");
|
||||
}
|
||||
} catch (err) {
|
||||
// Token ungültig/abgelaufen -> zurück zur Registrierung/Login
|
||||
S.clearToken();
|
||||
zeige("panel-register");
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- "Mit Google anmelden" (professioneller Login, Abschnitt
|
||||
"screen1" Feedback vom 03.08.2026) — komplett optional, taucht nur
|
||||
auf, wenn der Websitebetreiber eine GOOGLE_CLIENT_ID hinterlegt hat.
|
||||
Datenschutz (Nachtrag 04.08.2026): Googles Skript wird NICHT mehr
|
||||
automatisch beim Seitenaufruf nachgeladen — das würde eine Verbindung
|
||||
zu Google (inkl. IP-Übertragung) herstellen, bevor der/die Besucher:in
|
||||
überhaupt zugestimmt hat. Stattdessen zeigt initGoogleConsent() nur
|
||||
einen eigenen, klar beschrifteten Button; erst der echte Klick darauf
|
||||
lädt Googles Skript und rendert deren echten Button (initGoogleSignIn). */
|
||||
function initGoogleConsent() {
|
||||
const wrap = document.getElementById("google-consent-wrap");
|
||||
const btn = document.getElementById("google-consent-btn");
|
||||
if (!publicConfig.googleClientId || !wrap || !btn) return;
|
||||
wrap.hidden = false;
|
||||
btn.addEventListener("click", () => {
|
||||
wrap.hidden = true;
|
||||
document.getElementById("google-frame-wrap").hidden = false;
|
||||
initGoogleSignIn();
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
function initGoogleSignIn() {
|
||||
if (!publicConfig.googleClientId) return;
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://accounts.google.com/gsi/client";
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: publicConfig.googleClientId,
|
||||
callback: handleGoogleCredential,
|
||||
});
|
||||
window.google.accounts.id.renderButton(document.getElementById("google-signin-button"), {
|
||||
type: "standard", theme: "filled_black", size: "large", shape: "pill", text: "continue_with", width: 340,
|
||||
});
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
async function handleGoogleCredential(response) {
|
||||
const el = document.getElementById("register-fehler");
|
||||
fehlerVerstecken(el);
|
||||
try {
|
||||
const data = await S.apiFetch("/supporter/google-login", { credential: response.credential });
|
||||
S.setToken(data.token);
|
||||
await pruefeStatus();
|
||||
} catch (err) {
|
||||
fehlerZeigen(el, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- PayPal Smart Payment Buttons (Abschnitt "screen2" Feedback
|
||||
vom 03.08.2026: alle in Deutschland üblichen Zahlarten anzeigen — das
|
||||
übernimmt PayPal automatisch, sobald die echten Buttons statt einer
|
||||
reinen Weiterleitung genutzt werden). ---------- */
|
||||
let paypalSdkGeladen = false;
|
||||
// Zeitpunkt der Widerrufsrecht-Zustimmung (siehe Checkbox-Listener weiter
|
||||
// unten) — wird beim Abschluss an den Server mitgegeben, damit ein
|
||||
// Zeitstempel-Nachweis existiert (siehe Datenschutzerklärung Ziffer 7).
|
||||
let widerrufConsentAt = null;
|
||||
function ladePaypalButtons() {
|
||||
const fehlerEl = document.getElementById("subscribe-fehler");
|
||||
const ladeHinweis = document.getElementById("paypal-laedt");
|
||||
fehlerVerstecken(fehlerEl);
|
||||
ladeHinweis.hidden = false;
|
||||
ladeHinweis.textContent = "Zahlungsoptionen werden geladen …";
|
||||
if (!publicConfig.paypalClientId || !publicConfig.paypalPlanId) {
|
||||
ladeHinweis.textContent = "Das Bezahlsystem wird gerade eingerichtet — bitte später erneut versuchen.";
|
||||
return;
|
||||
}
|
||||
if (paypalSdkGeladen) {
|
||||
renderPaypalButtons();
|
||||
return;
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
script.src = `https://www.paypal.com/sdk/js?client-id=${encodeURIComponent(publicConfig.paypalClientId)}&vault=true&intent=subscription¤cy=EUR&locale=de_DE`;
|
||||
script.onload = () => { paypalSdkGeladen = true; renderPaypalButtons(); };
|
||||
script.onerror = () => { ladeHinweis.textContent = "PayPal konnte nicht geladen werden. Bitte Seite neu laden."; ladeHinweis.hidden = false; };
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
function renderPaypalButtons() {
|
||||
const container = document.getElementById("paypal-button-container");
|
||||
const ladeHinweis = document.getElementById("paypal-laedt");
|
||||
container.innerHTML = "";
|
||||
window.paypal
|
||||
.Buttons({
|
||||
style: { shape: "pill", color: "gold", layout: "vertical", label: "subscribe" },
|
||||
createSubscription: (data, actions) => actions.subscription.create({ plan_id: publicConfig.paypalPlanId }),
|
||||
onApprove: async (data) => {
|
||||
const fehlerEl = document.getElementById("subscribe-fehler");
|
||||
try {
|
||||
await S.apiFetch("/supporter/paypal/confirm", { subscriptionId: data.subscriptionID, widerrufConsentAt }, true);
|
||||
await pruefeStatus();
|
||||
} catch (err) {
|
||||
fehlerZeigen(fehlerEl, err.message);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
fehlerZeigen(document.getElementById("subscribe-fehler"), "PayPal-Fehler: " + (err?.message || "Unbekannter Fehler."));
|
||||
},
|
||||
})
|
||||
.render("#paypal-button-container");
|
||||
ladeHinweis.hidden = true;
|
||||
}
|
||||
|
||||
document.getElementById("switch-to-login").addEventListener("click", () => zeige("panel-login"));
|
||||
document.getElementById("switch-to-register").addEventListener("click", () => zeige("panel-register"));
|
||||
|
||||
// Widerrufsrecht-Checkbox: erst nach aktivem Ankreuzen werden die
|
||||
// PayPal-Zahlungsoptionen überhaupt geladen/klickbar (siehe agb.html
|
||||
// Ziffer 6 + Datenschutzerklärung Ziffer 7). Beim Abwählen wird alles
|
||||
// wieder gesperrt und der Zeitstempel verworfen.
|
||||
document.getElementById("widerruf-consent").addEventListener("change", (e) => {
|
||||
const container = document.getElementById("paypal-button-container");
|
||||
const gesperrtHinweis = document.getElementById("paypal-gesperrt-hinweis");
|
||||
if (e.target.checked) {
|
||||
widerrufConsentAt = new Date().toISOString();
|
||||
container.classList.remove("paypal-gesperrt");
|
||||
gesperrtHinweis.hidden = true;
|
||||
ladePaypalButtons();
|
||||
} else {
|
||||
widerrufConsentAt = null;
|
||||
container.classList.add("paypal-gesperrt");
|
||||
gesperrtHinweis.hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btn-register").addEventListener("click", async () => {
|
||||
const el = document.getElementById("register-fehler");
|
||||
fehlerVerstecken(el);
|
||||
const displayName = document.getElementById("reg-name").value.trim();
|
||||
const tiktokUsername = document.getElementById("reg-tiktok").value.trim();
|
||||
const email = document.getElementById("reg-email").value.trim();
|
||||
if (!displayName || !email) return fehlerZeigen(el, "Bitte Name und E-Mail-Adresse angeben.");
|
||||
try {
|
||||
await S.apiFetch("/supporter/register", { displayName, tiktokUsername, email });
|
||||
codePurpose = "verify_email";
|
||||
codeEmail = email;
|
||||
zeige("panel-code");
|
||||
} catch (err) {
|
||||
fehlerZeigen(el, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btn-login-request").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.");
|
||||
try {
|
||||
await S.apiFetch("/supporter/login-request", { email });
|
||||
codePurpose = "login";
|
||||
codeEmail = email;
|
||||
zeige("panel-code");
|
||||
} catch (err) {
|
||||
fehlerZeigen(el, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btn-code-verify").addEventListener("click", async () => {
|
||||
const el = document.getElementById("code-fehler");
|
||||
fehlerVerstecken(el);
|
||||
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 });
|
||||
S.setToken(data.token);
|
||||
await pruefeStatus();
|
||||
} catch (err) {
|
||||
fehlerZeigen(el, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const res = await fetch(`${S.API_BASE}/supporter/public-config`);
|
||||
const data = await res.json();
|
||||
if (data.ok) publicConfig = data;
|
||||
} catch {
|
||||
/* Konfiguration konnte nicht geladen werden — Google-Button/PayPal-Buttons bleiben dann aus. */
|
||||
}
|
||||
initGoogleConsent();
|
||||
await pruefeStatus();
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="dogfather">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AGB — DogiCrew-Supporter-Abo — DOGFATHER UNIVERSE</title>
|
||||
<meta name="description" content="Allgemeine Geschäftsbedingungen für das DogiCrew-Supporter-Abo von DOGFATHER UNIVERSE." />
|
||||
<link rel="icon" type="image/png" href="assets/img/favicon.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#0b0d10" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="AGB — DogiCrew-Supporter-Abo — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Allgemeine Geschäftsbedingungen für das DogiCrew-Supporter-Abo von DOGFATHER UNIVERSE." />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/agb.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="AGB — DogiCrew-Supporter-Abo — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="Allgemeine Geschäftsbedingungen für das DogiCrew-Supporter-Abo von DOGFATHER UNIVERSE." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta name="robots" content="noindex" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="site-header"></div>
|
||||
|
||||
<main>
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<span class="eyebrow">Rechtliches</span>
|
||||
<h1>Allgemeine Geschäftsbedingungen — DogiCrew-Supporter-Abo</h1>
|
||||
<p class="lead worlds">Gelten für den Abschluss und die Nutzung des kostenpflichtigen DogiCrew-Supporter-Abonnements auf dogfather-universe.com.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<p class="small muted">Rechtstexte werden auf Deutsch bereitgestellt, um Übersetzungsfehler bei rechtlich verbindlichen Angaben zu vermeiden. / Legal texts are provided in German only to avoid translation errors in legally binding information.</p>
|
||||
<div class="small muted" style="line-height:1.7;">
|
||||
|
||||
<p><strong>1. Geltungsbereich und Vertragspartner</strong><br>
|
||||
Diese Allgemeinen Geschäftsbedingungen (AGB) gelten für den Abschluss und die Durchführung des
|
||||
kostenpflichtigen „DogiCrew-Supporter-Abo" auf der Website dogfather-universe.com. Anbieter und
|
||||
Vertragspartner ist:<br>
|
||||
Filipe Pereira Queiroz, 3, rue Arthur Thinnes, L-3919 Mondercange, Luxemburg —
|
||||
<a href="mailto:[email protected]">[email protected]</a> (im Folgenden „Anbieter").
|
||||
Näheres siehe <a href="kontakt.html">Impressum</a>.</p>
|
||||
|
||||
<p><strong>2. Leistungsbeschreibung</strong><br>
|
||||
Das DogiCrew-Supporter-Abo gewährt gegen die in Ziffer 3 genannte monatliche Zahlung Zugang zu einem
|
||||
exklusiven, passwortgeschützten Supporter-Bereich mit besonderen Inhalten sowie eine persönliche
|
||||
Treuekollektion: Mit jedem vollständig bezahlten Monat wächst der Fortschritt der Supporter:in über
|
||||
einen 30-Monats-Zyklus, verbunden mit digitalen Meilensteinen und gelegentlichen physischen
|
||||
Zusatzprämien (z. B. Teddy, Jubiläums-Prämien, Hoodie nach 30 Monaten — Details siehe
|
||||
<a href="abonnieren.html">Abo-Seite</a>). Der genaue Inhalt der Kollektionen kann sich mit der Zeit
|
||||
ändern; der Kernumfang (Zugang zum Supporter-Bereich, digitale Inhalte) bleibt bestehen.</p>
|
||||
|
||||
<p><strong>3. Preis, Zahlung und Steuer</strong><br>
|
||||
Der Preis beträgt 4,99 € pro Monat, automatisch fällig und abgebucht über PayPal (PayPal-Konto oder
|
||||
Kredit-/Debitkarte als Gast, z. B. Visa/Mastercard, je nach Verfügbarkeit im jeweiligen Land). Der
|
||||
Anbieter ist eine Kleinstunternehmung mit Sitz in Luxemburg und macht derzeit von der
|
||||
Kleinunternehmerregelung für Kleinunternehmen Gebrauch (Art. 57 des geänderten luxemburgischen
|
||||
TVA-Gesetzes vom 12.02.1979 sowie der EU-weiten Kleinunternehmerregelung nach Richtlinie (EU)
|
||||
2020/285); der ausgewiesene Preis von 4,99 € enthält daher <strong>keine gesondert ausgewiesene
|
||||
Mehrwertsteuer</strong>. Sollte diese Regelung durch Überschreiten der maßgeblichen Umsatzschwellen
|
||||
künftig nicht mehr anwendbar sein, wird der Anbieter die Preisangabe entsprechend anpassen. Es
|
||||
entstehen keine weiteren, nicht genannten Kosten.</p>
|
||||
|
||||
<p><strong>4. Vertragsschluss und Laufzeit</strong><br>
|
||||
Der Vertrag kommt zustande, sobald die Supporter:in sich registriert, der Zahlung über PayPal
|
||||
zustimmt und die Checkbox zum sofortigen Leistungsbeginn (siehe Ziffer 6) bestätigt hat. Das Abo hat
|
||||
keine Mindestlaufzeit und verlängert sich automatisch um jeweils einen Monat, bis es gekündigt wird.</p>
|
||||
|
||||
<p><strong>5. Kündigung</strong><br>
|
||||
Das Abo kann jederzeit zum Ende des bereits bezahlten Zeitraums gekündigt werden — entweder im
|
||||
Supporter-Bereich der Website oder direkt über die eigenen PayPal-Zahlungseinstellungen. Bereits
|
||||
bezahlte Zeiträume werden bei Kündigung nicht anteilig erstattet; der Zugang bleibt bis zum Ende des
|
||||
bezahlten Zeitraums bestehen.</p>
|
||||
|
||||
<p><strong>6. Sofortiger Leistungsbeginn und Erlöschen des Widerrufsrechts</strong><br>
|
||||
Da es sich beim Supporter-Bereich um digitale Inhalte handelt, die nicht auf einem körperlichen
|
||||
Datenträger geliefert werden, gilt: Verbraucher:innen haben grundsätzlich ein 14-tägiges
|
||||
Widerrufsrecht bei außerhalb von Geschäftsräumen bzw. im Fernabsatz geschlossenen Verträgen. Dieses
|
||||
Widerrufsrecht <strong>erlischt vorzeitig</strong>, wenn der Anbieter mit der Ausführung des Vertrags
|
||||
begonnen hat, nachdem die Supporter:in ausdrücklich zugestimmt hat, dass mit der Ausführung vor Ablauf
|
||||
der Widerrufsfrist begonnen wird, und gleichzeitig bestätigt hat, dass sie dadurch ihr Widerrufsrecht
|
||||
verliert. Genau diese Zustimmung wird vor jeder Zahlung über eine eigene Checkbox auf der
|
||||
<a href="abonnieren.html">Abo-Seite</a> aktiv eingeholt (nicht vorausgewählt) und mitsamt Zeitstempel
|
||||
zur eigenen Nachricht gespeichert. Ohne diese Bestätigung wird keine Zahlung ausgelöst.</p>
|
||||
|
||||
<p><strong>7. Versand von physischen Zusatzprämien</strong><br>
|
||||
Für den Versand physischer Zusatzprämien (z. B. Teddy, Hoodie) wird im Supporter-Bereich freiwillig
|
||||
eine Versandadresse hinterlegt. Der Versand erfolgt an die zuletzt hinterlegte Adresse; für
|
||||
Fehlzustellungen aufgrund falscher oder veralteter Adressangaben sowie für Verzögerungen oder Verlust
|
||||
durch den beauftragten Versanddienstleister übernimmt der Anbieter keine Haftung, soweit ihn kein
|
||||
eigenes Verschulden trifft. Das Eigentum an einer physischen Prämie geht mit Übergabe an den
|
||||
Versanddienstleister über.</p>
|
||||
|
||||
<p><strong>8. Verfügbarkeit und Änderungen</strong><br>
|
||||
Der Anbieter bemüht sich um einen möglichst unterbrechungsfreien Zugang zum Supporter-Bereich,
|
||||
garantiert aber keine ununterbrochene Verfügbarkeit (u. a. abhängig von Drittanbietern wie Cloudflare
|
||||
und PayPal, siehe <a href="kontakt.html">Datenschutzerklärung</a>). Einzelne Inhalte, Meilensteine
|
||||
oder Prämien-Designs der Kollektionen können sich ändern, ohne dass dies den Kernanspruch auf Zugang
|
||||
zum Supporter-Bereich berührt.</p>
|
||||
|
||||
<p><strong>9. Haftung</strong><br>
|
||||
Der Anbieter haftet unbeschränkt für Vorsatz und grobe Fahrlässigkeit sowie nach den Vorschriften des
|
||||
Produkthaftungsgesetzes bzw. vergleichbarer Vorschriften. Für leichte Fahrlässigkeit haftet der
|
||||
Anbieter nur bei Verletzung einer wesentlichen Vertragspflicht (Kardinalpflicht) und begrenzt auf den
|
||||
vertragstypisch vorhersehbaren Schaden.</p>
|
||||
|
||||
<p><strong>10. Änderungen dieser AGB</strong><br>
|
||||
Der Anbieter kann diese AGB mit Wirkung für die Zukunft ändern, etwa bei Änderungen der Rechtslage
|
||||
oder des Leistungsumfangs. Über wesentliche Änderungen wird per E-Mail oder deutlich sichtbarem
|
||||
Hinweis im Supporter-Bereich informiert.</p>
|
||||
|
||||
<p><strong>11. Anwendbares Recht</strong><br>
|
||||
Es gilt luxemburgisches Recht unter Ausschluss des UN-Kaufrechts. Für Verbraucher:innen mit
|
||||
gewöhnlichem Aufenthalt in einem anderen EU-Mitgliedstaat bleiben zwingende verbraucherschützende
|
||||
Vorschriften dieses Staates unberührt (Art. 6 Rom-I-Verordnung).</p>
|
||||
|
||||
<p><strong>12. Schlussbestimmungen</strong><br>
|
||||
Sollte eine Bestimmung dieser AGB unwirksam sein, bleibt die Wirksamkeit der übrigen Bestimmungen
|
||||
unberührt. Stand: August 2026.</p>
|
||||
|
||||
<p class="small muted" style="margin-top:1.5rem;"><em>Hinweis: Dieser Text wurde nach bestem Wissen und aktuellem Stand der Website erstellt, ersetzt aber keine individuelle Rechtsberatung.</em></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -12,12 +12,12 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Aktion — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Detailansicht einer Aktion oder eines Projekts von Team Dogi." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/aktion-detail.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/aktion-detail.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Aktion — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="Detailansicht einer Aktion oder eines Projekts von Team Dogi." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
|
||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 349 KiB |
|
After Width: | Height: | Size: 349 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 344 KiB |
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 408 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,104 @@
|
||||
/* =====================================================================
|
||||
admin-auth.js — gemeinsame Session-Logik fürs interne Team-Login
|
||||
(dasselbe Owner-/Rollen-System wie schon fürs frühere Bewerbungs-
|
||||
Postfach).
|
||||
|
||||
Session (Token + Rolle/Rechte) liegt in localStorage (Nutzer-Wunsch
|
||||
04.08.2026: "den Zugangscode auf der Verwaltungsseite nur einmal am Tag
|
||||
eingeben müssen") — localStorage übersteht anders als sessionStorage
|
||||
auch das Schließen des Tabs/Browsers, sonst wäre "einmal am Tag" nicht
|
||||
möglich. Eigener 24-Stunden-Ablauf hier im Client (siehe SESSION_MAX_AGE_MS)
|
||||
zusätzlich zum server-seitigen Session-Timeout (lib/auth.js, ebenfalls
|
||||
24 Std) — beide zusammen sorgen dafür, dass wirklich niemand länger als
|
||||
einen Tag mit einem alten Code eingeloggt bleiben kann.
|
||||
|
||||
Ursprünglich für postfach.html/zugaenge.html gebaut (Bewerbungs-
|
||||
Verwaltung) — die wurden am 01.08.2026 auf Dogis Wunsch entfernt ("nicht
|
||||
mehr gebraucht"). Diese Datei selbst ist aber ein reines, seitenneutrales
|
||||
Login-Werkzeug (kein Bewerbungscode) und wurde am 04.08.2026 reaktiviert
|
||||
für verwaltung.html (Supporter-Abo-Administration) — nutzt exakt
|
||||
denselben, weiterhin voll funktionsfähigen Backend-Login
|
||||
(POST /auth/login), nur eine neue Seite drumherum.
|
||||
===================================================================== */
|
||||
|
||||
const SESSION_KEY = "dogfather_admin_session";
|
||||
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function getSession() {
|
||||
try {
|
||||
const session = JSON.parse(localStorage.getItem(SESSION_KEY) || "null");
|
||||
if (!session) return null;
|
||||
if (!session.loggedInAt || Date.now() - session.loggedInAt > SESSION_MAX_AGE_MS) {
|
||||
clearSession(); // älter als 24 Std -> als abgelaufen behandeln, neuer Code nötig
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setSession(data) {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify({ ...data, loggedInAt: Date.now() }));
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
|
||||
/** Prüft, ob die aktuelle Session (Owner oder Rolle+Overrides) ein Recht hat. */
|
||||
function sessionHasPermission(session, key) {
|
||||
if (!session) return false;
|
||||
if (session.isOwner) return true;
|
||||
const overrides = session.overrides || {};
|
||||
if (Object.prototype.hasOwnProperty.call(overrides, key)) return !!overrides[key];
|
||||
return Array.isArray(session.permissions) && session.permissions.includes(key);
|
||||
}
|
||||
|
||||
async function apiCall(apiBaseUrl, path, body, { auth = true } = {}) {
|
||||
const session = getSession();
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (auth && session?.token) headers["Authorization"] = `Bearer ${session.token}`;
|
||||
|
||||
const res = await fetch(`${apiBaseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
let data;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
data = { ok: false, error: "Ungültige Antwort vom Server." };
|
||||
}
|
||||
if (res.status === 401 && auth) {
|
||||
clearSession(); // Session ist abgelaufen/ungültig -> zurück zum Login zwingen
|
||||
}
|
||||
return { httpOk: res.ok, ...data };
|
||||
}
|
||||
|
||||
async function doLogin(apiBaseUrl, code) {
|
||||
const res = await apiCall(apiBaseUrl, "/auth/login", { code }, { auth: false });
|
||||
if (res.ok) {
|
||||
setSession({
|
||||
token: res.token,
|
||||
isOwner: res.isOwner,
|
||||
name: res.name,
|
||||
username: res.username,
|
||||
roleName: res.roleName,
|
||||
permissions: res.permissions || [],
|
||||
overrides: res.overrides || {},
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function doLogout(apiBaseUrl) {
|
||||
await apiCall(apiBaseUrl, "/auth/logout", {});
|
||||
clearSession();
|
||||
}
|
||||
|
||||
// Für Einbindung ohne Modul-Bundler global verfügbar machen.
|
||||
if (typeof window !== "undefined") {
|
||||
window.AdminAuth = { getSession, setSession, clearSession, sessionHasPermission, apiCall, doLogin, doLogout };
|
||||
}
|
||||
@@ -39,6 +39,80 @@ const SCOUTS = [
|
||||
fr: "Patrick est scout chez Team Dogi. Avec Bananenstift, il recherche et accompagne de nouveaux talents pour DogFather en tant que manager.",
|
||||
pt: "Patrick é scout na Team Dogi. Junto com Bananenstift, ele busca e acompanha novos talentos para o DogFather como manager.",
|
||||
},
|
||||
// Kurzer Einstiegstext, direkt NEBEN dem Foto (siehe profil.html /
|
||||
// person.intro). Die restliche, ausführlichere Vorstellung (bioHtml)
|
||||
// folgt darunter im breiten Bereich — gleiches Muster wie Bananenstift.
|
||||
intro: {
|
||||
de: `
|
||||
<p>Hallo, mein Name ist <strong>Patrick</strong>. Geboren wurde ich am <strong>18. Mai 1985</strong> im schönen Harz. 🏔️</p>
|
||||
<p>Seit Mai 2021 bin ich auf TikTok aktiv. Mein erstes Video veröffentlichte ich am <strong>29. Mai 2021</strong>. Zu diesem Zeitpunkt hätte ich vermutlich selbst nicht erwartet, wie viele besondere Erfahrungen, Begegnungen und Erinnerungen mich auf dieser Reise noch erwarten würden.</p>
|
||||
`,
|
||||
"de-CH": `
|
||||
<p>Hoi, ich heisse <strong>Patrick</strong>. Gebore bin ich am <strong>18. Mai 1985</strong> im schöne Harz. 🏔️</p>
|
||||
<p>Sit Mai 2021 bin ich uf TikTok aktiv. Mis erste Video han ich am <strong>29. Mai 2021</strong> veröffentlicht. Zu dem Zytpunkt hätt ich wahrschindlich sälber nöd erwartet, wie vill bsundrigi Erfahrige, Begägnige und Erinnerige mich uf dere Reise no erwarted.</p>
|
||||
`,
|
||||
en: `
|
||||
<p>Hi, my name is <strong>Patrick</strong>. I was born on <strong>May 18, 1985</strong> in the beautiful Harz region. 🏔️</p>
|
||||
<p>I've been active on TikTok since May 2021. I published my first video on <strong>May 29, 2021</strong>. Back then, I probably wouldn't have expected just how many special experiences, encounters, and memories this journey would still bring me.</p>
|
||||
`,
|
||||
fr: `
|
||||
<p>Bonjour, je m'appelle <strong>Patrick</strong>. Je suis né le <strong>18 mai 1985</strong> dans la belle région du Harz. 🏔️</p>
|
||||
<p>Je suis actif sur TikTok depuis mai 2021. J'ai publié ma première vidéo le <strong>29 mai 2021</strong>. À l'époque, je ne m'attendais probablement pas à toutes les expériences, rencontres et souvenirs particuliers que ce parcours allait encore m'offrir.</p>
|
||||
`,
|
||||
pt: `
|
||||
<p>Olá, o meu nome é <strong>Patrick</strong>. Nasci em <strong>18 de maio de 1985</strong>, na bela região do Harz. 🏔️</p>
|
||||
<p>Estou ativo no TikTok desde maio de 2021. Publiquei o meu primeiro vídeo em <strong>29 de maio de 2021</strong>. Naquela altura, provavelmente eu mesmo não esperava quantas experiências, encontros e memórias especiais essa jornada ainda me traria.</p>
|
||||
`,
|
||||
},
|
||||
// Ausführliche Fortsetzung von Patricks Vorstellung — erscheint UNTER
|
||||
// dem Foto im breiten Lesebereich (siehe profil.html / person.bioHtml).
|
||||
bioHtml: {
|
||||
de: `
|
||||
<p>Im Oktober 2024 wagte ich schließlich den nächsten Schritt und ging zum ersten Mal live. Seitdem durfte ich zahlreiche Menschen kennenlernen und einige von ihnen fest in mein Herz schließen. Genau diese Begegnungen und der persönliche Austausch machen TikTok für mich zu etwas Besonderem.</p>
|
||||
<p>Auf den ersten Blick wirke ich gelegentlich etwas grimmig oder nachdenklich. Davon sollte man sich jedoch nicht täuschen lassen, denn mein Gesicht vermittelt manchmal einen ganz anderen Eindruck als meine tatsächliche Stimmung. 😅</p>
|
||||
<p>Wer mich wirklich kennt, weiß, dass ich ein offener, humorvoller und herzlicher Mensch bin. Ich lache gerne, bin für jeden Spaß zu haben und unterstütze die Menschen, die mir wichtig sind. Ehrlichkeit, Zusammenhalt und gegenseitige Unterstützung haben für mich einen besonders hohen Stellenwert.</p>
|
||||
<p>Als Scout unterstütze ich das Team dabei, neue Talente zu entdecken, ihnen zur Seite zu stehen und sie auf ihrem persönlichen Weg zu begleiten. Dabei geht es mir nicht nur um Zahlen oder Erfolge, sondern vor allem um die Menschen, ihre Ziele und ihre Entwicklung.</p>
|
||||
<p>Ich freue mich auf alles, was noch vor mir liegt: neue Begegnungen, besondere Erinnerungen und viele weitere gemeinsame Momente.</p>
|
||||
<p class="quote" style="margin-top:1rem;">Das bin ich – Patrick. Nicht perfekt, manchmal mit einem grimmigen Blick, aber immer mit Herz. ❤️</p>
|
||||
<p style="text-align:right;"><strong>– euer Patrick ❤️</strong></p>
|
||||
`,
|
||||
"de-CH": `
|
||||
<p>Im Oktober 2024 han ich schliesslich de nöchscht Schritt gwagt und bi zum erste Mal live gange. Sithär han ich zahlriichi Mönsche cha kenneleärne und e paar dervo fescht i mis Herz gschlosse. Genau die Begägnige und dr persönlich Uustuusch mached TikTok für mich zu öppisem Bsundrigem.</p>
|
||||
<p>Uf de erschte Blick wirk ich mängisch chli grimmig oder nachdenklich. Dervo sött me sich aber nöd teusche la, dänn mis Gsicht vermittlet mängisch en ganz andere Yidruck als mini tatsächlichi Stimmig. 😅</p>
|
||||
<p>Wer mich würklich känt, weiss, dass ich en offene, humorvolle und härzliche Mönsch bin. Ich lach gern, bin für jede Spass ztue und unterstütze d Mönsche, wo mir wichtig sind. Ehrlichkeit, Zämehalt und gägesitigi Unterstützig händ für mich en bsunders hohe Stellewert.</p>
|
||||
<p>Als Scout unterstütz ich s Team derbi, neui Talänt z'entdecke, ihne zur Syte z'stah und sie uf ihrem persönliche Wäg z'begleite. Debii gaht's mir nöd nume um Zahle oder Erfolg, sondern vor allem um d Mönsche, ihri Ziel und ihri Entwicklig.</p>
|
||||
<p>Ich fröi mich uf alles, wo no vor mir liit: neui Begägnige, bsundrigi Erinnerige und no vill gmeinsami Momänt.</p>
|
||||
<p class="quote" style="margin-top:1rem;">Das bin ich – Patrick. Nöd perfekt, mängisch mit emene grimmige Blick, aber immer mit Herz. ❤️</p>
|
||||
<p style="text-align:right;"><strong>– öie Patrick ❤️</strong></p>
|
||||
`,
|
||||
en: `
|
||||
<p>In October 2024, I finally took the next step and went live for the first time. Since then, I've gotten to know so many people, and some of them have found a firm place in my heart. It's exactly these encounters and the personal exchange that make TikTok something special to me.</p>
|
||||
<p>At first glance, I sometimes come across as a bit grumpy or lost in thought. But don't be fooled by that — my face sometimes gives a very different impression than my actual mood. 😅</p>
|
||||
<p>Anyone who really knows me knows that I'm an open, humorous, and warm-hearted person. I love to laugh, I'm always up for a bit of fun, and I support the people who matter to me. Honesty, togetherness, and mutual support mean a great deal to me.</p>
|
||||
<p>As a scout, I help the team discover new talent, stand by their side, and guide them on their personal journey. For me, it's not just about numbers or success — it's above all about the people, their goals, and their growth.</p>
|
||||
<p>I'm looking forward to everything still ahead of me: new encounters, special memories, and many more moments together.</p>
|
||||
<p class="quote" style="margin-top:1rem;">That's me – Patrick. Not perfect, sometimes with a grumpy look, but always with heart. ❤️</p>
|
||||
<p style="text-align:right;"><strong>– your Patrick ❤️</strong></p>
|
||||
`,
|
||||
fr: `
|
||||
<p>En octobre 2024, j'ai finalement franchi l'étape suivante et j'ai fait mon premier live. Depuis, j'ai pu rencontrer de nombreuses personnes et certaines d'entre elles ont pris une vraie place dans mon cœur. Ce sont justement ces rencontres et ces échanges personnels qui rendent TikTok si particulier pour moi.</p>
|
||||
<p>Au premier abord, je peux parfois paraître un peu grognon ou pensif. Il ne faut cependant pas s'y fier, car mon visage donne parfois une impression bien différente de mon humeur réelle. 😅</p>
|
||||
<p>Ceux qui me connaissent vraiment savent que je suis quelqu'un d'ouvert, plein d'humour et chaleureux. J'aime rire, je suis toujours partant pour m'amuser et je soutiens les personnes qui comptent pour moi. L'honnêteté, la cohésion et le soutien mutuel ont une valeur particulièrement importante à mes yeux.</p>
|
||||
<p>En tant que scout, j'aide l'équipe à découvrir de nouveaux talents, à les épauler et à les accompagner sur leur propre chemin. Ce qui compte pour moi, ce n'est pas seulement les chiffres ou le succès, mais avant tout les personnes, leurs objectifs et leur évolution.</p>
|
||||
<p>Je me réjouis de tout ce qui m'attend encore : de nouvelles rencontres, des souvenirs particuliers et bien d'autres moments partagés.</p>
|
||||
<p class="quote" style="margin-top:1rem;">C'est moi, Patrick. Pas parfait, parfois avec un regard grognon, mais toujours avec du cœur. ❤️</p>
|
||||
<p style="text-align:right;"><strong>– votre Patrick ❤️</strong></p>
|
||||
`,
|
||||
pt: `
|
||||
<p>Em outubro de 2024, finalmente dei o próximo passo e fiz a minha primeira live. Desde então, pude conhecer muitas pessoas e algumas delas conquistaram um lugar especial no meu coração. São exatamente esses encontros e a troca pessoal que tornam o TikTok algo especial para mim.</p>
|
||||
<p>À primeira vista, às vezes pareço um pouco carrancudo ou pensativo. No entanto, não se deixe enganar por isso, porque o meu rosto às vezes transmite uma impressão bem diferente do meu humor real. 😅</p>
|
||||
<p>Quem realmente me conhece sabe que sou uma pessoa aberta, bem-humorada e carinhosa. Gosto de rir, estou sempre pronto para me divertir e apoio as pessoas que são importantes para mim. Honestidade, união e apoio mútuo têm um valor muito especial para mim.</p>
|
||||
<p>Como scout, ajudo a equipe a descobrir novos talentos, a estar ao lado deles e a acompanhá-los no seu caminho pessoal. Para mim, não se trata apenas de números ou sucesso, mas sobretudo das pessoas, dos seus objetivos e do seu desenvolvimento.</p>
|
||||
<p>Estou animado com tudo o que ainda está por vir: novos encontros, memórias especiais e muitos outros momentos em conjunto.</p>
|
||||
<p class="quote" style="margin-top:1rem;">Este sou eu – Patrick. Não sou perfeito, às vezes com um olhar carrancudo, mas sempre com coração. ❤️</p>
|
||||
<p style="text-align:right;"><strong>– o seu Patrick ❤️</strong></p>
|
||||
`,
|
||||
},
|
||||
quote: "", // TODO: Zitat von Patrick einholen
|
||||
social: {},
|
||||
status: "aktiv",
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
vorausgefüllten "mailto:"-Link.
|
||||
===================================================================== */
|
||||
|
||||
const API_BASE_URL = "https://dogfather-universe-postfach.dogfather1608.workers.dev";
|
||||
const API_BASE_URL = "https://postfach.dogfather-universe.com";
|
||||
|
||||
const CONTACT_EMAIL = "kontakt@dogfather-universe.example"; // TODO: echte Kontakt-E-Mail eintragen
|
||||
const CONTACT_EMAIL = "dogfather[email protected]";
|
||||
|
||||
const FORM_TYPES = {
|
||||
"form-creator": "creator",
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/* =====================================================================
|
||||
i18n-abonnieren.js — Übersetzungs-Wörterbuch NUR für abonnieren.html
|
||||
Alle 5 Sprachen vollständig gepflegt (de, de-CH, en, fr, pt).
|
||||
===================================================================== */
|
||||
|
||||
window.I18N_PAGE = {
|
||||
ab_eyebrow: {
|
||||
de: "👑 DogiCrew 🩵", "de-CH": "👑 DogiCrew 🩵",
|
||||
en: "👑 DogiCrew 🩵", fr: "👑 DogiCrew 🩵", pt: "👑 DogiCrew 🩵",
|
||||
},
|
||||
ab_h1: {
|
||||
de: "Mehr als ein Abo — deine Unterstützung wird Teil unserer Geschichte",
|
||||
"de-CH": "Meh als es Abo — dini Unterstützig wird Teil vo üser Gschicht",
|
||||
en: "More than a subscription — your support becomes part of our story",
|
||||
fr: "Plus qu'un abonnement — ton soutien devient une part de notre histoire",
|
||||
pt: "Mais do que uma assinatura — seu apoio se torna parte da nossa história",
|
||||
},
|
||||
ab_lead: {
|
||||
de: "Kein gewöhnlicher Shop. Ein exklusives Dankeschön- und Treueprogramm für alle, die Dogfather dauerhaft unterstützen.",
|
||||
"de-CH": "Kein gwöhnlicher Shop. Es exklusives Dankeschön- und Treueprogramm für alli, wo Dogfather duurhaft unterstütze.",
|
||||
en: "Not a regular shop. An exclusive thank-you and loyalty program for everyone who supports Dogfather long-term.",
|
||||
fr: "Pas une boutique ordinaire. Un programme exclusif de remerciement et de fidélité pour tous ceux qui soutiennent Dogfather durablement.",
|
||||
pt: "Não é uma loja comum. Um programa exclusivo de agradecimento e fidelidade para todos que apoiam Dogfather a longo prazo.",
|
||||
},
|
||||
ab_text1: {
|
||||
de: "Dieses Abonnement ist kein gewöhnlicher Shop und kein einfacher Kauf. Es ist für die Menschen gedacht, die Dogfather dauerhaft unterstützen und ein besonderer Teil dieser gemeinsamen Reise sein möchten.",
|
||||
"de-CH": "Das Abonnement isch kein gwöhnlicher Shop und kein eifache Kauf. Es isch für die Mönsche gedacht, wo Dogfather duurhaft unterstütze und en bsundere Teil vo dere gmeinsame Reise si wänd.",
|
||||
en: "This subscription is not a regular shop and not a simple purchase. It's for the people who support Dogfather long-term and want to be a special part of this shared journey.",
|
||||
fr: "Cet abonnement n'est ni une boutique ordinaire ni un simple achat. Il est destiné aux personnes qui soutiennent Dogfather durablement et souhaitent faire partie de ce voyage commun.",
|
||||
pt: "Esta assinatura não é uma loja comum nem uma compra simples. É para as pessoas que apoiam Dogfather a longo prazo e querem ser parte especial dessa jornada em comum.",
|
||||
},
|
||||
ab_text2: {
|
||||
de: "Für 4,99 € im Monat erhältst du Zugang zu einem exklusiven Supporter-Bereich, besonderen Inhalten und einer persönlichen Treuekollektion. Mit jedem erfolgreich bezahlten Meilenstein wächst deine Sammlung: vom kleinen Hasen-Teddy über besondere Jubiläumsprämien bis zum exklusiven Hoodie nach 30 Monaten. Danach beginnt für dich eine neue Kollektion — mit neuen Designs, neuen Erinnerungen und derselben Bedeutung.",
|
||||
"de-CH": "Für 4,99 € im Monat überchunnsch du Zugang zu emene exklusive Supporter-Bereich, bsundere Inhalt und ere persönliche Treuekollektion. Mit jedem erfolgriich bezahlte Meilestei wachst dini Sammlig: vom chline Hase-Teddy über bsundere Jubiläumsprämie bis zum exklusive Hoodie nach 30 Monet. Dernah fangt für dich e neui Kollektion a — mit neue Designs, neue Erinnerige und derselbe Bedeutig.",
|
||||
en: "For 4.99 € a month, you get access to an exclusive supporter area, special content, and a personal loyalty collection. With every successfully paid milestone, your collection grows — from the small bunny teddy through special anniversary rewards up to the exclusive hoodie after 30 months. After that, a new collection begins for you, with new designs, new memories, and the same meaning.",
|
||||
fr: "Pour 4,99 € par mois, tu as accès à un espace supporter exclusif, à du contenu spécial et à une collection de fidélité personnelle. Avec chaque mois payé avec succès, ta collection grandit : du petit lapin en peluche aux récompenses d'anniversaire jusqu'au hoodie exclusif après 30 mois. Ensuite, une nouvelle collection commence pour toi — avec de nouveaux designs, de nouveaux souvenirs et la même signification.",
|
||||
pt: "Por 4,99 € por mês, você tem acesso a uma área exclusiva de apoiadores, conteúdo especial e uma coleção pessoal de fidelidade. A cada mês pago com sucesso, sua coleção cresce: do pequeno coelhinho de pelúcia até recompensas especiais de aniversário e o hoodie exclusivo após 30 meses. Depois disso, começa uma nova coleção para você — com novos designs, novas memórias e o mesmo significado.",
|
||||
},
|
||||
ab_vorteil1: {
|
||||
de: "Exklusiver Supporter-Bereich", "de-CH": "Exklusive Supporter-Bereich",
|
||||
en: "Exclusive supporter area", fr: "Espace supporter exclusif", pt: "Área exclusiva de apoiador",
|
||||
},
|
||||
ab_vorteil2: {
|
||||
de: "Kleiner Hasen-Teddy nach 6 bezahlten Monaten", "de-CH": "Chline Hase-Teddy nach 6 bezahlte Monet",
|
||||
en: "Small bunny teddy after 6 paid months", fr: "Petit lapin en peluche après 6 mois payés", pt: "Coelhinho de pelúcia pequeno após 6 meses pagos",
|
||||
},
|
||||
ab_vorteil3: {
|
||||
de: "Mittelgroßer Hasen-Teddy nach 12 bezahlten Monaten", "de-CH": "Mittelgrosse Hase-Teddy nach 12 bezahlte Monet",
|
||||
en: "Medium bunny teddy after 12 paid months", fr: "Lapin en peluche moyen après 12 mois payés", pt: "Coelhinho de pelúcia médio após 12 meses pagos",
|
||||
},
|
||||
ab_vorteil4: {
|
||||
de: "Großer Hasen-Teddy nach 18 bezahlten Monaten", "de-CH": "Grosse Hase-Teddy nach 18 bezahlte Monet",
|
||||
en: "Large bunny teddy after 18 paid months", fr: "Grand lapin en peluche après 18 mois payés", pt: "Coelhinho de pelúcia grande após 18 meses pagos",
|
||||
},
|
||||
ab_vorteil5: {
|
||||
de: "Tasse & Autogrammkarte nach 24 bezahlten Monaten", "de-CH": "Tasse & Autogrammchärtli nach 24 bezahlte Monet",
|
||||
en: "Mug & autograph card after 24 paid months", fr: "Tasse et carte autographe après 24 mois payés", pt: "Caneca e cartão autografado após 24 meses pagos",
|
||||
},
|
||||
ab_vorteil6: {
|
||||
de: "Exklusiver Hoodie nach 30 bezahlten Monaten", "de-CH": "Exklusive Hoodie nach 30 bezahlte Monet",
|
||||
en: "Exclusive hoodie after 30 paid months", fr: "Hoodie exclusif après 30 mois payés", pt: "Hoodie exclusivo após 30 meses pagos",
|
||||
},
|
||||
ab_vorteil7: {
|
||||
de: "Neue Supporter-Kollektion nach jedem abgeschlossenen Zyklus", "de-CH": "Neui Supporter-Kollektion nach jedem abgschlossene Zyklus",
|
||||
en: "New supporter collection after every completed cycle", fr: "Nouvelle collection supporter après chaque cycle terminé", pt: "Nova coleção de apoiador após cada ciclo concluído",
|
||||
},
|
||||
ab_vanvan_h2: {
|
||||
de: "Die Teddy-Kollektion — in Kooperation mit VanVan", "de-CH": "Die Teddy-Kollektion — i Kooperation mit VanVan",
|
||||
en: "The Teddy Collection — in Cooperation with VanVan", fr: "La collection de peluches — en coopération avec VanVan", pt: "A coleção de pelúcias — em cooperação com VanVan",
|
||||
},
|
||||
ab_vanvan_text: {
|
||||
de: "Die DogiCrew 🩵 gehört Dogfather. Die Hasen-Teddys und die dazugehörigen Sammlerkollektionen entstehen jedoch in Zusammenarbeit mit VanVan — der rechten Hand von Dogfather, seiner wichtigsten Vertrauensperson und einem festen Bestandteil der Team-Dogi-Familie. Sie stehen für ihre besondere Rolle, den Zusammenhalt innerhalb der Community und die Verbindung zwischen Dogfather, VanVan und allen Menschen, die diesen gemeinsamen Weg dauerhaft unterstützen.",
|
||||
"de-CH": "D DogiCrew 🩵 ghört Dogfather. D Hase-Teddys und die dezueghörige Sammlerkollektione entstönd aber i Zämearbeit mit VanVan — dr rechte Hand vo Dogfather, sinere wichtigste Vertrouensperson und emene feste Bestandteil vo dr Team-Dogi-Familie. Sie stönd für ihri bsundrigi Roll, dr Zämehalt innerhalb vo dr Community und d Verbindig zwüsche Dogfather, VanVan und alle Mönsche, wo dää gmeinsame Wäg duurhaft unterstütze.",
|
||||
en: "The DogiCrew 🩵 belongs to Dogfather. The bunny teddies and their collector collections, however, are created in collaboration with VanVan — Dogfather's right hand, his most important trusted person, and a fixed part of the Team Dogi family. They represent her special role, the togetherness within the community, and the connection between Dogfather, VanVan, and everyone who supports this shared journey long-term.",
|
||||
fr: "La DogiCrew 🩵 appartient à Dogfather. Les lapins en peluche et leurs collections de collectionneur sont cependant créés en collaboration avec VanVan — le bras droit de Dogfather, sa personne de confiance la plus importante et un membre à part entière de la famille Team Dogi. Ils représentent son rôle particulier, la cohésion au sein de la communauté et le lien entre Dogfather, VanVan et toutes les personnes qui soutiennent durablement ce parcours commun.",
|
||||
pt: "A DogiCrew 🩵 pertence ao Dogfather. Os coelhinhos de pelúcia e suas coleções de colecionador, porém, nascem em colaboração com VanVan — a mão direita de Dogfather, sua pessoa de confiança mais importante e parte fixa da família Team Dogi. Eles representam seu papel especial, a união dentro da comunidade e a conexão entre Dogfather, VanVan e todas as pessoas que apoiam essa jornada em comum a longo prazo.",
|
||||
},
|
||||
ab_kollektion_coming_soon: {
|
||||
de: `Coming soon…`, "de-CH": `Coming soon…`,
|
||||
en: `Coming soon…`, fr: `Coming soon…`, pt: `Coming soon…`,
|
||||
},
|
||||
ab_preis_eyebrow: {
|
||||
de: "DogiCrew 🩵", "de-CH": "DogiCrew 🩵",
|
||||
en: "DogiCrew 🩵", fr: "DogiCrew 🩵", pt: "DogiCrew 🩵",
|
||||
},
|
||||
ab_preis_pro_monat: {
|
||||
de: "/ Monat", "de-CH": "/ Monet",
|
||||
en: "/ month", fr: "/ mois", pt: "/ mês",
|
||||
},
|
||||
ab_bed1: {
|
||||
de: "Monatliche automatische Zahlung über PayPal", "de-CH": "Monetlichi automatischi Zahlig über PayPal",
|
||||
en: "Monthly automatic payment via PayPal", fr: "Paiement mensuel automatique via PayPal", pt: "Pagamento mensal automático via PayPal",
|
||||
},
|
||||
ab_bed2: {
|
||||
de: "Keine Mindestlaufzeit, jederzeit kündbar", "de-CH": "Kei Mindestlouffzyt, jederzyt kündbar",
|
||||
en: "No minimum term, cancel anytime", fr: "Aucune durée minimale, résiliable à tout moment", pt: "Sem prazo mínimo, cancele quando quiser",
|
||||
},
|
||||
ab_bed3: {
|
||||
de: "Kündigung zum Ende des bereits bezahlten Zeitraums", "de-CH": "Kündigung zum Ändi vom scho bezahlte Zytruum",
|
||||
en: "Cancellation takes effect at the end of the already paid period", fr: "La résiliation prend effet à la fin de la période déjà payée", pt: "O cancelamento vale até o fim do período já pago",
|
||||
},
|
||||
ab_bed4: {
|
||||
de: "Keine versteckten Zusatzkosten", "de-CH": "Kei versteckti Zuestzchöschte",
|
||||
en: "No hidden extra costs", fr: "Aucun coût caché supplémentaire", pt: "Sem custos adicionais ocultos",
|
||||
},
|
||||
ab_laedt: {
|
||||
de: "Einen Moment …", "de-CH": "En Momänt …",
|
||||
en: "One moment …", fr: "Un instant …", pt: "Um momento …",
|
||||
},
|
||||
ab_feld_name: {
|
||||
de: "Name oder Anzeigename", "de-CH": "Name oder Aazeigename",
|
||||
en: "Name or display name", fr: "Nom ou pseudo", pt: "Nome ou nome de exibição",
|
||||
},
|
||||
ab_feld_tiktok: {
|
||||
de: "TikTok-Benutzername", "de-CH": "TikTok-Benutzername",
|
||||
en: "TikTok username", fr: "Nom d'utilisateur TikTok", pt: "Nome de usuário do TikTok",
|
||||
},
|
||||
ab_feld_email: {
|
||||
de: "E-Mail-Adresse", "de-CH": "E-Mail-Adrässe",
|
||||
en: "Email address", fr: "Adresse e-mail", pt: "Endereço de e-mail",
|
||||
},
|
||||
ab_btn_register: {
|
||||
de: "Registrieren", "de-CH": "Registriere",
|
||||
en: "Register", fr: "S'inscrire", pt: "Registrar",
|
||||
},
|
||||
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_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",
|
||||
},
|
||||
ab_switch_register: {
|
||||
de: "Noch kein Konto? Jetzt registrieren", "de-CH": "No kein Konto? Jetzt registriere",
|
||||
en: "No account yet? Register now", fr: "Pas encore de compte ? Inscris-toi maintenant", pt: "Ainda não tem conta? Registre-se agora",
|
||||
},
|
||||
ab_code_hinweis: {
|
||||
de: "Wir haben dir einen 6-stelligen Code per E-Mail geschickt.", "de-CH": "Mir händ dir en 6-stellige Code per E-Mail gschickt.",
|
||||
en: "We've sent you a 6-digit code by email.", fr: "Nous t'avons envoyé un code à 6 chiffres par e-mail.", pt: "Enviamos um código de 6 dígitos para o seu e-mail.",
|
||||
},
|
||||
ab_feld_code: {
|
||||
de: "Code", "de-CH": "Code",
|
||||
en: "Code", fr: "Code", pt: "Código",
|
||||
},
|
||||
ab_btn_code_verify: {
|
||||
de: "Bestätigen", "de-CH": "Bestätige",
|
||||
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:",
|
||||
},
|
||||
ab_oder_email: {
|
||||
de: "oder mit E-Mail", "de-CH": "oder mit E-Mail",
|
||||
en: "or with email", fr: "ou avec e-mail", pt: "ou com e-mail",
|
||||
},
|
||||
ab_google_consent_btn: {
|
||||
de: "Mit Google anmelden", "de-CH": "Mit Google aamelde",
|
||||
en: "Continue with Google", fr: "Continuer avec Google", pt: "Continuar com o Google",
|
||||
},
|
||||
ab_google_consent_hint: {
|
||||
de: "Beim Klick wird eine Verbindung zu Google hergestellt (deine IP-Adresse wird dabei übertragen).",
|
||||
"de-CH": "Bim Klick wird e Verbindig zu Google hergstellt (dini IP-Adrässe wird debii übertreit).",
|
||||
en: "Clicking this connects to Google (your IP address is transmitted in the process).",
|
||||
fr: "En cliquant, une connexion à Google est établie (ton adresse IP est alors transmise).",
|
||||
pt: "Ao clicar, é feita uma conexão com o Google (seu endereço IP é transmitido nesse processo).",
|
||||
},
|
||||
ab_paypal_laedt: {
|
||||
de: "Zahlungsoptionen werden geladen …", "de-CH": "Zahligsoptione wärded gladen …",
|
||||
en: "Loading payment options …", fr: "Chargement des options de paiement …", pt: "Carregando opções de pagamento …",
|
||||
},
|
||||
ab_bereits_supporter: {
|
||||
de: "Du bist bereits Supporter! 🩵", "de-CH": "Du bisch scho Supporter! 🩵",
|
||||
en: "You're already a supporter! 🩵", fr: "Tu es déjà supporter ! 🩵", pt: "Você já é apoiador! 🩵",
|
||||
},
|
||||
ab_btn_zum_bereich: {
|
||||
de: "Zum Supporter-Bereich", "de-CH": "Zum Supporter-Bereich",
|
||||
en: "Go to supporter area", fr: "Aller à l'espace supporter", pt: "Ir para a área do apoiador",
|
||||
},
|
||||
};
|
||||
@@ -177,8 +177,8 @@ window.I18N_PAGE = {
|
||||
},
|
||||
idx_team_h2: { de: "Team Dogi 🩵", "de-CH": "Team Dogi 🩵", en: "Team Dogi 🩵", fr: "Team Dogi 🩵", pt: "Team Dogi 🩵" },
|
||||
idx_team_lead: {
|
||||
de: "Von VanVan & die Modis — die Menschen, die DOGFATHER UNIVERSE tragen.",
|
||||
"de-CH": "Vo VanVan & de Modis — die Mönsche, wo DOGFATHER UNIVERSE tröget.",
|
||||
de: "VanVan & Modis — die Menschen, die DOGFATHER UNIVERSE tragen.",
|
||||
"de-CH": "VanVan & Modis — die Mönsche, wo DOGFATHER UNIVERSE tröget.",
|
||||
en: "From VanVan & the Modis — the people who carry DOGFATHER UNIVERSE.",
|
||||
fr: "De VanVan et des Modis — les personnes qui portent DOGFATHER UNIVERSE.",
|
||||
pt: "De VanVan e dos Modis — as pessoas que sustentam o DOGFATHER UNIVERSE.",
|
||||
|
||||
@@ -38,42 +38,15 @@ window.I18N_PAGE = {
|
||||
de: "Impressum", "de-CH": "Impressum",
|
||||
en: "Legal Notice", fr: "Mentions légales", pt: "Aviso legal",
|
||||
},
|
||||
kt_impressum_p: {
|
||||
de: `Angaben gemäß § 5 TMG<br>
|
||||
[Firmenname / Betreiber]<br>
|
||||
[Anschrift]<br>
|
||||
[Kontakt]<br>
|
||||
Vertreten durch: [Name]`,
|
||||
"de-CH": `Angabe gmäss § 5 TMG<br>
|
||||
[Firmename / Betriiber]<br>
|
||||
[Adrässe]<br>
|
||||
[Kontakt]<br>
|
||||
Vertrete durch: [Name]`,
|
||||
en: `Information according to § 5 TMG (German Telemedia Act)<br>
|
||||
[Company name / operator]<br>
|
||||
[Address]<br>
|
||||
[Contact]<br>
|
||||
Represented by: [Name]`,
|
||||
fr: `Mentions légales selon l'article § 5 TMG (loi allemande sur les télémédias)<br>
|
||||
[Nom de l'entreprise / exploitant]<br>
|
||||
[Adresse]<br>
|
||||
[Contact]<br>
|
||||
Représenté par : [Nom]`,
|
||||
pt: `Informações conforme o § 5 TMG (lei alemã de telemídia)<br>
|
||||
[Nome da empresa / responsável]<br>
|
||||
[Endereço]<br>
|
||||
[Contato]<br>
|
||||
Representado por: [Nome]`,
|
||||
kt_impressum_hinweis: {
|
||||
de: `Rechtstexte werden auf Deutsch bereitgestellt, um Übersetzungsfehler bei rechtlich verbindlichen Angaben zu vermeiden.`,
|
||||
"de-CH": `Rächtstäxt wärded uf Dütsch bereitgstellt, dass käni Übersetzigsfähler bi rächtlich verbindlechne Angabe entstönd.`,
|
||||
en: `Legal texts are provided in German only, to avoid translation errors in legally binding information.`,
|
||||
fr: `Les mentions légales ne sont fournies qu'en allemand, afin d'éviter toute erreur de traduction dans des informations juridiquement contraignantes.`,
|
||||
pt: `Os textos legais são fornecidos apenas em alemão, para evitar erros de tradução em informações juridicamente vinculativas.`,
|
||||
},
|
||||
kt_datenschutz_h2: {
|
||||
de: "Datenschutz", "de-CH": "Datenschutz",
|
||||
en: "Privacy Policy", fr: "Protection des données", pt: "Privacidade",
|
||||
},
|
||||
kt_datenschutz_p: {
|
||||
de: "Diese Website wird aktuell lokal/offline entwickelt und ist noch nicht öffentlich gehostet. Sobald ein Hosting-Anbieter feststeht und Formulare (z.B. Bewerbung) echte Daten versenden, wird hier eine vollständige Datenschutzerklärung ergänzt (Verarbeitungszwecke, Rechtsgrundlage, Speicherdauer, Empfänger, Rechte der Nutzer:innen).",
|
||||
"de-CH": "Die Website wird aktuell lokal/offline entwicklet und isch no nöd öffentlich gehostet. Sobald en Hosting-Aabieter feststoht und Formulär (z.B. Bewärbig) echti Date versände, wird da e vollständigi Dateschutzerklärig ergänzt (Verarbeitigszwäck, Rächtsgrundlag, Speicherduur, Empfänger, Rächt vo de Nutzer:inne).",
|
||||
en: "This website is currently being developed locally/offline and is not yet publicly hosted. Once a hosting provider is finalized and forms (e.g. applications) send real data, a complete privacy policy will be added here (processing purposes, legal basis, retention period, recipients, users' rights).",
|
||||
fr: "Ce site est actuellement développé en local/hors ligne et n'est pas encore hébergé publiquement. Dès qu'un hébergeur sera fixé et que les formulaires (p. ex. candidature) transmettront de vraies données, une déclaration de confidentialité complète sera ajoutée ici (finalités du traitement, base légale, durée de conservation, destinataires, droits des utilisateurs·rices).",
|
||||
pt: "Este site está atualmente sendo desenvolvido localmente/offline e ainda não está hospedado publicamente. Assim que um provedor de hospedagem for definido e os formulários (por exemplo, candidatura) enviarem dados reais, uma política de privacidade completa será adicionada aqui (finalidades do tratamento, base legal, prazo de armazenamento, destinatários, direitos dos usuários).",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,6 +23,36 @@ window.I18N_PAGE = {
|
||||
de: "Nur auf Anfrage", "de-CH": "Nur uf Aafrag",
|
||||
en: "By request only", fr: "Uniquement sur demande", pt: "Somente mediante solicitação",
|
||||
},
|
||||
lk_rabatt_badge: {
|
||||
de: "🔒 Nur für Supporter", "de-CH": "🔒 Nur für Supporter",
|
||||
en: "🔒 Supporters only", fr: "🔒 Réservé aux Supporters", pt: "🔒 Somente para apoiadores",
|
||||
},
|
||||
lk_rabatt_badge_frei: {
|
||||
de: "✓ Freigeschaltet", "de-CH": "✓ Freigschalte",
|
||||
en: "✓ Unlocked", fr: "✓ Débloqué", pt: "✓ Desbloqueado",
|
||||
},
|
||||
lk_rabatt_h3: {
|
||||
de: "Rabattcodes", "de-CH": "Rabattcodes",
|
||||
en: "Discount Codes", fr: "Codes de réduction", pt: "Códigos de desconto",
|
||||
},
|
||||
lk_rabatt_desc: {
|
||||
de: "Codes von Dogis Partnern — exklusiv für Dogfather-Supporter.",
|
||||
"de-CH": "Codes vo Dogis Partner — exklusiv für Dogfather-Supporter.",
|
||||
en: "Codes from Dogi's partners — exclusive to Dogfather supporters.",
|
||||
fr: "Des codes chez les partenaires de Dogi — exclusifs aux Supporters Dogfather.",
|
||||
pt: "Códigos dos parceiros do Dogi — exclusivos para apoiadores do Dogfather.",
|
||||
},
|
||||
lk_rabatt_gesperrt_hinweis: {
|
||||
de: "🔒 Rabattcodes sind nur für Dogfather-Supporter zugänglich. Werde jetzt Supporter, um sie freizuschalten.",
|
||||
"de-CH": "🔒 Rabattcodes sind nur für Dogfather-Supporter zuegänglich. Wird jetzt Supporter, zum sie freizschalte.",
|
||||
en: "🔒 Discount codes are only available to Dogfather supporters. Become a supporter now to unlock them.",
|
||||
fr: "🔒 Les codes de réduction sont réservés aux Supporters Dogfather. Deviens Supporter dès maintenant pour les débloquer.",
|
||||
pt: "🔒 Os códigos de desconto são exclusivos para apoiadores do Dogfather. Torne-se apoiador agora para desbloqueá-los.",
|
||||
},
|
||||
lk_rabatt_btn_abo: {
|
||||
de: "Jetzt Supporter werden 👑", "de-CH": "Jetzt Supporter werde 👑",
|
||||
en: "Become a supporter now 👑", fr: "Devenir Supporter maintenant 👑", pt: "Tornar-se apoiador agora 👑",
|
||||
},
|
||||
lk_shop_hint: {
|
||||
de: "Folgt in Kürze", "de-CH": "Chunnt bald",
|
||||
en: "Coming soon", fr: "Bientôt disponible", pt: "Em breve",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/* =====================================================================
|
||||
i18n-rabattcodes.js — Übersetzungs-Wörterbuch NUR für rabattcodes.html
|
||||
Alle 5 Sprachen vollständig gepflegt (de, de-CH, en, fr, pt).
|
||||
===================================================================== */
|
||||
|
||||
window.I18N_PAGE = {
|
||||
rc_eyebrow: {
|
||||
de: "🔒 Nur für Supporter", "de-CH": "🔒 Nur für Supporter",
|
||||
en: "🔒 Supporters only", fr: "🔒 Réservé aux Supporters", pt: "🔒 Somente para apoiadores",
|
||||
},
|
||||
rc_h1: {
|
||||
de: "Rabattcodes", "de-CH": "Rabattcodes",
|
||||
en: "Discount Codes", fr: "Codes de réduction", pt: "Códigos de desconto",
|
||||
},
|
||||
rc_lead: {
|
||||
de: "Codes von Dogis Partnern — exklusiv für Dogfather-Supporter.",
|
||||
"de-CH": "Codes vo Dogis Partner — exklusiv für Dogfather-Supporter.",
|
||||
en: "Codes from Dogi's partners — exclusive to Dogfather supporters.",
|
||||
fr: "Des codes chez les partenaires de Dogi — exclusifs aux Supporters Dogfather.",
|
||||
pt: "Códigos dos parceiros do Dogi — exclusivos para apoiadores do Dogfather.",
|
||||
},
|
||||
rc_gesperrt_titel: {
|
||||
de: "Nur für Dogfather-Supporter", "de-CH": "Nur für Dogfather-Supporter",
|
||||
en: "Dogfather supporters only", fr: "Réservé aux Supporters Dogfather", pt: "Somente para apoiadores do Dogfather",
|
||||
},
|
||||
rc_gesperrt_text: {
|
||||
de: "Rabattcodes sind ein exklusiver Vorteil der DogiCrew 🩵. Werde jetzt Teil davon, um sie freizuschalten, sobald die ersten Kooperationen live sind.",
|
||||
"de-CH": "Rabattcodes sind e exklusive Vorteil vo dr DogiCrew 🩵. Wird jetzt Teil devo, zum sie freizschalte, sobald die erste Kooperatione live sind.",
|
||||
en: "Discount codes are an exclusive perk of the DogiCrew 🩵. Join now to unlock them as soon as the first collaborations go live.",
|
||||
fr: "Les codes de réduction sont un avantage exclusif de la DogiCrew 🩵. Rejoins-la dès maintenant pour les débloquer dès que les premières collaborations seront lancées.",
|
||||
pt: "Os códigos de desconto são um benefício exclusivo da DogiCrew 🩵. Junte-se agora para desbloqueá-los assim que as primeiras parcerias forem lançadas.",
|
||||
},
|
||||
rc_btn_abo: {
|
||||
de: "Jetzt Supporter werden 👑", "de-CH": "Jetzt Supporter werde 👑",
|
||||
en: "Become a supporter now 👑", fr: "Devenir Supporter maintenant 👑", pt: "Tornar-se apoiador agora 👑",
|
||||
},
|
||||
rc_titel: {
|
||||
de: "Codes folgen in Kürze", "de-CH": "Codes chömed bald",
|
||||
en: "Codes coming soon", fr: "Codes bientôt disponibles", pt: "Códigos em breve",
|
||||
},
|
||||
rc_text: {
|
||||
de: "Als Creator bekommt DogFather eigene Rabattcodes bei ausgewählten Partnern — die gibt er direkt an Team Dogi weiter. Sobald die ersten Kooperationen live sind, stehen die Codes genau hier.",
|
||||
"de-CH": "Als Creator überchunnt DogFather eigeni Rabattcodes bi usgwählte Partner — die gitt er direkt a Team Dogi wyter. Sobald die erste Kooperatione live sind, stönd die Codes genau da.",
|
||||
en: "As a creator, DogFather gets his own discount codes from selected partners — and passes them straight on to Team Dogi. As soon as the first collaborations go live, the codes will show up right here.",
|
||||
fr: "En tant que créateur, DogFather reçoit ses propres codes de réduction chez des partenaires sélectionnés — et les transmet directement à Team Dogi. Dès que les premières collaborations seront lancées, les codes apparaîtront ici.",
|
||||
pt: "Como criador, DogFather recebe seus próprios códigos de desconto de parceiros selecionados — e os repassa direto para a Team Dogi. Assim que as primeiras parcerias forem lançadas, os códigos aparecerão bem aqui.",
|
||||
},
|
||||
rc_beispiel_marke: {
|
||||
de: "Partner-Marke", "de-CH": "Partner-Marke",
|
||||
en: "Partner brand", fr: "Marque partenaire", pt: "Marca parceira",
|
||||
},
|
||||
rc_beispiel_code: {
|
||||
de: `Code „DOGFATHER" · –10 %`, "de-CH": `Code „DOGFATHER" · –10 %`,
|
||||
en: `Code "DOGFATHER" · –10%`, fr: `Code « DOGFATHER » · –10 %`, pt: `Código "DOGFATHER" · –10%`,
|
||||
},
|
||||
rc_btn_kontakt: {
|
||||
de: "Auf dem Laufenden bleiben", "de-CH": "Am Bau blibe",
|
||||
en: "Stay in the loop", fr: "Rester informé(e)", pt: "Fique por dentro",
|
||||
},
|
||||
rc_btn_zurueck: {
|
||||
de: "← Zurück zu allen Links", "de-CH": "← Zrugg zu alle Links",
|
||||
en: "← Back to all links", fr: "← Retour à tous les liens", pt: "← Voltar a todos os links",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
/* =====================================================================
|
||||
i18n-supporter.js — Übersetzungs-Wörterbuch NUR für supporter.html
|
||||
Alle 5 Sprachen vollständig gepflegt (de, de-CH, en, fr, pt).
|
||||
|
||||
Hinweis: Die dynamisch aus der API befüllten Inhalte (Status-Labels,
|
||||
Prämien-Namen, Archiv-Texte) werden aktuell noch fest auf Deutsch
|
||||
generiert (siehe <script> unten im HTML) — vollständige Mehrsprachigkeit
|
||||
für diese dynamischen Texte ist als Ausbaustufe vorgemerkt, hier zunächst
|
||||
nur der statische Seitenrahmen (Überschriften/Buttons).
|
||||
===================================================================== */
|
||||
|
||||
window.I18N_PAGE = {
|
||||
su_eyebrow: {
|
||||
de: "👑 Supporter-Bereich", "de-CH": "👑 Supporter-Bereich",
|
||||
en: "👑 Supporter Area", fr: "👑 Espace Supporter", pt: "👑 Área do Apoiador",
|
||||
},
|
||||
su_lade: {
|
||||
de: "Dein Supporter-Bereich", "de-CH": "Dis Supporter-Bereich",
|
||||
en: "Your Supporter Area", fr: "Ton espace Supporter", pt: "Sua Área de Apoiador",
|
||||
},
|
||||
su_lead: {
|
||||
de: "Danke, dass du Dogfather dauerhaft unterstützt.", "de-CH": "Danke, dass du Dogfather duurhaft unterstützisch.",
|
||||
en: "Thank you for supporting Dogfather long-term.", fr: "Merci de soutenir Dogfather durablement.", pt: "Obrigado por apoiar Dogfather a longo prazo.",
|
||||
},
|
||||
su_fenster1_h2: {
|
||||
de: "Mein Abo", "de-CH": "Mis Abo",
|
||||
en: "My Subscription", fr: "Mon abonnement", pt: "Minha assinatura",
|
||||
},
|
||||
su_stat_status: {
|
||||
de: "Status", "de-CH": "Status",
|
||||
en: "Status", fr: "Statut", pt: "Status",
|
||||
},
|
||||
su_stat_preis: {
|
||||
de: "Monatlicher Preis", "de-CH": "Monetliche Priis",
|
||||
en: "Monthly price", fr: "Prix mensuel", pt: "Preço mensal",
|
||||
},
|
||||
su_stat_naechste_zahlung: {
|
||||
de: "Nächste Zahlung", "de-CH": "Nächsti Zahlig",
|
||||
en: "Next payment", fr: "Prochain paiement", pt: "Próximo pagamento",
|
||||
},
|
||||
su_stat_monate: {
|
||||
de: "Bezahlte Monate gesamt", "de-CH": "Bezahlti Monet insgsamt",
|
||||
en: "Total paid months", fr: "Total des mois payés", pt: "Total de meses pagos",
|
||||
},
|
||||
su_stat_zyklus: {
|
||||
de: "Aktueller Zyklus", "de-CH": "Aktuelle Zyklus",
|
||||
en: "Current cycle", fr: "Cycle actuel", pt: "Ciclo atual",
|
||||
},
|
||||
su_stat_details_hinweis: {
|
||||
de: "Details ansehen", "de-CH": "Details aaluege",
|
||||
en: "View details", fr: "Voir les détails", pt: "Ver detalhes",
|
||||
},
|
||||
su_kein_abo_text: {
|
||||
de: "Du hast noch kein aktives Abo.", "de-CH": "Du häsch no kein aktivs Abo.",
|
||||
en: "You don't have an active subscription yet.", fr: "Tu n'as pas encore d'abonnement actif.", pt: "Você ainda não tem uma assinatura ativa.",
|
||||
},
|
||||
su_kein_abo_btn: {
|
||||
de: "Jetzt abonnieren", "de-CH": "Jetzt abonniere",
|
||||
en: "Subscribe now", fr: "S'abonner maintenant", pt: "Assine agora",
|
||||
},
|
||||
su_fenster2_h2: {
|
||||
de: "Mein aktueller Fortschritt", "de-CH": "Mis aktuelle Fortschritt",
|
||||
en: "My Current Progress", fr: "Ma progression actuelle", pt: "Meu progresso atual",
|
||||
},
|
||||
su_archiv_h2: {
|
||||
de: "Mein Sammlungsarchiv", "de-CH": "Mis Sammligsarchiv",
|
||||
en: "My Collection Archive", fr: "Mes archives de collection", pt: "Meu arquivo de coleção",
|
||||
},
|
||||
su_verwalten_h2: {
|
||||
de: "Abo verwalten", "de-CH": "Abo verwalte",
|
||||
en: "Manage Subscription", fr: "Gérer l'abonnement", pt: "Gerenciar assinatura",
|
||||
},
|
||||
su_feld_name: {
|
||||
de: "Name / Anzeigename", "de-CH": "Name / Aazeigename",
|
||||
en: "Name / display name", fr: "Nom / pseudo", pt: "Nome / nome de exibição",
|
||||
},
|
||||
su_feld_tiktok: {
|
||||
de: "TikTok-Benutzername", "de-CH": "TikTok-Benutzername",
|
||||
en: "TikTok username", fr: "Nom d'utilisateur TikTok", pt: "Nome de usuário do TikTok",
|
||||
},
|
||||
su_btn_profil_speichern: {
|
||||
de: "Speichern", "de-CH": "Spichere",
|
||||
en: "Save", fr: "Enregistrer", pt: "Salvar",
|
||||
},
|
||||
su_profil_gespeichert: {
|
||||
de: "Gespeichert.", "de-CH": "Gspicheret.",
|
||||
en: "Saved.", fr: "Enregistré.", pt: "Salvo.",
|
||||
},
|
||||
su_btn_kuendigen: {
|
||||
de: "Abo kündigen", "de-CH": "Abo kündige",
|
||||
en: "Cancel subscription", fr: "Résilier l'abonnement", pt: "Cancelar assinatura",
|
||||
},
|
||||
su_btn_logout: {
|
||||
de: "Ausloggen", "de-CH": "Uslogge",
|
||||
en: "Log out", fr: "Se déconnecter", pt: "Sair",
|
||||
},
|
||||
su_nicht_eingeloggt: {
|
||||
de: "Du bist nicht eingeloggt.", "de-CH": "Du bisch nid yyglogget.",
|
||||
en: "You're not logged in.", fr: "Tu n'es pas connecté(e).", pt: "Você não está conectado.",
|
||||
},
|
||||
su_zum_abo: {
|
||||
de: "Zur DogiCrew 🩵", "de-CH": "Zur DogiCrew 🩵",
|
||||
en: "Go to the DogiCrew 🩵", fr: "Aller à la DogiCrew 🩵", pt: "Ir para a DogiCrew 🩵",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,257 @@
|
||||
/* =====================================================================
|
||||
i18n-tt-muddi.js — Übersetzungs-Wörterbuch NUR für tt-muddi.html
|
||||
Alle 5 Sprachen vollständig gepflegt (de, de-CH, en, fr, pt).
|
||||
|
||||
Persönliche Widmung von Dogi an Tili ("TT-Muddi"), eingefügt am
|
||||
03.08.2026 auf Wunsch von Dogi (Original-Text 1:1 von ihm geliefert,
|
||||
siehe Chat-Verlauf). Bewusst NICHT von main p { font-size: 1.14rem }
|
||||
& Co. wie bei vanvan.html abgewichen — gleiche "persönlicher Brief"-
|
||||
Textgröße, damit lange emotionale Fließtexte gut lesbar bleiben.
|
||||
===================================================================== */
|
||||
|
||||
window.I18N_PAGE = {
|
||||
tm_eyebrow: {
|
||||
de: "🩵 Familie", "de-CH": "🩵 Familie",
|
||||
en: "🩵 Family", fr: "🩵 Famille", pt: "🩵 Família",
|
||||
},
|
||||
tm_h1: {
|
||||
de: "Tili – meine TikTok-Muddi und ein ganz besonderer Teil meines Lebens 🩵",
|
||||
"de-CH": "Tili – mini TikTok-Muddi und en ganz bsundere Teil vo minem Läbe 🩵",
|
||||
en: "Tili – my TikTok Muddi and a very special part of my life 🩵",
|
||||
fr: "Tili – ma TikTok Muddi et une partie très spéciale de ma vie 🩵",
|
||||
pt: "Tili – minha TikTok Muddi e uma parte muito especial da minha vida 🩵",
|
||||
},
|
||||
tm_lead: {
|
||||
de: "Eine persönliche Widmung von Dogi an eine seiner wichtigsten Vertrauenspersonen.",
|
||||
"de-CH": "E persönlichi Widmig vo Dogi a eini vo sine wichtigste Vertrouenspersone.",
|
||||
en: "A personal dedication from Dogi to one of his most important trusted people.",
|
||||
fr: "Une dédicace personnelle de Dogi à l'une de ses personnes de confiance les plus importantes.",
|
||||
pt: "Uma dedicatória pessoal de Dogi a uma das pessoas de confiança mais importantes de sua vida.",
|
||||
},
|
||||
|
||||
tm_1: {
|
||||
de: "Es gibt Menschen, die einem nicht einfach nur begegnen. Sie treten in das eigene Leben und hinterlassen etwas, das sich nur schwer in Worte fassen lässt. Sie geben einem Kraft, schenken Vertrauen und erinnern einen daran, dass es selbst in schwierigen Zeiten noch ehrliche und gute Menschen gibt.",
|
||||
"de-CH": "Es git Mönsche, wo eim nid eifach nur begägnet. Sie tred i eim's eigete Läbe und hinderlönd öppis, wo sich nur schwär i Worte fasse lat. Sie gänd eim Chraft, schänked Vertroue und erinnered eim dra, dass es sogar i schwierige Ziite no ehrlichi und guet Mönsche git.",
|
||||
en: "There are people you don't just meet in passing. They step into your life and leave behind something that's hard to put into words. They give you strength, offer trust, and remind you that even in difficult times, there are still honest and good people out there.",
|
||||
fr: "Il y a des gens qu'on ne fait pas que croiser. Ils entrent dans notre vie et y laissent quelque chose difficile à mettre en mots. Ils nous donnent de la force, nous offrent leur confiance et nous rappellent que même dans les moments difficiles, il existe encore des personnes honnêtes et bonnes.",
|
||||
pt: "Há pessoas que a gente não apenas encontra. Elas entram na nossa vida e deixam algo que é difícil de colocar em palavras. Elas nos dão força, nos oferecem confiança e nos lembram que, mesmo em tempos difíceis, ainda existem pessoas honestas e boas.",
|
||||
},
|
||||
tm_quote1: {
|
||||
de: "Für mich ist Tili genau so ein Mensch.",
|
||||
"de-CH": "Für mich isch Tili genau so en Mönsch.",
|
||||
en: "For me, Tili is exactly that kind of person.",
|
||||
fr: "Pour moi, Tili est exactement ce genre de personne.",
|
||||
pt: "Para mim, Tili é exatamente esse tipo de pessoa.",
|
||||
},
|
||||
tm_2: {
|
||||
de: "Tili ist nicht nur eine Streamerin, die mich von Anfang an begleitet hat. Sie ist die erste und bis heute einzige Streamerin, die mir über all die Zeit hinweg treu geblieben ist. Während sich Wege verändert, Menschen entfernt und viele Versprechen irgendwann ihre Bedeutung verloren haben, war sie weiterhin da.",
|
||||
"de-CH": "Tili isch nid nur e Streamerin, wo mich vo Afang a begleitet hät. Sie isch di erschti und bis hüt einzigi Streamerin, wo mir über die ganzi Ziit treu bliebe isch. Während sich Wäg veränderet, Mönsche entfernt und vieli Verspräche irgendwenn ihri Bedütig verlore händ, isch sie witerhin da gsi.",
|
||||
en: "Tili isn't just a streamer who's been with me from the start. She's the first — and to this day the only — streamer who's stayed loyal to me through all this time. While paths changed, people drifted away, and many promises eventually lost their meaning, she kept being there.",
|
||||
fr: "Tili n'est pas seulement une streameuse qui m'accompagne depuis le début. Elle est la première — et à ce jour la seule — streameuse qui m'est restée fidèle tout ce temps. Alors que les chemins changeaient, que des gens s'éloignaient et que beaucoup de promesses ont fini par perdre leur sens, elle est restée présente.",
|
||||
pt: "Tili não é apenas uma streamer que me acompanha desde o início. Ela é a primeira — e até hoje a única — streamer que permaneceu fiel a mim ao longo de todo esse tempo. Enquanto caminhos mudavam, pessoas se afastavam e muitas promessas acabavam perdendo o sentido, ela continuou presente.",
|
||||
},
|
||||
tm_3a: {
|
||||
de: "Nicht nur dann, wenn alles gut lief.",
|
||||
"de-CH": "Nid nur denn, wenn alles guet gloffe isch.",
|
||||
en: "Not only when everything was going well.",
|
||||
fr: "Pas seulement quand tout allait bien.",
|
||||
pt: "Não só quando tudo ia bem.",
|
||||
},
|
||||
tm_3b: {
|
||||
de: "Nicht nur dann, wenn es etwas zu feiern gab.",
|
||||
"de-CH": "Nid nur denn, wenn's öppis z'fiire gäbe hät.",
|
||||
en: "Not only when there was something to celebrate.",
|
||||
fr: "Pas seulement quand il y avait quelque chose à fêter.",
|
||||
pt: "Não só quando havia algo a comemorar.",
|
||||
},
|
||||
tm_3c: {
|
||||
de: "Sondern auch in den Momenten, in denen es schwierig wurde und ich Menschen an meiner Seite besonders gebraucht habe.",
|
||||
"de-CH": "Sondern au i dene Momänt, wo's schwierig worde isch und ich Mönsche a minere Siite bsunders bruucht ha.",
|
||||
en: "But also in the moments when things got difficult and I especially needed people by my side.",
|
||||
fr: "Mais aussi dans les moments où c'est devenu difficile et où j'avais particulièrement besoin de gens à mes côtés.",
|
||||
pt: "Mas também nos momentos em que as coisas ficaram difíceis e eu precisava especialmente de pessoas ao meu lado.",
|
||||
},
|
||||
tm_4: {
|
||||
de: "Genau diese Treue ist etwas, das ich niemals als selbstverständlich ansehen werde.",
|
||||
"de-CH": "Genau die Treui isch öppis, wo ich niemals als sälbschtverständlich aluege werd.",
|
||||
en: "That exact loyalty is something I will never take for granted.",
|
||||
fr: "Cette fidélité est précisément quelque chose que je ne considérerai jamais comme acquise.",
|
||||
pt: "Exatamente essa lealdade é algo que eu nunca vou considerar óbvio.",
|
||||
},
|
||||
tm_5: {
|
||||
de: "Doch Tili ist für mich schon lange viel mehr als eine Streamerin oder ein Teil meiner TikTok-Geschichte. Sie ist meine zweite Mama, meine ganz persönliche TikTok-Muddi. Ein Mensch, bei dem ich mich nicht verstellen muss und bei dem ich weiß, dass ich mit meinen Gedanken, meinen Sorgen und meinen Gefühlen niemals allein bin.",
|
||||
"de-CH": "Doch Tili isch für mich scho lang viel meh als e Streamerin oder en Teil vo minere TikTok-Gschicht. Sie isch mini zwöiti Mama, mini ganz persönlichi TikTok-Muddi. En Mönsch, bi dem ich mich nid verstelle muess und bi dem ich weiss, dass ich mit mine Gedanke, mine Sorge und mine Gfüehl niemals elei bi.",
|
||||
en: "But Tili has long been much more to me than a streamer or a part of my TikTok story. She's my second mom, my very own TikTok Muddi. A person I don't have to pretend around, and with whom I know I'm never alone with my thoughts, my worries, and my feelings.",
|
||||
fr: "Mais Tili est depuis longtemps bien plus qu'une streameuse ou une partie de mon histoire sur TikTok. Elle est ma deuxième maman, ma TikTok Muddi bien à moi. Une personne avec qui je n'ai pas besoin de faire semblant, et avec qui je sais que je ne suis jamais seul avec mes pensées, mes soucis et mes sentiments.",
|
||||
pt: "Mas Tili há muito tempo é bem mais do que uma streamer ou uma parte da minha história no TikTok. Ela é minha segunda mãe, minha própria TikTok Muddi. Uma pessoa com quem não preciso fingir e com quem sei que nunca estou sozinho com meus pensamentos, minhas preocupações e meus sentimentos.",
|
||||
},
|
||||
tm_6: {
|
||||
de: "Sie hört zu, ohne sofort zu urteilen. Sie sagt ehrlich ihre Meinung, auch wenn diese vielleicht nicht immer das ist, was ich gerade hören möchte. Und genau das macht echtes Vertrauen aus: nicht nur die schönen Worte zu sagen, sondern füreinander da zu sein, ehrlich zu bleiben und sich gegenseitig wieder aufzurichten.",
|
||||
"de-CH": "Sie lost zue, ohni sofort z'urteile. Sie seit ehrlich ihri Meinig, au wenn die vilicht nid immer das isch, won ich grad ghöre well. Und genau das macht echts Vertroue us: nid nur die schöne Wörter z'säge, sondern füreinand da z'si, ehrlich z'bliibe und sich gägeseitig wieder ufz'richte.",
|
||||
en: "She listens without immediately judging. She's honest with her opinion, even when it's not always what I want to hear in that moment. And that's exactly what real trust is: not just saying the nice words, but being there for each other, staying honest, and helping each other back up.",
|
||||
fr: "Elle écoute sans juger immédiatement. Elle dit honnêtement son avis, même si ce n'est pas toujours ce que j'ai envie d'entendre sur le moment. Et c'est exactement ça, la vraie confiance : ce n'est pas seulement dire de belles paroles, mais être là l'un pour l'autre, rester honnête et se relever mutuellement.",
|
||||
pt: "Ela escuta sem julgar de imediato. Ela é honesta com sua opinião, mesmo quando nem sempre é o que eu quero ouvir naquele momento. E é exatamente isso que faz a confiança verdadeira: não apenas dizer palavras bonitas, mas estar presente um pelo outro, permanecer honesto e ajudar o outro a se reerguer.",
|
||||
},
|
||||
tm_7: {
|
||||
de: "Neben VanVan gehört Tili zu den wichtigsten Vertrauenspersonen in meinem Leben. Sie kennt nicht nur den Streamer Dogfather, den die Menschen auf TikTok sehen. Sie kennt auch den Menschen dahinter. Sie kennt meine starken Seiten, aber auch meine Unsicherheiten, meine Sorgen und die Momente, in denen selbst ich nicht mehr genau weiß, wie es weitergehen soll.",
|
||||
"de-CH": "Näbe VanVan ghört Tili zu de wichtigste Vertrouenspersone i minem Läbe. Sie kennt nid nur dr Streamer Dogfather, won d Lüt uf TikTok gseh. Sie kennt au dr Mönsch dehinder. Sie kennt mini starche Siite, aber au mini Unsicherheite, mini Sorge und die Momänt, wo sogar ich nümm gnau weiss, wie's witergo söll.",
|
||||
en: "Alongside VanVan, Tili is one of the most important trusted people in my life. She doesn't just know Dogfather the streamer that people see on TikTok. She also knows the person behind it. She knows my strengths, but also my insecurities, my worries, and the moments when even I no longer know exactly how to move forward.",
|
||||
fr: "Aux côtés de VanVan, Tili fait partie des personnes de confiance les plus importantes de ma vie. Elle ne connaît pas seulement Dogfather le streamer que les gens voient sur TikTok. Elle connaît aussi la personne derrière. Elle connaît mes forces, mais aussi mes incertitudes, mes soucis et les moments où même moi je ne sais plus exactement comment avancer.",
|
||||
pt: "Ao lado de VanVan, Tili é uma das pessoas de confiança mais importantes da minha vida. Ela não conhece apenas o Dogfather streamer que as pessoas veem no TikTok. Ela também conhece a pessoa por trás disso. Ela conhece meus pontos fortes, mas também minhas inseguranças, minhas preocupações e os momentos em que até eu mesmo não sei mais exatamente como seguir em frente.",
|
||||
},
|
||||
tm_8: {
|
||||
de: "Und trotzdem bleibt sie.",
|
||||
"de-CH": "Und trotzdäm bliibt sie.",
|
||||
en: "And still, she stays.",
|
||||
fr: "Et pourtant, elle reste.",
|
||||
pt: "E, mesmo assim, ela fica.",
|
||||
},
|
||||
tm_9: {
|
||||
de: "Tili gibt mir das Gefühl, verstanden und angenommen zu werden – nicht aufgrund von Zahlen, Reichweite, Erfolgen oder irgendeiner Rolle, sondern einfach als der Mensch, der ich bin.",
|
||||
"de-CH": "Tili git mir s'Gfühl, verstande und agnoh z'werde – nid wäge Zahle, Reichwiite, Erfolg oder irgend ere Roll, sondern eifach als dr Mönsch, wo ich bi.",
|
||||
en: "Tili gives me the feeling of being understood and accepted — not because of numbers, reach, success, or any kind of role, but simply as the person I am.",
|
||||
fr: "Tili me donne le sentiment d'être compris et accepté — pas à cause de chiffres, de portée, de succès ou d'un quelconque rôle, mais simplement en tant que la personne que je suis.",
|
||||
pt: "Tili me dá a sensação de ser compreendido e aceito — não por causa de números, alcance, sucesso ou qualquer papel, mas simplesmente como a pessoa que eu sou.",
|
||||
},
|
||||
tm_10: {
|
||||
de: "Sie ist für mich ein Stück Familie geworden.",
|
||||
"de-CH": "Sie isch für mich es Stück Familie worde.",
|
||||
en: "She's become a piece of family to me.",
|
||||
fr: "Elle est devenue pour moi un morceau de famille.",
|
||||
pt: "Ela se tornou, para mim, um pedaço de família.",
|
||||
},
|
||||
tm_11: {
|
||||
de: "Eine Familie, die nicht durch denselben Namen oder dasselbe Blut entstanden ist, sondern durch Vertrauen, Loyalität, gemeinsame Erlebnisse und die Entscheidung, auch dann füreinander da zu sein, wenn das Leben einmal nicht perfekt läuft.",
|
||||
"de-CH": "E Familie, wo nid dur dr gliich Name oder s'gliich Bluet entstande isch, sondern dur Vertroue, Loyalität, gmeinsami Erläbnis und dr Entscheid, au denn füreinand da z'si, wenn's Läbe emol nid perfekt lauft.",
|
||||
en: "A family that wasn't created by the same name or the same blood, but through trust, loyalty, shared experiences, and the decision to be there for each other even when life isn't running perfectly.",
|
||||
fr: "Une famille qui n'est pas née du même nom ou du même sang, mais de la confiance, de la loyauté, des expériences partagées et de la décision d'être là l'un pour l'autre, même quand la vie ne se déroule pas parfaitement.",
|
||||
pt: "Uma família que não nasceu do mesmo nome ou do mesmo sangue, mas da confiança, da lealdade, de experiências compartilhadas e da decisão de estar presente um pelo outro mesmo quando a vida não corre perfeitamente.",
|
||||
},
|
||||
tm_12: {
|
||||
de: "Für mich steht Tili für Hoffnung. Sie ist einer der Menschen, die mir zeigen, dass das Gute im Menschen noch existiert. Dass es noch Personen gibt, die nicht nur an sich selbst denken. Menschen, die ein ehrliches Herz besitzen, anderen Mut machen und ihre Hand nicht zurückziehen, sobald der Weg schwieriger wird.",
|
||||
"de-CH": "Für mich staht Tili für Hoffnig. Sie isch eini vo dene Mönsche, wo mir zeiged, dass s'Guete im Mönsch no existiert. Dass es no Persone git, wo nid nur a sich sälber dänked. Mönsche, wo es ehrlichs Härz händ, andere Muet mached und ihri Hand nid zruckzieh, sobald dr Wäg schwieriger wird.",
|
||||
en: "To me, Tili stands for hope. She's one of the people who show me that the good in people still exists. That there are still people who don't just think of themselves. People with an honest heart who give others courage and don't pull their hand away the moment the road gets harder.",
|
||||
fr: "Pour moi, Tili incarne l'espoir. Elle fait partie de ces personnes qui me montrent que la bonté existe encore chez l'être humain. Qu'il existe encore des gens qui ne pensent pas seulement à eux-mêmes. Des personnes au cœur honnête, qui donnent du courage aux autres et ne retirent pas leur main dès que le chemin devient plus difficile.",
|
||||
pt: "Para mim, Tili representa esperança. Ela é uma das pessoas que me mostram que o bem ainda existe nas pessoas. Que ainda existem pessoas que não pensam só em si mesmas. Pessoas de coração honesto, que dão coragem aos outros e não retiram a mão assim que o caminho fica mais difícil.",
|
||||
},
|
||||
tm_13: {
|
||||
de: "Gerade in einer Welt, in der vieles schnelllebig und oberflächlich geworden ist, ist ein Mensch wie Tili etwas unglaublich Wertvolles.",
|
||||
"de-CH": "Grad i ere Wält, wo vieles schnälllebig und oberflächlich worde isch, isch en Mönsch wie Tili öppis unglaublich Wärtvolls.",
|
||||
en: "Especially in a world where so much has become fast-paced and superficial, a person like Tili is something incredibly valuable.",
|
||||
fr: "Justement dans un monde où beaucoup de choses sont devenues éphémères et superficielles, une personne comme Tili est quelque chose d'incroyablement précieux.",
|
||||
pt: "Justamente em um mundo onde muita coisa se tornou apressada e superficial, uma pessoa como Tili é algo incrivelmente valioso.",
|
||||
},
|
||||
tm_14: {
|
||||
de: "Sie hat mich unterstützt, an mich geglaubt und mir immer wieder gezeigt, dass ich nicht allein bin. Vielleicht kann ich ihr niemals vollständig zurückgeben, was sie mir durch ihre Art, ihre Treue und ihr Vertrauen gegeben hat. Aber ich möchte, dass sie weiß, dass ich nichts davon vergesse.",
|
||||
"de-CH": "Sie hät mich unterstützt, a mich glaubt und mir immer wieder zeigt, dass ich nid elei bi. Vilicht chan ich ihre niemals vollständig zruckgäh, was sie mir dur ihri Art, ihri Treui und ihres Vertroue gäh hät. Aber ich well, dass sie weiss, dass ich nüt devo vergiss.",
|
||||
en: "She has supported me, believed in me, and shown me again and again that I'm not alone. Maybe I can never fully give back what she has given me through her way, her loyalty, and her trust. But I want her to know that I forget none of it.",
|
||||
fr: "Elle m'a soutenu, a cru en moi et m'a montré encore et encore que je ne suis pas seul. Je ne pourrai peut-être jamais lui rendre pleinement ce qu'elle m'a donné par sa manière d'être, sa fidélité et sa confiance. Mais je veux qu'elle sache que je n'oublie rien de tout cela.",
|
||||
pt: "Ela me apoiou, acreditou em mim e me mostrou repetidas vezes que eu não estou sozinho. Talvez eu nunca consiga retribuir totalmente o que ela me deu através do seu jeito, da sua lealdade e da sua confiança. Mas eu quero que ela saiba que eu não esqueço nada disso.",
|
||||
},
|
||||
tm_15a: {
|
||||
de: "Ich vergesse nicht, wer geblieben ist.",
|
||||
"de-CH": "Ich vergiss nid, wär bliebe isch.",
|
||||
en: "I don't forget who stayed.",
|
||||
fr: "Je n'oublie pas qui est resté.",
|
||||
pt: "Eu não esqueço quem ficou.",
|
||||
},
|
||||
tm_15b: {
|
||||
de: "Ich vergesse nicht, wer an mich geglaubt hat.",
|
||||
"de-CH": "Ich vergiss nid, wär a mich glaubt hät.",
|
||||
en: "I don't forget who believed in me.",
|
||||
fr: "Je n'oublie pas qui a cru en moi.",
|
||||
pt: "Eu não esqueço quem acreditou em mim.",
|
||||
},
|
||||
tm_15c: {
|
||||
de: "Und ich vergesse niemals, wer mir in schweren Momenten gezeigt hat, dass Aufgeben keine Lösung ist.",
|
||||
"de-CH": "Und ich vergiss niemals, wär mir i schwäre Momänt zeigt hät, dass Ufgäh kei Lösig isch.",
|
||||
en: "And I will never forget who showed me in the hardest moments that giving up is not a solution.",
|
||||
fr: "Et je n'oublierai jamais qui m'a montré, dans les moments difficiles, qu'abandonner n'est pas une solution.",
|
||||
pt: "E eu nunca vou esquecer quem me mostrou, nos momentos mais difíceis, que desistir não é solução.",
|
||||
},
|
||||
tm_16: {
|
||||
de: "Diese Seite ist deshalb nicht einfach nur eine Vorstellung. Sie ist eine persönliche Widmung an einen Menschen, der einen festen Platz in meinem Leben und in meinem Herzen besitzt.",
|
||||
"de-CH": "Die Site isch drum nid eifach nur e Vorstellig. Sie isch e persönlichi Widmig a en Mönsch, wo en fäschte Platz i minem Läbe und i minem Härz hät.",
|
||||
en: "This page is therefore not just an introduction. It's a personal dedication to a person who holds a firm place in my life and in my heart.",
|
||||
fr: "Cette page n'est donc pas simplement une présentation. C'est une dédicace personnelle à une personne qui occupe une place solide dans ma vie et dans mon cœur.",
|
||||
pt: "Por isso, esta página não é apenas uma apresentação. É uma dedicatória pessoal a uma pessoa que ocupa um lugar firme na minha vida e no meu coração.",
|
||||
},
|
||||
tm_danke_h2: {
|
||||
de: "Danke, Tili.", "de-CH": "Danke, Tili.",
|
||||
en: "Thank you, Tili.", fr: "Merci, Tili.", pt: "Obrigado, Tili.",
|
||||
},
|
||||
tm_danke1: {
|
||||
de: "Danke für deine Treue.", "de-CH": "Danke für dini Treui.",
|
||||
en: "Thank you for your loyalty.", fr: "Merci pour ta fidélité.", pt: "Obrigado pela sua lealdade.",
|
||||
},
|
||||
tm_danke2: {
|
||||
de: "Danke für dein Vertrauen.", "de-CH": "Danke für dis Vertroue.",
|
||||
en: "Thank you for your trust.", fr: "Merci pour ta confiance.", pt: "Obrigado pela sua confiança.",
|
||||
},
|
||||
tm_danke3: {
|
||||
de: "Danke für deine Ehrlichkeit.", "de-CH": "Danke für dini Ehrlichkeit.",
|
||||
en: "Thank you for your honesty.", fr: "Merci pour ton honnêteté.", pt: "Obrigado pela sua honestidade.",
|
||||
},
|
||||
tm_danke4: {
|
||||
de: "Danke für jedes Gespräch, jeden Rat und jeden Moment, in dem du für mich da warst.",
|
||||
"de-CH": "Danke für jedes Gspräch, jede Rat und jede Momänt, wo du für mich da gsi bisch.",
|
||||
en: "Thank you for every conversation, every piece of advice, and every moment you were there for me.",
|
||||
fr: "Merci pour chaque conversation, chaque conseil et chaque instant où tu as été là pour moi.",
|
||||
pt: "Obrigado por cada conversa, cada conselho e cada momento em que você esteve presente por mim.",
|
||||
},
|
||||
tm_danke5: {
|
||||
de: "Danke dafür, dass du meine TikTok-Muddi bist und mir immer wieder zeigst, wie wertvoll echte Verbundenheit sein kann.",
|
||||
"de-CH": "Danke defür, dass du mini TikTok-Muddi bisch und mir immer wieder zeigsch, wie wärtvoll echti Verbundeheit si cha.",
|
||||
en: "Thank you for being my TikTok Muddi and for showing me again and again how valuable real connection can be.",
|
||||
fr: "Merci d'être ma TikTok Muddi et de me montrer encore et encore à quel point un vrai lien peut être précieux.",
|
||||
pt: "Obrigado por ser minha TikTok Muddi e por me mostrar repetidas vezes o quanto uma conexão verdadeira pode ser valiosa.",
|
||||
},
|
||||
tm_17: {
|
||||
de: "Egal, wie viele Wege noch vor uns liegen und wie sehr sich das Leben verändern wird: Du wirst immer ein wichtiger Teil meiner Geschichte sein.",
|
||||
"de-CH": "Egal, wie vieli Wäg no vor eus liege und wie sehr sich s'Läbe verändere wird: Du wirsch immer en wichtige Teil vo minere Gschicht si.",
|
||||
en: "No matter how many paths still lie ahead of us and how much life will change: you will always be an important part of my story.",
|
||||
fr: "Peu importe combien de chemins nous attendent encore et à quel point la vie changera : tu resteras toujours une part importante de mon histoire.",
|
||||
pt: "Não importa quantos caminhos ainda estejam pela frente e o quanto a vida vá mudar: você sempre será uma parte importante da minha história.",
|
||||
},
|
||||
tm_18a: {
|
||||
de: "Nicht nur als Streamerin.", "de-CH": "Nid nur als Streamerin.",
|
||||
en: "Not only as a streamer.", fr: "Pas seulement en tant que streameuse.", pt: "Não só como streamer.",
|
||||
},
|
||||
tm_18b: {
|
||||
de: "Nicht nur als Unterstützerin.", "de-CH": "Nid nur als Unterstützerin.",
|
||||
en: "Not only as a supporter.", fr: "Pas seulement en tant que soutien.", pt: "Não só como apoiadora.",
|
||||
},
|
||||
tm_18c: {
|
||||
de: "Sondern als Vertrauensperson, zweite Mama und Familie.",
|
||||
"de-CH": "Sondern als Vertrouensperson, zwöiti Mama und Familie.",
|
||||
en: "But as a trusted person, second mom, and family.",
|
||||
fr: "Mais en tant que personne de confiance, deuxième maman et famille.",
|
||||
pt: "Mas como pessoa de confiança, segunda mãe e família.",
|
||||
},
|
||||
tm_quote2: {
|
||||
de: "Diese Seite ist für dich, Tili – weil besondere Menschen einen besonderen Platz verdienen.",
|
||||
"de-CH": "Die Site isch für dich, Tili – wil bsundrigi Mönsche en bsundere Platz verdienend.",
|
||||
en: "This page is for you, Tili — because special people deserve a special place.",
|
||||
fr: "Cette page est pour toi, Tili — parce que les personnes spéciales méritent une place spéciale.",
|
||||
pt: "Esta página é para você, Tili — porque pessoas especiais merecem um lugar especial.",
|
||||
},
|
||||
tm_signatur_dank: {
|
||||
de: "In tiefer Dankbarkeit und von ganzem Herzen",
|
||||
"de-CH": "In tüüfer Dankbarkeit und vo ganzem Härz",
|
||||
en: "In deep gratitude and with all my heart",
|
||||
fr: "Avec une profonde gratitude et de tout mon cœur",
|
||||
pt: "Com profunda gratidão e de todo o coração",
|
||||
},
|
||||
tm_signatur1: {
|
||||
de: "Dein Dogi", "de-CH": "Dein Dogi",
|
||||
en: "Your Dogi", fr: "Ton Dogi", pt: "Seu Dogi",
|
||||
},
|
||||
tm_signatur2: {
|
||||
de: "Dogfather – Filipe 🩵", "de-CH": "Dogfather – Filipe 🩵",
|
||||
en: "Dogfather – Filipe 🩵", fr: "Dogfather – Filipe 🩵", pt: "Dogfather – Filipe 🩵",
|
||||
},
|
||||
tm_btn_zurueck: {
|
||||
de: "← Zurück zu DogFather", "de-CH": "← Zrugg zu DogFather",
|
||||
en: "← Back to DogFather", fr: "← Retour à DogFather", pt: "← Voltar a DogFather",
|
||||
},
|
||||
};
|
||||
@@ -13,83 +13,347 @@ window.I18N_PAGE = {
|
||||
en: "Time Travel", fr: "Voyage dans le temps", pt: "Viagem no Tempo",
|
||||
},
|
||||
zr_lead: {
|
||||
de: `Die Geschichte von DogFather & Team Dogi.`,
|
||||
"de-CH": `Die Gschicht vo DogFather & Team Dogi.`,
|
||||
en: `The story of DogFather & Team Dogi.`,
|
||||
fr: `L'histoire de DogFather & Team Dogi.`,
|
||||
pt: `A história de DogFather & Team Dogi.`,
|
||||
de: `Vom ersten Livestream bis zum DOGFATHER UNIVERSE — die Reise von DogFather & Team Dogi.`,
|
||||
"de-CH": `Vom erste Livestream bis zum DOGFATHER UNIVERSE — d Reise vo DogFather & Team Dogi.`,
|
||||
en: `From the first livestream to the DOGFATHER UNIVERSE — the journey of DogFather & Team Dogi.`,
|
||||
fr: `Du premier live à l'univers DOGFATHER UNIVERSE — le parcours de DogFather & Team Dogi.`,
|
||||
pt: `Da primeira live ao DOGFATHER UNIVERSE — a jornada de DogFather & Team Dogi.`,
|
||||
},
|
||||
zr_datum_folgt: {
|
||||
de: "Datum folgt", "de-CH": "Datum chunnt no",
|
||||
en: "Date coming soon", fr: "Date à venir", pt: "Data em breve",
|
||||
},
|
||||
|
||||
/* ---------- Station 1 — Datum folgt ---------- */
|
||||
zr_ev1_h3: {
|
||||
de: "Start als Content Creator", "de-CH": "Start als Content Creator",
|
||||
en: "Start as a content creator", fr: "Débuts en tant que créateur de contenu", pt: "Início como criador de conteúdo",
|
||||
en: "Start as a Content Creator", fr: "Débuts en tant que créateur de contenu", pt: "Início como criador de conteúdo",
|
||||
},
|
||||
zr_ev1_p: {
|
||||
de: "Die ersten Schritte auf TikTok.",
|
||||
"de-CH": "Die erste Schritt uf TikTok.",
|
||||
en: "The first steps on TikTok.",
|
||||
fr: "Les premiers pas sur TikTok.",
|
||||
pt: "Os primeiros passos no TikTok.",
|
||||
de: "DogFather beginnt seine Reise auf TikTok. Aus ersten Videos und Livestreams entwickelt sich Schritt für Schritt ein eigener Content-Stil mit Humor, Persönlichkeit und direkter Nähe zur Community.",
|
||||
"de-CH": "DogFather startet sini Reise uf TikTok. Us de erste Videos und Livestreams entwicklet sich Schritt für Schritt en eigete Content-Stil mit Humor, Persönlichkeit und direkte Nechi zur Community.",
|
||||
en: "DogFather begins his journey on TikTok. From his first videos and livestreams, a unique content style emerges step by step — full of humor, personality, and a genuine closeness to the community.",
|
||||
fr: "DogFather commence son aventure sur TikTok. À travers ses premières vidéos et lives, un style de contenu bien à lui se dessine peu à peu, fait d'humour, de personnalité et d'une réelle proximité avec la communauté.",
|
||||
pt: "DogFather começa sua jornada no TikTok. A partir dos primeiros vídeos e lives, um estilo de conteúdo próprio vai surgindo aos poucos, cheio de humor, personalidade e proximidade real com a comunidade.",
|
||||
},
|
||||
|
||||
/* ---------- Station 2 — Datum folgt ---------- */
|
||||
zr_ev2_h3: {
|
||||
de: "HasiDog entsteht", "de-CH": "HasiDog entstaht",
|
||||
en: "HasiDog is born", fr: "Naissance de HasiDog", pt: "Nasce o HasiDog",
|
||||
de: "Team Dogi entsteht", "de-CH": "Team Dogi entstaht",
|
||||
en: "Team Dogi Is Born", fr: "Naissance de Team Dogi", pt: "Nasce a Team Dogi",
|
||||
},
|
||||
zr_ev2_p: {
|
||||
de: "Aus dem Spitznamen wird eine eigene Marke — inkl. Musikprojekt auf Spotify.",
|
||||
"de-CH": "Us em Spitzname wird e eigeni Marke — inklusive Musikprojekt uf Spotify.",
|
||||
en: "The nickname becomes its own brand — including a music project on Spotify.",
|
||||
fr: "Le surnom devient une marque à part entière — avec un projet musical sur Spotify.",
|
||||
pt: "O apelido se transforma em uma marca própria — incluindo um projeto musical no Spotify.",
|
||||
de: "Aus einzelnen Zuschauern wird eine starke Gemeinschaft. Menschen kommen zusammen, unterstützen sich gegenseitig und erschaffen eine Community, in der Zusammenhalt, Loyalität und Respekt an erster Stelle stehen.",
|
||||
"de-CH": "Us einzelne Zueschauer wird e starki Gmeinschaft. D Lüt chömed zäme, unterstützed sich gägesitig und erschaffed e Community, wo Zämehalt, Loyalität und Respekt a erster Stell stönd.",
|
||||
en: "Individual viewers grow into a powerful community. People come together, support each other, and build a community where unity, loyalty, and respect come first.",
|
||||
fr: "De simples spectateurs se transforment en une communauté soudée. Les gens se rassemblent, se soutiennent mutuellement et créent une communauté où la cohésion, la loyauté et le respect passent avant tout.",
|
||||
pt: "De simples espectadores nasce uma comunidade forte. As pessoas se unem, se apoiam mutuamente e criam uma comunidade onde união, lealdade e respeito vêm em primeiro lugar.",
|
||||
},
|
||||
|
||||
/* ---------- Station 3 — Ostern 2025 (Meilenstein) ---------- */
|
||||
zr_ev3_date: {
|
||||
de: "Ostern 2025", "de-CH": "Oschtere 2025",
|
||||
en: "Easter 2025", fr: "Pâques 2025", pt: "Páscoa de 2025",
|
||||
},
|
||||
zr_ev3_h3: {
|
||||
de: "Start als Manager bei Spicy Media", "de-CH": "Start als Manager bi Spicy Media",
|
||||
en: "Start as a manager at Spicy Media", fr: "Débuts en tant que manager chez Spicy Media", pt: "Início como gerente na Spicy Media",
|
||||
de: "HasiDog wird geboren", "de-CH": "HasiDog wird gebore",
|
||||
en: "HasiDog Is Born", fr: "Naissance de HasiDog", pt: "Nasce o HasiDog",
|
||||
},
|
||||
zr_ev3_p: {
|
||||
de: "DogFather übernimmt die Manager-Rolle, erste Scouts und Creator kommen dazu.",
|
||||
"de-CH": "DogFather übernimmt d Manager-Rolle, die erste Scouts und Creator chömed dezue.",
|
||||
en: "DogFather takes on the manager role, and the first scouts and creators join.",
|
||||
fr: "DogFather prend le rôle de manager, les premiers scouts et créateurs rejoignent l'équipe.",
|
||||
pt: "DogFather assume o papel de gerente, os primeiros scouts e criadores se juntam.",
|
||||
de: "Aus einem Spitznamen entsteht eine eigenständige Marke. HasiDog entwickelt sich zu einem festen Bestandteil des DogFather-Universums – mit eigener Persönlichkeit, eigenen Geschichten und einem geplanten Musikprojekt auf Spotify.",
|
||||
"de-CH": "Us emene Spitzname wird e eigeständigi Marke. HasiDog entwicklet sich zu emene feste Bestandteil vom DogFather-Universum — mit eigeter Persönlichkeit, eigete Gschichte und emene plante Musikprojekt uf Spotify.",
|
||||
en: "A nickname becomes its own brand. HasiDog grows into a fixed part of the DogFather universe — with his own personality, his own stories, and a planned music project on Spotify.",
|
||||
fr: "Un surnom devient une marque à part entière. HasiDog devient un élément incontournable de l'univers DogFather — avec sa propre personnalité, ses propres histoires et un projet musical prévu sur Spotify.",
|
||||
pt: "Um apelido se transforma em uma marca própria. HasiDog se torna parte fixa do universo DogFather — com personalidade, histórias e um projeto musical planejado no Spotify.",
|
||||
},
|
||||
|
||||
/* ---------- Station 4 — 7. März 2026 (Meilenstein) ---------- */
|
||||
zr_ev4_date: {
|
||||
de: "7. März 2026", "de-CH": "7. März 2026",
|
||||
en: "March 7, 2026", fr: "7 mars 2026", pt: "7 de março de 2026",
|
||||
},
|
||||
zr_ev4_h3: {
|
||||
de: `Buch „Hasidog & Casper"`,
|
||||
"de-CH": `Buech „Hasidog & Casper"`,
|
||||
en: `Book „Hasidog & Casper"`,
|
||||
fr: `Livre « Hasidog & Casper »`,
|
||||
pt: `Livro „Hasidog & Casper"`,
|
||||
de: "Der 28-Stunden-Stream", "de-CH": "De 28-Stunde-Stream",
|
||||
en: "The 28-Hour Stream", fr: "Le stream de 28 heures", pt: "A live de 28 horas",
|
||||
},
|
||||
zr_ev4_p: {
|
||||
de: "Die Geschichte von DogFather und seinem Husky wird zum Buch.",
|
||||
"de-CH": "D Gschicht vo DogFather und sim Husky wird zum Buech.",
|
||||
en: "The story of DogFather and his husky becomes a book.",
|
||||
fr: "L'histoire de DogFather et de son husky devient un livre.",
|
||||
pt: "A história de DogFather e seu husky vira um livro.",
|
||||
de: "DogFather stellt sich einer besonderen Herausforderung und streamt insgesamt 28 Stunden. Ein intensives Erlebnis voller Unterhaltung, besonderer Momente und starkem Zusammenhalt innerhalb der Community.",
|
||||
"de-CH": "DogFather stellt sich ere bsundere Herusforderig und streamt insgsamt 28 Stund. Es intensivs Erläbnis voller Underhaltig, bsundere Momänt und starkem Zämehalt innerhalb vo de Community.",
|
||||
en: "DogFather takes on a very special challenge and streams for a total of 28 hours. An intense experience full of entertainment, special moments, and strong community spirit.",
|
||||
fr: "DogFather se lance un défi hors norme et streame pendant 28 heures d'affilée. Une expérience intense, pleine de divertissement, de moments forts et d'une grande cohésion au sein de la communauté.",
|
||||
pt: "DogFather encara um desafio especial e transmite ao vivo por 28 horas seguidas. Uma experiência intensa, cheia de diversão, momentos especiais e muita união na comunidade.",
|
||||
},
|
||||
|
||||
/* ---------- Station 5 — Mai 2026 ---------- */
|
||||
zr_ev5_date: {
|
||||
de: "Mai 2026", "de-CH": "Mai 2026",
|
||||
en: "May 2026", fr: "Mai 2026", pt: "Maio de 2026",
|
||||
},
|
||||
zr_ev5_h3: {
|
||||
de: "Kooperation VanVan Teddys", "de-CH": "Kooperation VanVan Teddys",
|
||||
en: "VanVan Teddys collaboration", fr: "Collaboration VanVan Teddys", pt: "Colaboração VanVan Teddys",
|
||||
de: "Ein neues Kapitel beginnt", "de-CH": "Es neus Kapitel fangt a",
|
||||
en: "A New Chapter Begins", fr: "Un nouveau chapitre commence", pt: "Um novo capítulo começa",
|
||||
},
|
||||
zr_ev5_p: {
|
||||
de: "Merchandise-Kooperation rund um VanVan startet.",
|
||||
"de-CH": "D Merchandise-Kooperation rund um VanVan startet.",
|
||||
en: "The merchandise collaboration around VanVan begins.",
|
||||
fr: "La collaboration de produits dérivés autour de VanVan démarre.",
|
||||
pt: "Começa a colaboração de produtos em torno do VanVan.",
|
||||
de: "DogFather beendet die Zusammenarbeit mit seiner bisherigen Agentur und entscheidet sich dazu, seinen weiteren Weg neu auszurichten. Der Fokus liegt dabei auf persönlicher Weiterentwicklung, neuen Zielen und mehr Eigenständigkeit.",
|
||||
"de-CH": "DogFather beendet d Zämearbeit mit sinere bisherige Agentur und entscheidet sich derfür, sim wiitere Wäg neu uszrichte. De Fokus liit derbi uf persönlicher Wiiterentwicklig, neue Ziel und meh Eigeständigkeit.",
|
||||
en: "DogFather ends his collaboration with his previous agency and decides to realign his path. The focus shifts to personal growth, new goals, and greater independence.",
|
||||
fr: "DogFather met fin à sa collaboration avec son agence précédente et décide de réorienter sa trajectoire. L'accent est désormais mis sur son développement personnel, de nouveaux objectifs et plus d'indépendance.",
|
||||
pt: "DogFather encerra a parceria com sua antiga agência e decide reorientar seu caminho. O foco passa a ser o desenvolvimento pessoal, novas metas e mais independência.",
|
||||
},
|
||||
|
||||
/* ---------- Station 6 — 2. bis 15. Juni 2026 ---------- */
|
||||
zr_ev6_date: {
|
||||
de: "2. bis 15. Juni 2026", "de-CH": "2. bis 15. Juni 2026",
|
||||
en: "June 2–15, 2026", fr: "Du 2 au 15 juin 2026", pt: "De 2 a 15 de junho de 2026",
|
||||
},
|
||||
zr_ev6_h3: {
|
||||
de: "DOGFATHER UNIVERSE geht online", "de-CH": "DOGFATHER UNIVERSE gaht online",
|
||||
en: "DOGFATHER UNIVERSE goes online", fr: "DOGFATHER UNIVERSE est mis en ligne", pt: "DOGFATHER UNIVERSE entra no ar",
|
||||
de: "Die große 12-Stunden-Challenge", "de-CH": "Die grossi 12-Stunde-Challenge",
|
||||
en: "The Big 12-Hour Challenge", fr: "Le grand défi de 12 heures", pt: "O grande desafio de 12 horas",
|
||||
},
|
||||
zr_ev6_p: {
|
||||
de: "Die eigene Website vereint alle drei Welten erstmals an einem Ort.",
|
||||
"de-CH": "Die eigeni Website vereint alli drei Wälte zum erste Mal a eim Ort.",
|
||||
en: "The own website brings all three worlds together in one place for the first time.",
|
||||
fr: "Le site officiel réunit pour la première fois les trois univers en un seul endroit.",
|
||||
pt: "O próprio site reúne os três universos pela primeira vez em um só lugar.",
|
||||
de: "Im Rahmen einer besonderen Community-Aktion stellt sich DogFather einer weiteren Streaming-Herausforderung. Gemeinsam mit Team Dogi entstehen zahlreiche unterhaltsame und unvergessliche Momente.",
|
||||
"de-CH": "Im Rahme vonere bsundere Community-Aktion stellt sich DogFather ere wiitere Streaming-Herusforderig. Zäme mit Team Dogi entstönd zahlriichi underhaltsami und unvergässlichi Momänt.",
|
||||
en: "As part of a special community event, DogFather takes on another streaming challenge. Together with Team Dogi, countless entertaining and unforgettable moments are created.",
|
||||
fr: "Dans le cadre d'une action communautaire spéciale, DogFather relève un nouveau défi de streaming. Avec Team Dogi, de nombreux moments divertissants et inoubliables voient le jour.",
|
||||
pt: "Como parte de uma ação especial da comunidade, DogFather encara mais um desafio de live. Junto com a Team Dogi, surgem inúmeros momentos divertidos e inesquecíveis.",
|
||||
},
|
||||
|
||||
/* ---------- Station 7 — Juli 2026 ---------- */
|
||||
zr_ev7_date: {
|
||||
de: "Juli 2026", "de-CH": "Juli 2026",
|
||||
en: "July 2026", fr: "Juillet 2026", pt: "Julho de 2026",
|
||||
},
|
||||
zr_ev7_h3: {
|
||||
de: "Start als Manager bei Spicy Media", "de-CH": "Start als Manager bi Spicy Media",
|
||||
en: "Starting as a Manager at Spicy Media", fr: "Débuts en tant que manager chez Spicy Media", pt: "Início como gerente na Spicy Media",
|
||||
},
|
||||
zr_ev7_p: {
|
||||
de: "DogFather übernimmt bei Spicy Media die Rolle als Manager. Neben seiner Tätigkeit als Streamer beginnt er damit, Creator zu begleiten, Talente zu fördern und ein eigenes, starkes Team aufzubauen.",
|
||||
"de-CH": "DogFather übernimmt bi Spicy Media d Rolle als Manager. Näbe sinere Tätigkeit als Streamer fangt er a, Creator z'begleite, Talänt z'fördere und es eiges, starkes Team ufzboue.",
|
||||
en: "DogFather takes on the role of manager at Spicy Media. Alongside his work as a streamer, he begins guiding creators, nurturing talent, and building his own strong team.",
|
||||
fr: "DogFather devient manager chez Spicy Media. En plus de son activité de streamer, il commence à accompagner des créateurs, à révéler des talents et à bâtir sa propre équipe solide.",
|
||||
pt: "DogFather assume o cargo de gerente na Spicy Media. Além de atuar como streamer, ele passa a acompanhar criadores, descobrir talentos e construir sua própria equipe forte.",
|
||||
},
|
||||
|
||||
/* ---------- Station 8 — Juli 2026 ---------- */
|
||||
zr_ev8_date: {
|
||||
de: "Juli 2026", "de-CH": "Juli 2026",
|
||||
en: "July 2026", fr: "Juillet 2026", pt: "Julho de 2026",
|
||||
},
|
||||
zr_ev8_h3: {
|
||||
de: "Das Manager-Team wächst", "de-CH": "Ds Manager-Team wachst",
|
||||
en: "The Manager Team Grows", fr: "L'équipe de management s'agrandit", pt: "A equipe de gerência cresce",
|
||||
},
|
||||
zr_ev8_p: {
|
||||
de: "Mit VanVan als rechter Hand und Diene als wichtiger Unterstützung entsteht eine klare Teamstruktur. Gemeinsam werden Creator begleitet, Probleme gelöst und neue Projekte geplant.",
|
||||
"de-CH": "Mit VanVan als rechti Hand und Diene als wichtigi Unterstützig entstaht e klari Teamstruktur. Zäme wärded Creator begleitet, Problem glöst und neui Projekt planet.",
|
||||
en: "With VanVan as his right hand and Diene as key support, a clear team structure takes shape. Together, they guide creators, solve problems, and plan new projects.",
|
||||
fr: "Avec VanVan comme bras droit et Diene comme soutien essentiel, une structure d'équipe claire se met en place. Ensemble, ils accompagnent les créateurs, résolvent les problèmes et planifient de nouveaux projets.",
|
||||
pt: "Com VanVan como braço direito e Diene como apoio fundamental, surge uma estrutura de equipe clara. Juntos, eles acompanham criadores, resolvem problemas e planejam novos projetos.",
|
||||
},
|
||||
|
||||
/* ---------- Station 9 — 27. Juli bis 2. August 2026 (Meilenstein) ---------- */
|
||||
zr_ev9_date: {
|
||||
de: "27. Juli bis 2. August 2026", "de-CH": "27. Juli bis 2. Auguscht 2026",
|
||||
en: "July 27 – August 2, 2026", fr: "Du 27 juillet au 2 août 2026", pt: "De 27 de julho a 2 de agosto de 2026",
|
||||
},
|
||||
zr_ev9_h3: {
|
||||
de: "Der Creator Cup", "de-CH": "De Creator Cup",
|
||||
en: "The Creator Cup", fr: "Le Creator Cup", pt: "O Creator Cup",
|
||||
},
|
||||
zr_ev9_p: {
|
||||
de: "Gemeinsam mit Cidem organisiert DogFather den Creator Cup für die Streamer von Spicy Media. Liveminuten, Teamherzen und Coins werden zu Punkten – verbunden mit besonderen Preisen und einer starken gemeinsamen Aktion.",
|
||||
"de-CH": "Zäme mit Cidem organisiert DogFather de Creator Cup für d Streamer vo Spicy Media. Liveminute, Teamherze und Coins wärded zu Punkt — verbunde mit bsundere Priise und ere starke gmeinsame Aktion.",
|
||||
en: "Together with Cidem, DogFather organizes the Creator Cup for Spicy Media's streamers. Live minutes, team hearts, and coins become points — combined with special prizes and a strong joint event.",
|
||||
fr: "Avec Cidem, DogFather organise le Creator Cup pour les streamers de Spicy Media. Minutes de live, cœurs d'équipe et coins deviennent des points, avec des récompenses spéciales et un fort esprit collectif.",
|
||||
pt: "Junto com Cidem, DogFather organiza o Creator Cup para os streamers da Spicy Media. Minutos ao vivo, corações da equipe e coins viram pontos, com prêmios especiais e uma forte ação em conjunto.",
|
||||
},
|
||||
|
||||
/* ---------- Station 10 — 28. Juli 2026 ---------- */
|
||||
zr_ev10_date: {
|
||||
de: "28. Juli 2026", "de-CH": "28. Juli 2026",
|
||||
en: "July 28, 2026", fr: "28 juillet 2026", pt: "28 de julho de 2026",
|
||||
},
|
||||
zr_ev10_h3: {
|
||||
de: "Patrick und Bananenstift werden Scouts", "de-CH": "Patrick und Bananenstift wärded Scouts",
|
||||
en: "Patrick and Bananenstift Become Scouts", fr: "Patrick et Bananenstift deviennent scouts", pt: "Patrick e Bananenstift se tornam scouts",
|
||||
},
|
||||
zr_ev10_p: {
|
||||
de: "Patrick und Bananenstift schließen sich DogFather als Scouts an. Ihre Aufgabe ist es, neue Talente auf TikTok zu entdecken, zu begleiten und auf ihrem Weg als Creator zu unterstützen.",
|
||||
"de-CH": "Patrick und Bananenstift schliessed sich DogFather als Scouts a. Ihri Ufgab isch, neui Talänt uf TikTok z'entdecke, z'begleite und uf ihrem Wäg als Creator z'unterstütze.",
|
||||
en: "Patrick and Bananenstift join DogFather as scouts. Their job is to discover new talent on TikTok, guide them, and support them on their journey as creators.",
|
||||
fr: "Patrick et Bananenstift rejoignent DogFather en tant que scouts. Leur mission : dénicher de nouveaux talents sur TikTok, les accompagner et les soutenir dans leur parcours de créateur.",
|
||||
pt: "Patrick e Bananenstift se juntam a DogFather como scouts. A missão deles é descobrir novos talentos no TikTok, acompanhá-los e apoiá-los em sua jornada como criadores.",
|
||||
},
|
||||
|
||||
/* ---------- Station 11 — 28. Juli 2026 ---------- */
|
||||
zr_ev11_date: {
|
||||
de: "28. Juli 2026", "de-CH": "28. Juli 2026",
|
||||
en: "July 28, 2026", fr: "28 juillet 2026", pt: "28 de julho de 2026",
|
||||
},
|
||||
zr_ev11_h3: {
|
||||
de: "Aufbau eines eigenen Creator-Systems", "de-CH": "Ufbou vonemene eigete Creator-System",
|
||||
en: "Building an Own Creator System", fr: "Construction d'un système de créateurs", pt: "Construção de um sistema próprio de criadores",
|
||||
},
|
||||
zr_ev11_p: {
|
||||
de: "DogFather entwickelt gemeinsam mit seinen Scouts eine langfristige Struktur für Creator, Scouts und zukünftige Manager. Das Ziel: Menschen nicht nur zu finden, sondern sie dauerhaft zu fördern und gemeinsam mit ihnen zu wachsen.",
|
||||
"de-CH": "DogFather entwicklet zäme mit sine Scouts e langfristigi Struktur für Creator, Scouts und zuekünftigi Manager. Ds Ziel: Mensche nid nur z'finde, sondern sie duurhaft z'fördere und zäme mit ihne z'wachse.",
|
||||
en: "Together with his scouts, DogFather develops a long-term structure for creators, scouts, and future managers. The goal: not just to find people, but to support them long-term and grow together with them.",
|
||||
fr: "Avec ses scouts, DogFather développe une structure durable pour les créateurs, les scouts et les futurs managers. L'objectif : ne pas seulement trouver des talents, mais les accompagner durablement et grandir avec eux.",
|
||||
pt: "Junto com seus scouts, DogFather desenvolve uma estrutura de longo prazo para criadores, scouts e futuros gerentes. O objetivo: não apenas encontrar pessoas, mas apoiá-las de forma duradoura e crescer junto com elas.",
|
||||
},
|
||||
|
||||
/* ---------- Station 12 — Ende Juli 2026 (Meilenstein) ---------- */
|
||||
zr_ev12_date: {
|
||||
de: "Ende Juli 2026", "de-CH": "Ändi Juli 2026",
|
||||
en: "Late July 2026", fr: "Fin juillet 2026", pt: "Final de julho de 2026",
|
||||
},
|
||||
zr_ev12_h3: {
|
||||
de: "Das DOGFATHER UNIVERSE nimmt Form an", "de-CH": "Ds DOGFATHER UNIVERSE nimmt Form a",
|
||||
en: "The DOGFATHER UNIVERSE Takes Shape", fr: "L'univers DOGFATHER UNIVERSE prend forme", pt: "O DOGFATHER UNIVERSE toma forma",
|
||||
},
|
||||
zr_ev12_p: {
|
||||
de: "Die Planung einer eigenen Website beginnt. Sie soll die drei großen Welten von DogFather erstmals miteinander verbinden: den Streamer DogFather, den Manager bei Spicy Media und die Marke HasiDog.",
|
||||
"de-CH": "D Planig vonere eigete Website startet. Sie soll die drei grosse Wälte vo DogFather zum erste Mal mitenand verbinde: de Streamer DogFather, de Manager bi Spicy Media und d Marke HasiDog.",
|
||||
en: "Planning begins for a website of its own. It's meant to connect DogFather's three big worlds for the first time: the streamer DogFather, the manager at Spicy Media, and the HasiDog brand.",
|
||||
fr: "La planification d'un site web propre commence. Il doit réunir pour la première fois les trois grands univers de DogFather : le streamer DogFather, le manager chez Spicy Media et la marque HasiDog.",
|
||||
pt: "Começa o planejamento de um site próprio. Ele deve unir pela primeira vez os três grandes universos de DogFather: o streamer DogFather, o gerente na Spicy Media e a marca HasiDog.",
|
||||
},
|
||||
|
||||
/* ---------- Station 13 — Datum folgt ---------- */
|
||||
zr_ev13_h3: {
|
||||
de: `Das Buch „HasiDog & Casper"`, "de-CH": `Ds Buech „HasiDog & Casper"`,
|
||||
en: `The Book "HasiDog & Casper"`, fr: `Le livre « HasiDog & Casper »`, pt: `O livro "HasiDog & Casper"`,
|
||||
},
|
||||
zr_ev13_p: {
|
||||
de: "Die besondere Geschichte von DogFather, HasiDog und seinem Husky Casper wird zu einem eigenen Buchprojekt. Eine Geschichte über Freundschaft, Familie, Zusammenhalt und gemeinsame Abenteuer.",
|
||||
"de-CH": "D bsunderi Gschicht vo DogFather, HasiDog und sim Husky Casper wird zu emene eigete Buechprojekt. E Gschicht über Fründschaft, Familie, Zämehalt und gmeinsami Abentüür.",
|
||||
en: "The special story of DogFather, HasiDog, and his husky Casper becomes its own book project. A story about friendship, family, togetherness, and shared adventures.",
|
||||
fr: "L'histoire particulière de DogFather, HasiDog et de son husky Casper devient un véritable projet de livre. Une histoire d'amitié, de famille, de cohésion et d'aventures partagées.",
|
||||
pt: "A história especial de DogFather, HasiDog e seu husky Casper se transforma em um projeto de livro próprio. Uma história sobre amizade, família, união e aventuras compartilhadas.",
|
||||
},
|
||||
|
||||
/* ---------- Station 14 — Datum folgt ---------- */
|
||||
zr_ev14_h3: {
|
||||
de: "HasiDog startet auf Spotify", "de-CH": "HasiDog startet uf Spotify",
|
||||
en: "HasiDog Launches on Spotify", fr: "HasiDog arrive sur Spotify", pt: "HasiDog estreia no Spotify",
|
||||
},
|
||||
zr_ev14_p: {
|
||||
de: "HasiDog erhält sein eigenes Musikprojekt. Songs und weitere Informationen folgen.",
|
||||
"de-CH": "HasiDog überchunnt si eiges Musikprojekt. Songs und wiiteri Informatione chömed no.",
|
||||
en: "HasiDog gets his own music project. Songs and more information to follow.",
|
||||
fr: "HasiDog obtient son propre projet musical. Chansons et informations complémentaires à venir.",
|
||||
pt: "HasiDog ganha seu próprio projeto musical. Músicas e mais informações em breve.",
|
||||
},
|
||||
|
||||
/* ---------- Station 15 — Datum folgt ---------- */
|
||||
zr_ev15_h3: {
|
||||
de: "Kooperation mit VanVan Teddys", "de-CH": "Kooperation mit VanVan Teddys",
|
||||
en: "Collaboration with VanVan Teddys", fr: "Collaboration avec VanVan Teddys", pt: "Colaboração com VanVan Teddys",
|
||||
},
|
||||
zr_ev15_p: {
|
||||
de: "Eine eigene Merchandise-Kooperation rund um VanVan startet. Geplant sind besondere Produkte, die ihre Persönlichkeit und ihre wichtige Rolle innerhalb von Team Dogi widerspiegeln.",
|
||||
"de-CH": "E eigeni Merchandise-Kooperation rund um VanVan startet. Plant sind bsunderi Produkt, wo ihri Persönlichkeit und ihri wichtigi Roll innerhalb vo Team Dogi widerspiegled.",
|
||||
en: "A dedicated merchandise collaboration around VanVan launches. Special products are planned that reflect her personality and her important role within Team Dogi.",
|
||||
fr: "Une collaboration de produits dérivés dédiée à VanVan démarre. Des produits spéciaux sont prévus, reflétant sa personnalité et son rôle important au sein de Team Dogi.",
|
||||
pt: "Começa uma colaboração própria de produtos em torno da VanVan. Estão previstos produtos especiais que refletem sua personalidade e seu papel importante dentro da Team Dogi.",
|
||||
},
|
||||
|
||||
/* ---------- Station 16 — Datum folgt ---------- */
|
||||
zr_ev16_h3: {
|
||||
de: "Die ersten betreuten Creator", "de-CH": "Die erste betreute Creator",
|
||||
en: "The First Supported Creators", fr: "Les premiers créateurs accompagnés", pt: "Os primeiros criadores acompanhados",
|
||||
},
|
||||
zr_ev16_p: {
|
||||
de: "Die ersten Creator schließen sich dem Team von Manager DogFather an. Sie erhalten persönliche Unterstützung, Hilfe bei Problemen, Profil-Checks, Schulungen und eine langfristige Begleitung auf TikTok.",
|
||||
"de-CH": "Die erste Creator schliessed sich em Team vom Manager DogFather a. Sie überchömed persönlichi Unterstützig, Hilf bi Problem, Profil-Checks, Schulige und e langfristigi Begleitig uf TikTok.",
|
||||
en: "The first creators join Manager DogFather's team. They receive personal support, help with problems, profile checks, training, and long-term guidance on TikTok.",
|
||||
fr: "Les premiers créateurs rejoignent l'équipe du manager DogFather. Ils bénéficient d'un accompagnement personnalisé, d'aide en cas de problème, de vérifications de profil, de formations et d'un suivi durable sur TikTok.",
|
||||
pt: "Os primeiros criadores se juntam à equipe do gerente DogFather. Eles recebem apoio pessoal, ajuda com problemas, análises de perfil, treinamentos e acompanhamento de longo prazo no TikTok.",
|
||||
},
|
||||
|
||||
/* ---------- Station 17 — Datum folgt ---------- */
|
||||
zr_ev17_h3: {
|
||||
de: "Weitere Scouts und Manager kommen hinzu", "de-CH": "Wiiteri Scouts und Manager chömed dezue",
|
||||
en: "More Scouts and Managers Join", fr: "Plus de scouts et de managers rejoignent l'équipe", pt: "Mais scouts e gerentes se juntam",
|
||||
},
|
||||
zr_ev17_p: {
|
||||
de: "Das Team soll langfristig weiter wachsen. Engagierte Scouts erhalten die Möglichkeit, sich über verschiedene Entwicklungsstufen bis hin zum eigenen Manager weiterzuentwickeln.",
|
||||
"de-CH": "Ds Team söll langfristig wiiter wachse. Engagierti Scouts überchömed d Möglichkeit, sich über verschideni Entwickligsstufe bis zum eigete Manager wiiterzentwickle.",
|
||||
en: "The team is set to keep growing long-term. Dedicated scouts get the chance to develop through various stages all the way up to becoming a manager themselves.",
|
||||
fr: "L'équipe est destinée à continuer de grandir sur le long terme. Les scouts engagés ont la possibilité d'évoluer à travers différents niveaux, jusqu'à devenir eux-mêmes managers.",
|
||||
pt: "A equipe deve continuar crescendo a longo prazo. Scouts dedicados ganham a chance de evoluir por diferentes níveis até se tornarem gerentes.",
|
||||
},
|
||||
|
||||
/* ---------- Station 18 — Datum folgt ---------- */
|
||||
zr_ev18_h3: {
|
||||
de: "Das erste Community-Treffen", "de-CH": "Ds erste Community-Träffe",
|
||||
en: "The First Community Meetup", fr: "La première rencontre communautaire", pt: "O primeiro encontro da comunidade",
|
||||
},
|
||||
zr_ev18_p: {
|
||||
de: "Team Dogi begegnet sich nicht mehr nur im Livestream oder auf Discord. Beim gemeinsamen Treffen entstehen echte Erinnerungen, die zeigen, dass diese Community weit mehr als nur Zahlen und Likes ist.",
|
||||
"de-CH": "Team Dogi begägnet sich nid nur im Livestream oder uf Discord. Bim gmeinsame Träffe entstönd echti Erinnerige, wo zeiged, dass die Community wit meh isch als nume Zahle und Likes.",
|
||||
en: "Team Dogi no longer just meets in livestreams or on Discord. At the shared meetup, real memories are made — proof that this community is far more than just numbers and likes.",
|
||||
fr: "Team Dogi ne se retrouve plus seulement en live ou sur Discord. Lors de la rencontre commune naissent de vrais souvenirs, preuve que cette communauté est bien plus que des chiffres et des likes.",
|
||||
pt: "A Team Dogi não se encontra mais só em lives ou no Discord. No encontro presencial nascem memórias reais, mostrando que essa comunidade é muito mais do que números e curtidas.",
|
||||
},
|
||||
|
||||
/* ---------- Station 19 — 2026 – Datum folgt (Meilenstein) ---------- */
|
||||
zr_ev19_date: {
|
||||
de: "2026 – Datum folgt", "de-CH": "2026 – Datum chunnt no",
|
||||
en: "2026 – date coming soon", fr: "2026 – date à venir", pt: "2026 – data em breve",
|
||||
},
|
||||
zr_ev19_h3: {
|
||||
de: "DOGFATHER UNIVERSE geht online", "de-CH": "DOGFATHER UNIVERSE gaht online",
|
||||
en: "DOGFATHER UNIVERSE Goes Online", fr: "DOGFATHER UNIVERSE est en ligne", pt: "DOGFATHER UNIVERSE entra no ar",
|
||||
},
|
||||
zr_ev19_p: {
|
||||
de: "Die offizielle Website öffnet ihre Türen. Sie vereint DogFather als Streamer, seine Arbeit als Manager bei Spicy Media, HasiDog, Casper, Team Dogi, die Creator, Scouts, Moderatoren und alle wichtigen Projekte erstmals an einem gemeinsamen Ort.",
|
||||
"de-CH": "D offizielli Website öffnet ihri Türe. Sie vereint DogFather als Streamer, sini Arbeit als Manager bi Spicy Media, HasiDog, Casper, Team Dogi, d Creator, d Scouts, d Moderatore und alli wichtige Projäkt zum erste Mal a eim gmeinsame Ort.",
|
||||
en: "The official website opens its doors. It brings together DogFather as a streamer, his work as a manager at Spicy Media, HasiDog, Casper, Team Dogi, the creators, scouts, moderators, and all major projects in one shared place for the first time.",
|
||||
fr: "Le site officiel ouvre ses portes. Il réunit pour la première fois DogFather en tant que streamer, son travail de manager chez Spicy Media, HasiDog, Casper, Team Dogi, les créateurs, les scouts, les modérateurs et tous les projets importants en un seul endroit.",
|
||||
pt: "O site oficial abre suas portas. Ele reúne pela primeira vez, em um só lugar, DogFather como streamer, seu trabalho como gerente na Spicy Media, HasiDog, Casper, a Team Dogi, os criadores, scouts, moderadores e todos os projetos importantes.",
|
||||
},
|
||||
|
||||
/* ---------- Station 20 — Fortsetzung folgt (Finale) ---------- */
|
||||
zr_ev20_date: {
|
||||
de: "Fortsetzung folgt", "de-CH": "Fortsetzig chunnt no",
|
||||
en: "To be continued", fr: "À suivre", pt: "Continua",
|
||||
},
|
||||
zr_ev20_h3: {
|
||||
de: "Die Geschichte ist noch lange nicht vorbei", "de-CH": "D Gschicht isch no lang nid verbi",
|
||||
en: "The Story Is Far From Over", fr: "L'histoire est loin d'être terminée", pt: "A história está longe de acabar",
|
||||
},
|
||||
zr_ev20_p: {
|
||||
de: "Das DOGFATHER UNIVERSE wächst weiter. Neue Creator, Kooperationen, Aktionen, Musikprojekte, Geschichten und große Community-Momente werden Teil dieser Reise.",
|
||||
"de-CH": "Ds DOGFATHER UNIVERSE wachst wiiter. Neui Creator, Kooperatione, Aktione, Musikprojäkt, Gschichte und grossi Community-Momänt wärded Teil vo dere Reise.",
|
||||
en: "The DOGFATHER UNIVERSE keeps growing. New creators, collaborations, events, music projects, stories, and big community moments become part of this journey.",
|
||||
fr: "L'univers DOGFATHER UNIVERSE continue de grandir. Nouveaux créateurs, collaborations, actions, projets musicaux, histoires et grands moments communautaires font désormais partie de cette aventure.",
|
||||
pt: "O DOGFATHER UNIVERSE continua crescendo. Novos criadores, parcerias, ações, projetos musicais, histórias e grandes momentos da comunidade fazem parte dessa jornada.",
|
||||
},
|
||||
zr_ev20_quote: {
|
||||
de: "Denn aus einem einzelnen TikTok-Livestream wurde eine Community – und aus dieser Community wurde eine Familie.",
|
||||
"de-CH": "Dänn us eim einzelne TikTok-Livestream isch e Community worde — und us dere Community isch e Familie worde.",
|
||||
en: "Because a single TikTok livestream became a community — and this community became a family.",
|
||||
fr: "Car un simple live TikTok est devenu une communauté — et cette communauté est devenue une famille.",
|
||||
pt: "Porque uma simples live no TikTok virou uma comunidade — e essa comunidade virou uma família.",
|
||||
},
|
||||
zr_ev20_familie: {
|
||||
de: "Team Dogi ist Familie. 🩵", "de-CH": "Team Dogi isch Familie. 🩵",
|
||||
en: "Team Dogi is family. 🩵", fr: "Team Dogi, c'est la famille. 🩵", pt: "Team Dogi é família. 🩵",
|
||||
},
|
||||
zr_ev20_signoff: {
|
||||
de: "Liebe Grüße<br>-euer DogFather", "de-CH": "Liebi Grüess<br>-öie DogFather",
|
||||
en: "With love,<br>-your DogFather", fr: "Avec toute mon affection,<br>-votre DogFather", pt: "Com carinho,<br>-seu DogFather",
|
||||
},
|
||||
|
||||
/* ---------- Baum-Deko ---------- */
|
||||
zr_baum_start: {
|
||||
de: "Der Anfang", "de-CH": "De Aafang",
|
||||
en: "The Beginning", fr: "Le début", pt: "O começo",
|
||||
},
|
||||
zr_baum_wächst: {
|
||||
de: "…und die Geschichte wächst weiter", "de-CH": "…und d Gschicht wachst wiiter",
|
||||
en: "…and the story keeps growing", fr: "…et l'histoire continue de grandir", pt: "…e a história continua a crescer",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
nav_dogfather_casper: { de: "Casper", "de-CH": "Casper", en: "Casper", fr: "Casper", pt: "Casper" },
|
||||
nav_dogfather_vanvan: { de: "VanVan", "de-CH": "VanVan", en: "VanVan", fr: "VanVan", pt: "VanVan" },
|
||||
nav_dogfather_zeitreise: { de: "Zeitreise", "de-CH": "Zytreis", en: "Timeline", fr: "Chronologie", pt: "Linha do tempo" },
|
||||
nav_dogfather_ttmuddi: { de: "TT-Muddi", "de-CH": "TT-Muddi", en: "TT-Muddi", fr: "TT-Muddi", pt: "TT-Muddi" },
|
||||
nav_hasidog: { de: "HasiDog", "de-CH": "HasiDog", en: "HasiDog", fr: "HasiDog", pt: "HasiDog" },
|
||||
nav_hasidog_welt: { de: "HasiDog-Welt", "de-CH": "HasiDog-Wält", en: "HasiDog World", fr: "Monde HasiDog", pt: "Mundo HasiDog" },
|
||||
nav_hasidog_modi: { de: "Modi-Team", "de-CH": "Modi-Team", en: "Modi-Team", fr: "Modi-Team", pt: "Modi-Team" },
|
||||
@@ -100,6 +101,9 @@
|
||||
nav_medien_casper: { de: "Casper", "de-CH": "Casper", en: "Casper", fr: "Casper", pt: "Casper" },
|
||||
nav_links: { de: "Links", "de-CH": "Links", en: "Links", fr: "Liens", pt: "Links" },
|
||||
nav_cta_bewerben: { de: "Bewerben 🩵", "de-CH": "Bewärbe 🩵", en: "Apply 🩵", fr: "Postuler 🩵", pt: "Candidatar-se 🩵" },
|
||||
nav_cta_abo_anonym: { de: "DogiCrew 👑", "de-CH": "DogiCrew 👑", en: "DogiCrew 👑", fr: "DogiCrew 👑", pt: "DogiCrew 👑" },
|
||||
nav_cta_abo_aktivieren: { de: "Abo aktivieren 👑", "de-CH": "Abo aktiviere 👑", en: "Activate Subscription 👑", fr: "Activer l'abonnement 👑", pt: "Ativar assinatura 👑" },
|
||||
nav_cta_abo_bereich: { de: "Supporter-Bereich 👑", "de-CH": "Supporter-Bereich 👑", en: "Supporter Area 👑", fr: "Espace Supporter 👑", pt: "Área do Apoiador 👑" },
|
||||
nav_toggle_aria: { de: "Menü öffnen", "de-CH": "Menü uftue", en: "Open menu", fr: "Ouvrir le menu", pt: "Abrir menu" },
|
||||
sprache_aria: { de: "Sprache wählen", "de-CH": "Sprach uswähle", en: "Choose language", fr: "Choisir la langue", pt: "Escolher idioma" },
|
||||
brand_tagline: {
|
||||
@@ -192,6 +196,7 @@
|
||||
{ key: "nav_dogfather_streamer", href: "streamer.html" },
|
||||
{ key: "nav_dogfather_casper", href: "casper.html" },
|
||||
{ key: "nav_dogfather_vanvan", href: "vanvan.html" },
|
||||
{ key: "nav_dogfather_ttmuddi", href: "tt-muddi.html" },
|
||||
{ key: "nav_dogfather_zeitreise", href: "zeitreise.html" },
|
||||
],
|
||||
},
|
||||
@@ -313,6 +318,173 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Vorschau-Modus für Dogi + VanVan (Zugangsschranke, siehe gate-worker.js)
|
||||
|
||||
Solange die Seite noch nicht öffentlich ist, kommen nur Dogi und VanVan
|
||||
per Zugangscode rein (gate-worker.js setzt dabei ein lesbares Cookie
|
||||
"dogi_role=dogi"/"vanvan" — NICHT das eigentliche Sicherheits-Cookie,
|
||||
das bleibt HttpOnly). Damit die beiden die Seite auch "als Abonnent"
|
||||
bzw. "als Nicht-Abonnent" ansehen können, OHNE ein echtes Abo
|
||||
abschließen zu müssen, gibt es einen kleinen Umschalter unten links,
|
||||
der NUR erscheint, wenn das Rollen-Cookie gesetzt ist. Die Wahl landet
|
||||
in localStorage und wird von renderAboButton() sowie von supporter.js/
|
||||
supporter.html (Mock-Daten statt echtem API-Aufruf) ausgelesen.
|
||||
=================================================================== */
|
||||
const VORSCHAU_SCHLUESSEL = "dogiPreviewMode"; // "abonnent" | "nicht-abonnent" | leer = echter Stand
|
||||
|
||||
function getCookieWert(name) {
|
||||
const treffer = document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
||||
return treffer ? decodeURIComponent(treffer[1]) : null;
|
||||
}
|
||||
|
||||
function getVorschauRolle() {
|
||||
return getCookieWert("dogi_role"); // "dogi" | "vanvan" | null
|
||||
}
|
||||
|
||||
/* Realistischer Anzeigename fürs Vorschau-Konto — Nutzer-Feedback
|
||||
03.08.2026: "ich will in der Vorschau alles sehen wie es fertig sein
|
||||
wird, nix da stehen haben von Vorschau, das bringt mir nix." Statt dem
|
||||
Wort "Vorschau" irgendwo im Text zeigt die Vorschau jetzt einfach den
|
||||
Namen der Person, die gerade eingeloggt ist (Dogi oder VanVan) — sieht
|
||||
dadurch exakt so aus wie ein echtes, fertiges Konto. */
|
||||
function vorschauAnzeigename() {
|
||||
const rolle = getVorschauRolle();
|
||||
return rolle === "vanvan" ? "VanVan" : "Dogi";
|
||||
}
|
||||
window.dogiVorschauName = vorschauAnzeigename; // öffentlich, für supporter.html
|
||||
|
||||
function getVorschauModus() {
|
||||
if (!getVorschauRolle()) return null; // Umschalter nur für Dogi/VanVan gültig
|
||||
const wert = localStorage.getItem(VORSCHAU_SCHLUESSEL);
|
||||
return wert === "abonnent" || wert === "nicht-abonnent" ? wert : null;
|
||||
}
|
||||
|
||||
function setVorschauModus(modus) {
|
||||
if (modus) localStorage.setItem(VORSCHAU_SCHLUESSEL, modus);
|
||||
else localStorage.removeItem(VORSCHAU_SCHLUESSEL);
|
||||
renderHeader();
|
||||
renderVorschauSchalter();
|
||||
document.dispatchEvent(new CustomEvent("dogi-vorschau-geaendert", { detail: { modus } }));
|
||||
}
|
||||
window.dogiVorschauModus = getVorschauModus; // öffentlich, für supporter.html/abonnieren.html
|
||||
|
||||
/* Aufklappbarer Kreisknopf unten links, spiegelbildlich zum Musik-Knopf
|
||||
unten rechts (gleiche Bauart, siehe .vorschau-toggle/.vorschau-panel in
|
||||
main.css) — Nutzer-Feedback 03.08.2026: statt einer dauerhaft offenen
|
||||
Text-Knopfleiste "so ein Button wie bei VanVan", der sich erst bei
|
||||
Klick öffnet. Nur EINMAL erzeugt (wie beim Musik-Widget), damit ein
|
||||
offenes Panel beim nahtlosen Seitenwechsel nicht jedes Mal neu gebaut
|
||||
wird — nur der Inhalt (aktive Auswahl) wird bei jedem Aufruf aktuell
|
||||
gehalten. */
|
||||
function renderVorschauSchalter() {
|
||||
if (!getVorschauRolle()) return; // öffentliche Besucher sehen nie etwas
|
||||
|
||||
let widget = document.getElementById("vorschau-widget");
|
||||
if (!widget) {
|
||||
widget = document.createElement("div");
|
||||
widget.className = "vorschau-widget";
|
||||
widget.id = "vorschau-widget";
|
||||
widget.innerHTML = `
|
||||
<div class="vorschau-panel" id="vorschau-panel" hidden>
|
||||
<div class="vorschau-panel-head">
|
||||
<strong>🎭 Vorschau</strong>
|
||||
<button class="vorschau-close" id="vorschau-close" type="button" aria-label="Vorschau-Feld schließen">✕</button>
|
||||
</div>
|
||||
<div class="vorschau-optionen" id="vorschau-optionen"></div>
|
||||
</div>
|
||||
<button class="vorschau-toggle" id="vorschau-toggle" type="button"
|
||||
aria-expanded="false" aria-controls="vorschau-panel"
|
||||
title="Vorschau: als Abonnent/Nicht-Abonnent ansehen"><span>🎭</span></button>`;
|
||||
document.body.appendChild(widget);
|
||||
|
||||
const panel = document.getElementById("vorschau-panel");
|
||||
const toggle = document.getElementById("vorschau-toggle");
|
||||
const oeffnen = (auf) => {
|
||||
panel.hidden = !auf;
|
||||
toggle.setAttribute("aria-expanded", String(auf));
|
||||
widget.classList.toggle("open", auf);
|
||||
};
|
||||
toggle.addEventListener("click", () => oeffnen(panel.hidden));
|
||||
document.getElementById("vorschau-close").addEventListener("click", () => oeffnen(false));
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && !panel.hidden) oeffnen(false);
|
||||
});
|
||||
document.getElementById("vorschau-optionen").addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("button[data-vorschau]");
|
||||
if (!btn) return;
|
||||
setVorschauModus(btn.dataset.vorschau === "echt" ? null : btn.dataset.vorschau);
|
||||
});
|
||||
}
|
||||
|
||||
const aktuell = getVorschauModus();
|
||||
const knopf = (wert, label) => {
|
||||
const aktiv = wert === "echt" ? !aktuell : aktuell === wert;
|
||||
return `<button type="button" data-vorschau="${wert}" class="${aktiv ? "aktiv" : ""}">${aktiv ? "✓ " : ""}${label}</button>`;
|
||||
};
|
||||
document.getElementById("vorschau-optionen").innerHTML =
|
||||
knopf("echt", "Echter Login-Stand") + knopf("abonnent", "Als Abonnent") + knopf("nicht-abonnent", "Als Nicht-Abonnent");
|
||||
}
|
||||
|
||||
/* Abo-Button in der Hauptnavigation (Dogfather_VanVan_Supporter_Abo.odt,
|
||||
Abschnitt 3) — Beschriftung/Ziel hängt vom Supporter-Login-Stand ab.
|
||||
Bewusst KEIN API-Aufruf bei jedem Seitenaufruf (würde jede einzelne
|
||||
Seite der Website an den Worker koppeln) — stattdessen wird der zuletzt
|
||||
bekannte Stand aus localStorage gelesen, den supporter.html/
|
||||
abonnieren.html bei jedem eigenen Laden aktuell hält (siehe dort).
|
||||
Vorschau-Modus (Dogi/VanVan, siehe oben) überschreibt das für den
|
||||
Nav-Knopf, unabhängig vom echten Login-Stand.
|
||||
|
||||
Nutzer-Feedback 03.08.2026: Ist man eingeloggt UND hat ein Abo, soll im
|
||||
Knopf nicht der generische Text "Supporter-Bereich" stehen, sondern der
|
||||
eigene Benutzername (aus /supporter/me → profile.displayName, wird von
|
||||
abonnieren.html/supporter.html per S.cacheDisplayName() aktuell
|
||||
gehalten). displayName ist freier Nutzertext -> escapeHtml() ist
|
||||
Pflicht (sonst XSS über den eigenen Anzeigenamen möglich), und wird auf
|
||||
eine im Knopf sinnvolle Länge gekürzt. */
|
||||
function escapeHtml(text) {
|
||||
return String(text).replace(/[&<>"']/g, (z) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[z]));
|
||||
}
|
||||
function kuerzen(text, maxLaenge) {
|
||||
return text.length > maxLaenge ? text.slice(0, maxLaenge - 1).trimEnd() + "…" : text;
|
||||
}
|
||||
function bereichLabel(name) {
|
||||
const sauber = (name || "").trim();
|
||||
if (!sauber) return t("nav_cta_abo_bereich");
|
||||
return `${escapeHtml(kuerzen(sauber, 18))} 👑`;
|
||||
}
|
||||
|
||||
function renderAboButton() {
|
||||
const vorschau = getVorschauModus();
|
||||
if (vorschau === "abonnent") {
|
||||
// Ist Dogi/VanVan zufällig wirklich mit einem echten Konto
|
||||
// eingeloggt (echter Name im Speicher), soll auch in der Vorschau
|
||||
// DER echte Name stehen. Ohne echtes Konto fällt es auf den Namen
|
||||
// der eingeloggten Rolle zurück (vorschauAnzeigename() — "Dogi" oder
|
||||
// "VanVan"), NICHT auf einen erkennbaren "Vorschau"-Platzhalter,
|
||||
// damit die Vorschau exakt so aussieht wie das fertige Ergebnis
|
||||
// (Nutzer-Feedback 03.08.2026: "nix da stehen haben von Vorschau,
|
||||
// das bringt mir nix").
|
||||
const echterName = localStorage.getItem("supporterDisplayName");
|
||||
return `<a class="nav-cta nav-cta-abo" href="supporter.html"><span>${bereichLabel(echterName || vorschauAnzeigename())}</span></a>`;
|
||||
}
|
||||
if (vorschau === "nicht-abonnent") {
|
||||
return `<a class="nav-cta nav-cta-abo" href="abonnieren.html"><span>${t("nav_cta_abo_anonym")}</span></a>`;
|
||||
}
|
||||
|
||||
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></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></a>`;
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
const el = document.getElementById("site-header");
|
||||
if (!el) return;
|
||||
@@ -329,6 +501,7 @@
|
||||
<nav class="nav-links">
|
||||
${NAV.map(renderNavItem).join("")}
|
||||
${renderSprachSchalter()}
|
||||
${renderAboButton()}
|
||||
<a class="nav-cta" href="bewerben.html"><span>${t("nav_cta_bewerben")}</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -389,6 +562,7 @@
|
||||
<li><a href="streamer.html">${t("nav_dogfather_streamer")}</a></li>
|
||||
<li><a href="casper.html">${t("nav_dogfather_casper")}</a></li>
|
||||
<li><a href="vanvan.html">${t("nav_dogfather_vanvan")}</a></li>
|
||||
<li><a href="tt-muddi.html">${t("nav_dogfather_ttmuddi")}</a></li>
|
||||
<li><a href="zeitreise.html">${t("nav_dogfather_zeitreise")}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -415,7 +589,7 @@
|
||||
</div>
|
||||
<div class="container footer-bottom">
|
||||
<span>${t("footer_copyright").replace("{jahr}", year)}</span>
|
||||
<span><a href="kontakt.html">${t("footer_impressum")}</a></span>
|
||||
<span><a href="kontakt.html">${t("footer_impressum")}</a> · <a href="agb.html">AGB</a></span>
|
||||
</div>
|
||||
</footer>`;
|
||||
}
|
||||
@@ -750,6 +924,28 @@
|
||||
}
|
||||
|
||||
alterInhalt.replaceWith(neuerInhalt);
|
||||
|
||||
// Seiteneigene <style>-Blöcke im <head> mitnehmen (Nutzer-Feedback
|
||||
// 03.08.2026: "wenn ich den Abo-Knopf drücke, sieht die Seite zuerst
|
||||
// kaputt aus, erst nach einem manuellen Neuladen stimmt es"). Ursache:
|
||||
// viele Seiten (abonnieren.html, supporter.html, rabattcodes.html,
|
||||
// tt-muddi.html, ...) haben ihr eigenes Feintuning als <style> direkt
|
||||
// im <head> stehen. Beim nahtlosen Wechsel wurde bisher NUR <main>
|
||||
// ausgetauscht — der <head> blieb unverändert, das eigene <style> der
|
||||
// neuen Seite kam nie mit. Ein echter Neuladen (F5) parst das
|
||||
// Dokument komplett neu und hatte das Problem deshalb nie gezeigt.
|
||||
// Alle mit main.js/main.css geteilten Styles kommen ausschließlich
|
||||
// über <link rel="stylesheet">, nie über ein <style> im <head> — jedes
|
||||
// <style>-Element dort ist also garantiert seitenEIGEN und darf beim
|
||||
// Wechsel 1:1 ersetzt werden.
|
||||
document.head.querySelectorAll("style[data-seiteneigen]").forEach((el) => el.remove());
|
||||
dok.head.querySelectorAll("style").forEach((el) => {
|
||||
const neu = document.createElement("style");
|
||||
neu.setAttribute("data-seiteneigen", "");
|
||||
neu.textContent = el.textContent;
|
||||
document.head.appendChild(neu);
|
||||
});
|
||||
|
||||
// Seiteneigenes i18n-Wörterbuch zurücksetzen — sonst könnte das der
|
||||
// VORHERIGEN Seite (z.B. Startseite) fälschlich auf der neuen Seite
|
||||
// weiterwirken. Hat die neue Seite ein eigenes (z.B. i18n-index.js),
|
||||
@@ -832,10 +1028,18 @@
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Das <style>, das die ERSTE Seite selbst mitbringt (roh aus dem HTML
|
||||
// geparst, noch ohne unsere Markierung), muss dieselbe Markierung wie
|
||||
// die später per SPA-Wechsel nachgeladenen bekommen — sonst würde
|
||||
// seiteLaden() es nie wieder entfernen können und es bliebe für immer
|
||||
// im <head> hängen, sobald man einmal weiterklickt (siehe Kommentar
|
||||
// dort für den vollen Zusammenhang).
|
||||
document.head.querySelectorAll("style:not([data-seiteneigen])").forEach((el) => el.setAttribute("data-seiteneigen", ""));
|
||||
renderHeader();
|
||||
renderFooter();
|
||||
renderFog();
|
||||
renderMusicWidget();
|
||||
renderVorschauSchalter();
|
||||
musikKnopfFarbenAnpassen();
|
||||
nahtloseNavigationAktivieren();
|
||||
applyTranslations(); // seiteneigene data-i18n-Elemente (z.B. Startseite) übersetzen
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/* =====================================================================
|
||||
supporter.js — gemeinsame Helfer für abonnieren.html + supporter.html
|
||||
(Dogfather Supporter-Abo). Eigenes, von main.js/forms.js
|
||||
komplett getrenntes Token-System (localStorage-Key "supporterToken"),
|
||||
siehe cloudflare-worker/src/lib/supporter-auth.js.
|
||||
===================================================================== */
|
||||
(function () {
|
||||
const API_BASE = "https://postfach.dogfather-universe.com";
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem("supporterToken") || "";
|
||||
}
|
||||
function setToken(token) {
|
||||
localStorage.setItem("supporterToken", token);
|
||||
}
|
||||
function clearToken() {
|
||||
localStorage.removeItem("supporterToken");
|
||||
localStorage.removeItem("supporterSubStatus");
|
||||
localStorage.removeItem("supporterDisplayName");
|
||||
}
|
||||
/* Wird vom Nav-Abo-Button in main.js gelesen (renderAboButton), damit die
|
||||
Beschriftung/das Ziel sofort beim nächsten Seitenwechsel stimmt, ohne
|
||||
dass main.js selbst einen API-Aufruf machen muss. */
|
||||
function cacheSubStatus(status) {
|
||||
if (status) localStorage.setItem("supporterSubStatus", status);
|
||||
else localStorage.removeItem("supporterSubStatus");
|
||||
}
|
||||
/* Name der eingeloggten Person (aus /supporter/me → profile.displayName) —
|
||||
damit der Nav-Knopf bei aktivem Abo statt des generischen "Supporter-
|
||||
Bereich" gleich den Benutzernamen zeigen kann (Nutzer-Feedback
|
||||
03.08.2026). Wird von abonnieren.html/supporter.html nach jedem
|
||||
erfolgreichen /supporter/me-Aufruf gesetzt. */
|
||||
function cacheDisplayName(name) {
|
||||
if (name) localStorage.setItem("supporterDisplayName", name);
|
||||
else localStorage.removeItem("supporterDisplayName");
|
||||
}
|
||||
|
||||
async function apiFetch(path, body, authed) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (authed) headers.Authorization = `Bearer ${getToken()}`;
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}${path}`, { method: "POST", headers, body: JSON.stringify(body || {}) });
|
||||
} catch {
|
||||
throw new Error("Verbindung zum Server fehlgeschlagen. Bitte prüfe deine Internetverbindung.");
|
||||
}
|
||||
let data = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
/* leere Antwort ist ok, z.B. bei manchen Fehlern */
|
||||
}
|
||||
if (!res.ok || !data || data.ok === false) {
|
||||
const err = new Error((data && data.error) || `Anfrage fehlgeschlagen (${res.status}).`);
|
||||
err.code = data && data.code;
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
window.DogiSupporter = { API_BASE, getToken, setToken, clearToken, cacheSubStatus, cacheDisplayName, apiFetch };
|
||||
})();
|
||||
@@ -12,12 +12,12 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Streamer gesucht — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Du bist Streamer und suchst einen Manager? Bewirb dich als Creator bei Spicy Media." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/bewerben-creator.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/bewerben-creator.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Streamer gesucht — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="Du bist Streamer und suchst einen Manager? Bewirb dich als Creator bei Spicy Media." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Kooperation anfragen — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Kooperationsanfrage an Manager DogFather / Spicy Media." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/bewerben-kooperation.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/bewerben-kooperation.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Kooperation anfragen — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="Kooperationsanfrage an Manager DogFather / Spicy Media." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Modi werden — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Werde Modi bei Team Dogi — DogFathers persönliches Community-Team." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/bewerben-modi.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/bewerben-modi.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Modi werden — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="Werde Modi bei Team Dogi — DogFathers persönliches Community-Team." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Scout werden — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Werde Scout für DogFather bei der Spicy Media Agentur — finde neue Talente." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/bewerben-scout.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/bewerben-scout.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Scout werden — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="Werde Scout für DogFather bei der Spicy Media Agentur — finde neue Talente." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Bewerben — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Werde Teil vom Team: Modi bei Team Dogi, Scout oder Creator bei Spicy Media, oder Kooperation anfragen." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/bewerben.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/bewerben.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Bewerben — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="Werde Teil vom Team: Modi bei Team Dogi, Scout oder Creator bei Spicy Media, oder Kooperation anfragen." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
@@ -47,12 +47,12 @@
|
||||
<div class="bewerben-split">
|
||||
<div class="bewerben-side bewerben-side-blau">
|
||||
<span class="bewerben-side-label" data-i18n="bw_label_community">🩵 Community — Team Dogi</span>
|
||||
<a class="card" href="bewerben-modi.html" style="--accent:#8fd9ea;--accent-2:#8b5cf6;">
|
||||
<a class="card card-duel" href="bewerben-modi.html" style="--accent:#8fd9ea;--accent-2:#8b5cf6;">
|
||||
<span class="tag" data-i18n="bw_tag_community">Community</span>
|
||||
<h3 data-i18n="bw_modi_h3">Modi werden</h3>
|
||||
<p data-i18n="bw_modi_p">DogFathers persönliches Team. Chat, Community und Zusammenhalt im Alltag.</p>
|
||||
</a>
|
||||
<a class="card" href="bewerben-kooperation.html" style="--accent:#8fd9ea;--accent-2:#8b5cf6;">
|
||||
<a class="card card-duel" href="bewerben-kooperation.html" style="--accent:#8fd9ea;--accent-2:#8b5cf6;">
|
||||
<span class="tag" data-i18n="bw_tag_anfrage">Anfrage</span>
|
||||
<h3 data-i18n="bw_koop_h3">Kooperation anfragen</h3>
|
||||
<p data-i18n="bw_koop_p">Firma, Marke oder eigenes Projekt — schick deine Idee direkt an Team Dogi.</p>
|
||||
@@ -63,12 +63,12 @@
|
||||
|
||||
<div class="bewerben-side bewerben-side-rot">
|
||||
<span class="bewerben-side-label" data-i18n="bw_label_agentur">Agentur — Spicy Media 🌶️</span>
|
||||
<a class="card" href="bewerben-scout.html">
|
||||
<a class="card card-duel" href="bewerben-scout.html">
|
||||
<span class="tag" data-i18n="bw_tag_agentur">Agentur</span>
|
||||
<h3 data-i18n="bw_scout_h3">Scout werden</h3>
|
||||
<p data-i18n="bw_scout_p">Finde und begleite neue Talente für DogFather als Manager.</p>
|
||||
</a>
|
||||
<a class="card" href="bewerben-creator.html">
|
||||
<a class="card card-duel" href="bewerben-creator.html">
|
||||
<span class="tag" data-i18n="bw_tag_manager">Manager-Bewerbung</span>
|
||||
<h3 data-i18n="bw_creator_h3">Als Streamer bewerben</h3>
|
||||
<p data-i18n="bw_creator_p">Du suchst einen Manager? Bewirb dich als Creator bei Spicy Media.</p>
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Casper — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Der Husky an DogFathers Seite — das tierische Gesicht der HasiDog-Welt." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/casper.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/casper.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Casper — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="Der Husky an DogFathers Seite — das tierische Gesicht der HasiDog-Welt." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
|
||||
@@ -217,6 +217,171 @@ Auf `zugaenge.html` (verlinkt von `postfach.html`, wenn du als Owner eingeloggt
|
||||
- CORS ist aktuell offen (`*`) — sobald die finale Domain feststeht, in `src/lib/http.js` →
|
||||
`corsHeaders()` auf die echte Domain einschränken.
|
||||
|
||||
## Supporter-Abo (Dogfather) — Setup
|
||||
|
||||
Setzt `Dogfather_VanVan_Supporter_Abo.odt` um: eigener, öffentlicher
|
||||
Supporter-Bereich mit PayPal-Abo (4,99 €/Monat), 30-Monats-Prämienzyklus
|
||||
(Hasen-Teddys → Tasse & Autogrammkarte → Hoodie) und wechselnden
|
||||
Kollektionen. **Wichtig: Das Abo selbst gehört nur Dogfather** — die
|
||||
Teddy-Kollektion/Sammlerstücke entstehen in Kooperation mit VanVan, aber
|
||||
das Abonnement ist NICHT gemeinsames Branding (klargestellt 03.08.2026).
|
||||
Code ist **vollständig fertig implementiert**, kann aber
|
||||
erst wirklich Geld verarbeiten bzw. E-Mails verschicken, sobald folgende
|
||||
zwei externe Dienste eingerichtet sind — beides Dinge, die **nur der
|
||||
Website-Betreiber selbst** einrichten kann (eigene Identität/Bankkonto):
|
||||
|
||||
```
|
||||
src/lib/supporter-cycle.js Reine Rechenlogik: 30-Monats-Zyklus/Meilensteine
|
||||
src/lib/supporter-auth.js Registrierung/Login (E-Mail-Code, eigene Sessions)
|
||||
src/lib/supporter-mail.js Transaktions-E-Mails (Resend-Adapter)
|
||||
src/lib/paypal.js PayPal Subscriptions API + Webhook-Signaturprüfung
|
||||
src/routes/supporter.js Öffentlicher Supporter-Bereich (Profil/Prämien/Archiv)
|
||||
src/routes/supporter-paypal.js Abo starten/kündigen + Webhook-Verarbeitung
|
||||
src/routes/supporter-admin.js Interner Administrationsbereich (Abonnentenübersicht etc.)
|
||||
migrations/0003_supporter_abo.sql D1-Schema (komplett getrennt vom Team-Zugangssystem)
|
||||
```
|
||||
|
||||
### 1) D1-Migration ausführen (einmalig)
|
||||
|
||||
```bash
|
||||
npx wrangler d1 execute dogfather-universe-db --remote --file=migrations/0003_supporter_abo.sql
|
||||
```
|
||||
|
||||
### 2) PayPal-Business-Konto + Abo-Plan einrichten
|
||||
|
||||
1. PayPal-Business-Konto anlegen (falls noch nicht vorhanden): https://www.paypal.com/business
|
||||
2. Developer-App anlegen: https://developer.paypal.com/dashboard/applications → **Create App**
|
||||
(zuerst im **Sandbox**-Modus testen, siehe unten).
|
||||
3. Ein Produkt anlegen (Katalog-Produkt, Typ "Service") mit dem Namen
|
||||
**„Dogfather Supporter-Abo"**.
|
||||
4. Einen Abo-Plan zu diesem Produkt anlegen: 4,99 € / Monat, EUR, monatliche
|
||||
automatische Verlängerung, **keine** Testphase, **keine** Einrichtungsgebühr.
|
||||
5. Einen Webhook anlegen, Ziel-URL:
|
||||
`https://dogfather-universe-postfach.dogfather1608.workers.dev/supporter/paypal-webhook`
|
||||
Mindestens folgende Ereignisse abonnieren: `BILLING.SUBSCRIPTION.ACTIVATED`,
|
||||
`BILLING.SUBSCRIPTION.UPDATED`, `BILLING.SUBSCRIPTION.CANCELLED`,
|
||||
`BILLING.SUBSCRIPTION.SUSPENDED`, `BILLING.SUBSCRIPTION.EXPIRED`,
|
||||
`PAYMENT.SALE.COMPLETED`, `PAYMENT.SALE.DENIED`, `PAYMENT.SALE.REFUNDED`,
|
||||
`PAYMENT.SALE.REVERSED`.
|
||||
|
||||
Aus diesen Schritten bekommst du 4 Werte, die als Secrets gesetzt werden
|
||||
(NIE im Code, NIE im Repo):
|
||||
|
||||
```bash
|
||||
npx wrangler secret put PAYPAL_CLIENT_ID
|
||||
npx wrangler secret put PAYPAL_CLIENT_SECRET
|
||||
npx wrangler secret put PAYPAL_PLAN_ID
|
||||
npx wrangler secret put PAYPAL_WEBHOOK_ID
|
||||
```
|
||||
|
||||
Und in `wrangler.toml` unter `[vars]` (unkritisch, kein Geheimnis):
|
||||
|
||||
```toml
|
||||
PAYPAL_ENV = "sandbox" # später auf "live" umstellen, siehe Abschnitt 7 des Auftrags
|
||||
```
|
||||
|
||||
**Erst vollständig in der Sandbox testen** (eigenes Sandbox-Business- und
|
||||
Sandbox-Privatkonto unter https://developer.paypal.com/dashboard/accounts),
|
||||
danach `PAYPAL_ENV` auf `"live"` umstellen und Schritte 2-5 mit den echten
|
||||
Live-Zugangsdaten wiederholen. Solange die Secrets fehlen, antworten alle
|
||||
PayPal-Endpunkte kontrolliert mit `PAYPAL_NOT_CONFIGURED` statt abzustürzen.
|
||||
|
||||
**Zahlarten-Abdeckung (03.08.2026 ergänzt):** Das Frontend nutzt jetzt die
|
||||
offiziellen PayPal Smart Payment Buttons (`abonnieren.html`) statt einer
|
||||
reinen Weiterleitung — dadurch zeigt PayPal automatisch ALLE für die
|
||||
jeweilige Käuferin/den jeweiligen Käufer verfügbaren, in Deutschland
|
||||
gängigen Zahlarten an (PayPal-Guthaben/-Bankkonto, Kreditkarte auch als
|
||||
Gast ohne eigenes PayPal-Konto). Das ist bewusst PayPal selbst überlassen
|
||||
(kein eigenes Karten-/SEPA-/Klarna-Backend nötig) — das Geld landet dabei
|
||||
immer direkt auf **deinem eigenen** PayPal-Business-Konto (dieselben
|
||||
`PAYPAL_CLIENT_ID`/`PAYPAL_PLAN_ID` wie oben). Es ist keine zusätzliche
|
||||
Einstellung nötig — welche Zahlarten genau angezeigt werden, entscheidet
|
||||
PayPal automatisch je nach Land/Konto deiner Supporter.
|
||||
|
||||
### 3) "Mit Google anmelden" einrichten (optional, aber empfohlen)
|
||||
|
||||
Zusätzlich zum E-Mail-Code-Login gibt es jetzt einen "Mit Google anmelden"-
|
||||
Button (professionellerer, schnellerer Login — Google hat die E-Mail-Adresse
|
||||
bereits selbst bestätigt, kein Extra-Code nötig). Komplett optional: ohne
|
||||
`GOOGLE_CLIENT_ID` bleibt der Button einfach ausgeblendet, der normale
|
||||
E-Mail-Code-Login funktioniert unverändert.
|
||||
|
||||
1. https://console.cloud.google.com/apis/credentials öffnen (kostenloses
|
||||
Google-Konto reicht, keine Kreditkarte nötig).
|
||||
2. Falls noch nicht vorhanden: **OAuth-Zustimmungsbildschirm** einmalig
|
||||
einrichten (App-Name z.B. "Dogfather Supporter-Abo", externe Nutzer).
|
||||
3. **Anmeldedaten erstellen → OAuth-Client-ID → Webanwendung.**
|
||||
4. Unter **Autorisierte JavaScript-Quellen** deine echte Domain eintragen
|
||||
(sobald vorhanden) sowie zum Testen
|
||||
`https://dogfather-universe.dogfather1608.workers.dev`.
|
||||
5. Die erzeugte Client-ID (endet auf `.apps.googleusercontent.com`) als
|
||||
Secret setzen:
|
||||
|
||||
```bash
|
||||
npx wrangler secret put GOOGLE_CLIENT_ID
|
||||
```
|
||||
|
||||
Kein Client-Secret nötig — die Anmeldung läuft komplett über Googles
|
||||
"Sign In With Google"-Button im Browser, der Worker prüft das dabei
|
||||
ausgestellte Token nur gegen Googles öffentlichen `tokeninfo`-Endpunkt.
|
||||
|
||||
### 4) E-Mail-Versand einrichten (Resend, kostenloser Tier reicht)
|
||||
|
||||
Ohne E-Mail-Versand können sich Supporter nicht registrieren/einloggen
|
||||
(E-Mail-Bestätigung/Login-Code). Benötigt außerdem eine **eigene, per
|
||||
SPF/DKIM verifizierte Domain** (auf `*.workers.dev` allein funktioniert
|
||||
zuverlässiger E-Mail-Versand nicht).
|
||||
|
||||
1. Account auf https://resend.com anlegen (kostenlos bis 3.000 E-Mails/Monat).
|
||||
2. Eigene Domain hinzufügen und die angezeigten DNS-Einträge setzen.
|
||||
3. API-Key erstellen.
|
||||
|
||||
```bash
|
||||
npx wrangler secret put RESEND_API_KEY
|
||||
```
|
||||
|
||||
Und in `wrangler.toml` unter `[vars]`:
|
||||
|
||||
```toml
|
||||
RESEND_FROM = "Dogfather Team <[email protected]>"
|
||||
```
|
||||
|
||||
### 5) Deployen
|
||||
|
||||
```bash
|
||||
npx wrangler deploy
|
||||
```
|
||||
|
||||
### 6) Berechtigung für den Administrationsbereich vergeben
|
||||
|
||||
Auf `zugaenge.html` einer Rolle (oder dir selbst als Owner, der hat ohnehin
|
||||
alles) die neuen Rechte **„Supporter-Abonnenten ansehen"**, **„Supporter-
|
||||
Prämien & Versand verwalten"** und **„Supporter-Kollektionen verwalten"**
|
||||
zuweisen (Gruppe „Supporter-Abo" in der Rechteverwaltung).
|
||||
|
||||
### Verbindliche Prämienlogik (nicht verändern, Abschnitt 11-13 des Auftrags)
|
||||
|
||||
| Erfolgreich bezahlte Monate (im laufenden Zyklus) | Prämie |
|
||||
|---|---|
|
||||
| 6 | Kleiner Hasen-Teddy |
|
||||
| 12 | Mittelgroßer Hasen-Teddy |
|
||||
| 18 | Großer Hasen-Teddy |
|
||||
| 24 | Tasse & Autogrammkarte |
|
||||
| 30 | Exklusiver Hoodie — Zyklus abgeschlossen |
|
||||
| 31+ | Neuer 30-Monats-Zyklus, neue Kollektion, Fortschritt bei 0 (Gesamt-Mitgliedschaftsdauer bleibt erhalten) |
|
||||
|
||||
Testzahlungen (Sandbox) zählen **nie** als echter Monat. Jedes
|
||||
PayPal-Ereignis wird per `paypal_event_id` genau einmal verarbeitet
|
||||
(keine doppelte Anrechnung bei erneuter Zustellung).
|
||||
|
||||
### Bewusst noch nicht gebaut (nächste Ausbaustufe, nicht Teil der Abnahmekriterien)
|
||||
|
||||
Abschnitt 20 des Auftrags ("Zusätzliche Vorteile") beschreibt optionale
|
||||
Bonus-Features — Abstimmungen, Downloads, Supporter-Wand, Jubiläums-
|
||||
Videobotschaften. Die Datenbankstruktur lässt Raum dafür, sie sind aber
|
||||
bewusst **nicht** Teil dieser ersten Ausbaustufe, da sie in den
|
||||
"Verbindlichen Abnahmekriterien" (Abschnitt 34) nicht gefordert sind.
|
||||
|
||||
## Wartung
|
||||
|
||||
- Bewerbungen/Zugänge/Rollen: alles über `zugaenge.html` bzw. `postfach.html` als Owner.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
iRF6hbEQmRMyie
|
||||
@@ -0,0 +1,152 @@
|
||||
-- =====================================================================
|
||||
-- DOGFATHER × VanVan Supporter-Abo — Datenbankschema
|
||||
-- Setzt "Dogfather_VanVan_Supporter_Abo.odt" um (Abschnitt 28 empfiehlt
|
||||
-- diese Struktur; hier leicht angepasst an die Konventionen des restlichen
|
||||
-- Workers: TEXT-IDs, ISO-Zeitstempel, Wiederverwendung von audit_log statt
|
||||
-- eines eigenen Änderungsprotokolls).
|
||||
--
|
||||
-- Bewusst eine EIGENE, von der internen Team-Zugangsverwaltung (users/
|
||||
-- roles/sessions) komplett GETRENNTE Nutzerwelt — Supporter sind
|
||||
-- öffentliche Fans/Kund:innen, keine Teammitglieder mit Rechten.
|
||||
-- =====================================================================
|
||||
|
||||
-- Öffentliche Supporter-Accounts. Nur Name, TikTok-Name, E-Mail bei
|
||||
-- Registrierung (siehe Abschnitt 5/30 — Datensparsamkeit).
|
||||
CREATE TABLE IF NOT EXISTS supporter_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
tiktok_username TEXT, -- ohne "@", normalisiert
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
pending_email TEXT, -- neue E-Mail, bis sie bestätigt wird (Abschnitt 25)
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active | deleted
|
||||
-- Versanddaten werden ERST bei der ersten Prämien-Einlösung abgefragt
|
||||
-- (Abschnitt 17/30), deshalb hier bewusst alles NULL-fähig.
|
||||
ship_name TEXT,
|
||||
ship_street TEXT,
|
||||
ship_zip TEXT,
|
||||
ship_city TEXT,
|
||||
ship_country TEXT,
|
||||
ship_save_address INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
last_login_at TEXT
|
||||
);
|
||||
|
||||
-- Einmalcodes für E-Mail-Bestätigung UND Magic-Link-Login (Abschnitt 5/116).
|
||||
CREATE TABLE IF NOT EXISTS supporter_auth_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
supporter_id TEXT NOT NULL REFERENCES supporter_users(id),
|
||||
purpose TEXT NOT NULL, -- verify_email | login
|
||||
code_hash TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Sessions für den öffentlichen Supporter-Bereich — eigenes, von der
|
||||
-- internen Team-Session-Tabelle getrenntes Token-System.
|
||||
CREATE TABLE IF NOT EXISTS supporter_sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
supporter_id TEXT NOT NULL REFERENCES supporter_users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
last_active_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Eine (aktuell: genau eine) Abo-Stufe pro Supporter.
|
||||
CREATE TABLE IF NOT EXISTS supporter_subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
supporter_id TEXT NOT NULL REFERENCES supporter_users(id),
|
||||
paypal_subscription_id TEXT UNIQUE, -- erst nach PayPal-Freigabe gesetzt
|
||||
paypal_plan_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | active | suspended | cancelled | expired
|
||||
started_at TEXT,
|
||||
next_payment_at TEXT,
|
||||
paid_period_end_at TEXT, -- bis wann Zugang nach Kündigung bleibt
|
||||
cancelled_at TEXT,
|
||||
-- Fortschritt (Abschnitt 11-13): Gesamtzahl erfolgreich angerechneter
|
||||
-- Monate bleibt für IMMER erhalten (auch über Kündigung/Reaktivierung
|
||||
-- hinweg, Abschnitt 23) — daraus werden Zyklus/Meilenstein/Kollektion
|
||||
-- rein rechnerisch abgeleitet (siehe lib/supporter-cycle.js), NICHT
|
||||
-- redundant gespeichert, damit nichts auseinanderlaufen kann.
|
||||
total_paid_months INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Jede EINZELNE erfolgreiche/fehlgeschlagene PayPal-Zahlung. Idempotenz
|
||||
-- über paypal_event_id: jedes Webhook-Ereignis wird nur einmal verarbeitet
|
||||
-- (Abschnitt 8, "doppelt empfangenes PayPal-Ereignis: keine doppelte
|
||||
-- Anrechnung").
|
||||
CREATE TABLE IF NOT EXISTS supporter_payments (
|
||||
id TEXT PRIMARY KEY,
|
||||
supporter_id TEXT NOT NULL REFERENCES supporter_users(id),
|
||||
subscription_id TEXT NOT NULL REFERENCES supporter_subscriptions(id),
|
||||
paypal_transaction_id TEXT,
|
||||
paypal_event_id TEXT UNIQUE,
|
||||
amount TEXT,
|
||||
currency TEXT DEFAULT 'EUR',
|
||||
payment_status TEXT NOT NULL, -- completed | failed | refunded | reversed
|
||||
is_sandbox INTEGER NOT NULL DEFAULT 0, -- Testzahlungen zählen NIE als echter Monat
|
||||
counted_for_progress INTEGER NOT NULL DEFAULT 0,
|
||||
paid_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Supporter-Kollektionen (Abschnitt 14) — jeder abgeschlossene 30-Monats-
|
||||
-- Zyklus bekommt ein neues Design. Zyklus-Nummer 1 = Kollektion 01 usw.
|
||||
-- collection_number ist der 1-basierte Zyklus-Index (global, nicht pro
|
||||
-- Supporter — alle Supporter im selben Zyklus teilen sich EIN Design).
|
||||
CREATE TABLE IF NOT EXISTS supporter_collections (
|
||||
id TEXT PRIMARY KEY,
|
||||
collection_number INTEGER NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
theme TEXT,
|
||||
description TEXT,
|
||||
cover_image TEXT,
|
||||
small_teddy_design TEXT,
|
||||
medium_teddy_design TEXT,
|
||||
large_teddy_design TEXT,
|
||||
mug_design TEXT,
|
||||
card_design TEXT,
|
||||
hoodie_design TEXT,
|
||||
available_hoodie_sizes TEXT DEFAULT '["S","M","L","XL","XXL"]', -- JSON-Array
|
||||
status TEXT NOT NULL DEFAULT 'draft', -- draft | published
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Eine Zeile pro freigeschaltetem Meilenstein/Prämie eines Supporters.
|
||||
-- milestone_month ∈ {6,12,18,24,30}, cycle_number = welcher 30er-Zyklus.
|
||||
CREATE TABLE IF NOT EXISTS supporter_premiums (
|
||||
id TEXT PRIMARY KEY,
|
||||
supporter_id TEXT NOT NULL REFERENCES supporter_users(id),
|
||||
collection_id TEXT NOT NULL REFERENCES supporter_collections(id),
|
||||
cycle_number INTEGER NOT NULL,
|
||||
milestone_month INTEGER NOT NULL, -- 6 | 12 | 18 | 24 | 30
|
||||
premium_type TEXT NOT NULL, -- teddy_klein | teddy_mittel | teddy_gross | tasse_karte | hoodie
|
||||
unlocked_at TEXT NOT NULL,
|
||||
redeem_status TEXT NOT NULL DEFAULT 'verfuegbar',
|
||||
-- verfuegbar | eingeloest | wird_geprueft | wird_vorbereitet
|
||||
-- versandbereit | versendet | zugestellt | erhalten | problem | ersatz_wird_vorbereitet
|
||||
redeemed_at TEXT,
|
||||
hoodie_size TEXT,
|
||||
hoodie_variant TEXT,
|
||||
ship_name TEXT, ship_street TEXT, ship_zip TEXT, ship_city TEXT, ship_country TEXT,
|
||||
tracking_number TEXT,
|
||||
shipped_at TEXT,
|
||||
delivered_at TEXT,
|
||||
admin_note TEXT,
|
||||
UNIQUE (supporter_id, cycle_number, milestone_month)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supp_sub_supporter ON supporter_subscriptions(supporter_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supp_pay_supporter ON supporter_payments(supporter_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supp_pay_sub ON supporter_payments(subscription_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supp_premiums_supporter ON supporter_premiums(supporter_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supp_sessions_supporter ON supporter_sessions(supporter_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supp_codes_supporter ON supporter_auth_codes(supporter_id);
|
||||
|
||||
-- Kollektion 01 direkt mit anlegen, damit das System sofort nutzbar ist —
|
||||
-- Designs/Bilder können jederzeit im Admin-Bereich nachgepflegt werden.
|
||||
INSERT OR IGNORE INTO supporter_collections (id, collection_number, name, theme, description, status, created_at)
|
||||
VALUES ('collection-01', 1, 'Der Anfang', 'Babyblau & Silber', 'Die allererste Supporter-Kollektion von Dogfather × VanVan.', 'published', datetime('now'));
|
||||
@@ -0,0 +1,22 @@
|
||||
-- =====================================================================
|
||||
-- Supporter-Login: TikTok & Discord (Nutzer-Wunsch 03.08.2026 — "professionelle
|
||||
-- Art sich anzumelden", kostenlose Alternativen zu "Mit Apple anmelden"
|
||||
-- (99 $/Jahr, verstößt gegen die 0-€-Vorgabe)).
|
||||
--
|
||||
-- Google-Login (bereits vorhanden) matched Konten rein über die E-Mail
|
||||
-- (email NOT NULL UNIQUE) — funktioniert, weil Google IMMER eine
|
||||
-- bestätigte E-Mail liefert. TikTok Login Kit liefert dagegen in aller
|
||||
-- Regel GAR KEINE E-Mail (nur open_id/union_id/display_name), Discord
|
||||
-- liefert zwar eine E-Mail, aber zur klaren Nachverfolgbarkeit ("mit
|
||||
-- welchem Anbieter wurde dieses Konto angelegt/zuletzt genutzt") bekommt
|
||||
-- auch Discord eine eigene ID-Spalte statt sich nur auf E-Mail-Matching
|
||||
-- zu verlassen.
|
||||
-- =====================================================================
|
||||
|
||||
ALTER TABLE supporter_users ADD COLUMN tiktok_open_id TEXT;
|
||||
ALTER TABLE supporter_users ADD COLUMN discord_id TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_supporter_users_tiktok_open_id
|
||||
ON supporter_users (tiktok_open_id) WHERE tiktok_open_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_supporter_users_discord_id
|
||||
ON supporter_users (discord_id) WHERE discord_id IS NOT NULL;
|
||||
@@ -7,16 +7,19 @@
|
||||
- Team-Codes: SHA-256-Hash-Lookup (users.code_hash).
|
||||
- Nach 5 Fehlversuchen pro Client (IP-basiert) 15 Minuten Sperre —
|
||||
angelehnt an Cloudflares "Protect your login"-Standardregel.
|
||||
- Sessions: Token wird nur gehasht gespeichert. Inaktivitäts-Timeout
|
||||
30 Minuten, absolute Höchstdauer 12 Stunden.
|
||||
- Sessions: Token wird nur gehasht gespeichert. Inaktivitäts-Timeout und
|
||||
absolute Höchstdauer beide 24 Stunden (Nutzer-Wunsch 04.08.2026: "den
|
||||
Zugangscode auf der Verwaltungsseite nur einmal am Tag eingeben
|
||||
müssen") — vorher 30 Min/12 Std, was mitten am Tag zum erneuten
|
||||
Login-Zwang führte.
|
||||
===================================================================== */
|
||||
import { sha256Hex, generateSessionToken, nowIso } from "./crypto.js";
|
||||
import { logAction } from "./audit.js";
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCKOUT_MINUTES = 15;
|
||||
const INACTIVITY_MS = 30 * 60 * 1000;
|
||||
const SESSION_MAX_AGE_MS = 12 * 60 * 60 * 1000;
|
||||
const INACTIVITY_MS = 24 * 60 * 60 * 1000;
|
||||
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function clientKey(request) {
|
||||
return request.headers.get("CF-Connecting-IP") || "unknown";
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/* =====================================================================
|
||||
lib/google-auth.js — Verifiziert ein Google-ID-Token ("Mit Google
|
||||
anmelden") serverseitig, für den öffentlichen Supporter-Login.
|
||||
|
||||
WICHTIG — offener Punkt, siehe README.md "Supporter-Abo Setup":
|
||||
Braucht GOOGLE_CLIENT_ID (öffentlich, KEIN Geheimnis — steht bewusst als
|
||||
normale [vars]-Variable in wrangler.toml, nicht als Secret). Ohne
|
||||
gesetzten Wert bleibt "Mit Google anmelden" ausgeblendet (siehe
|
||||
supporter.js im Frontend) statt kaputt zu sein.
|
||||
|
||||
Verifikation läuft über Googles offiziellen tokeninfo-Endpunkt
|
||||
(https://oauth2.googleapis.com/tokeninfo) — Google prüft dort Signatur,
|
||||
Ablaufzeit und Aussteller selbst und gibt die Nutzdaten zurück. Das
|
||||
spart eine eigene JWKS-/RS256-Prüfung im Worker, ist aber genauso sicher
|
||||
(offiziell von Google für Server-Side-Verifikation empfohlen).
|
||||
===================================================================== */
|
||||
|
||||
export class GoogleAuthNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super("Google-Login ist noch nicht eingerichtet (GOOGLE_CLIENT_ID fehlt).");
|
||||
this.name = "GoogleAuthNotConfiguredError";
|
||||
this.code = "GOOGLE_AUTH_NOT_CONFIGURED";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ein von Google Identity Services im Browser ausgestelltes ID-Token.
|
||||
* Gibt bei Erfolg die bestätigten Nutzerdaten zurück (E-Mail ist von Google
|
||||
* selbst bereits verifiziert — braucht deshalb KEINEN zusätzlichen
|
||||
* E-Mail-Bestätigungscode mehr, siehe routes/supporter.js).
|
||||
*/
|
||||
export async function verifyGoogleIdToken(env, credential) {
|
||||
if (!env.GOOGLE_CLIENT_ID) throw new GoogleAuthNotConfiguredError();
|
||||
if (!credential) return { ok: false, reason: "Kein Token übergeben." };
|
||||
|
||||
const res = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`);
|
||||
if (!res.ok) return { ok: false, reason: "Google-Token konnte nicht geprüft werden." };
|
||||
const data = await res.json();
|
||||
|
||||
if (data.aud !== env.GOOGLE_CLIENT_ID) return { ok: false, reason: "Token ist für eine andere App ausgestellt." };
|
||||
if (!data.email || data.email_verified !== "true") return { ok: false, reason: "E-Mail-Adresse ist bei Google nicht bestätigt." };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
email: String(data.email).toLowerCase(),
|
||||
name: data.name || data.given_name || data.email.split("@")[0],
|
||||
};
|
||||
}
|
||||
@@ -2,8 +2,16 @@
|
||||
http.js — gemeinsame HTTP-Helfer (CORS, JSON-Responses)
|
||||
===================================================================== */
|
||||
|
||||
// TODO (bewusst noch NICHT auf eine feste Origin eingeschränkt): seit
|
||||
// 03.08.2026 ist die eigene Domain final (dogfather-universe.com, läuft
|
||||
// PARALLEL zur bisherigen workers.dev-Adresse weiter). Ein Origin-genaues
|
||||
// CORS bräuchte den `request` (für den Origin-Header) an praktisch jeder
|
||||
// `json(...)`-Aufrufstelle im gesamten Worker — ein größerer, eigener
|
||||
// Umbau, absichtlich nicht "schnell nebenbei" gemacht, um keine
|
||||
// Login-/Zahlungs-Endpunkte durch eine halbfertige Änderung zu brechen.
|
||||
// Erlaubte Origins für den künftigen Umbau: https://dogfather-universe.com,
|
||||
// https://www.dogfather-universe.com, https://dogfather-universe.dogfather1608.workers.dev
|
||||
export function corsHeaders() {
|
||||
// TODO: sobald die Domain final steht, statt "*" die echte Domain eintragen.
|
||||
return {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/* =====================================================================
|
||||
lib/paypal.js — PayPal Subscriptions API Anbindung
|
||||
(Dogfather_VanVan_Supporter_Abo.odt, Abschnitt 7/8)
|
||||
|
||||
WICHTIG — offener Punkt, siehe README.md "Supporter-Abo Setup":
|
||||
Diese Datei ist vollständig fertig implementiert (OAuth2, Subscription
|
||||
erstellen, Subscription abfragen, Webhook-Signatur prüfen), kann aber
|
||||
erst wirklich Geld verarbeiten, sobald folgende Secrets gesetzt sind
|
||||
(per `wrangler secret put NAME`, NIEMALS im Code/Repo):
|
||||
PAYPAL_CLIENT_ID
|
||||
PAYPAL_CLIENT_SECRET
|
||||
PAYPAL_PLAN_ID (aus dem PayPal-Business-Konto, Abschnitt 7)
|
||||
PAYPAL_WEBHOOK_ID
|
||||
Und der Var (in wrangler.toml, unkritisch, kein Secret):
|
||||
PAYPAL_ENV = "sandbox" | "live"
|
||||
|
||||
Diese Werte kann NUR der Website-Betreiber selbst erzeugen (PayPal-
|
||||
Business-Konto + Developer-App sind an seine eigene Identität/Bank
|
||||
gebunden) — deshalb "fail closed" mit sprechendem Fehler statt eines
|
||||
Absturzes, solange sie fehlen.
|
||||
===================================================================== */
|
||||
|
||||
export class PayPalNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super("PayPal ist noch nicht eingerichtet (PAYPAL_CLIENT_ID/SECRET/PLAN_ID fehlen).");
|
||||
this.name = "PayPalNotConfiguredError";
|
||||
this.code = "PAYPAL_NOT_CONFIGURED";
|
||||
}
|
||||
}
|
||||
|
||||
function baseUrl(env) {
|
||||
return env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
|
||||
}
|
||||
|
||||
function assertConfigured(env) {
|
||||
if (!env.PAYPAL_CLIENT_ID || !env.PAYPAL_CLIENT_SECRET || !env.PAYPAL_PLAN_ID) {
|
||||
throw new PayPalNotConfiguredError();
|
||||
}
|
||||
}
|
||||
|
||||
let cachedToken = null; // { value, expiresAt } — pro Worker-Instanz, spart Requests
|
||||
|
||||
async function getAccessToken(env) {
|
||||
assertConfigured(env);
|
||||
if (cachedToken && cachedToken.expiresAt > Date.now() + 30_000) return cachedToken.value;
|
||||
|
||||
const res = await fetch(`${baseUrl(env)}/v1/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${btoa(`${env.PAYPAL_CLIENT_ID}:${env.PAYPAL_CLIENT_SECRET}`)}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
});
|
||||
if (!res.ok) throw new Error(`PayPal-OAuth fehlgeschlagen (${res.status}): ${await res.text().catch(() => "")}`);
|
||||
const data = await res.json();
|
||||
cachedToken = { value: data.access_token, expiresAt: Date.now() + (data.expires_in || 3600) * 1000 };
|
||||
return cachedToken.value;
|
||||
}
|
||||
|
||||
async function paypalFetch(env, path, options = {}) {
|
||||
const token = await getAccessToken(env);
|
||||
const res = await fetch(`${baseUrl(env)}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) {
|
||||
const err = new Error(`PayPal-Anfrage fehlgeschlagen (${res.status}): ${text.slice(0, 400)}`);
|
||||
err.paypalStatus = res.status;
|
||||
err.paypalBody = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein PayPal-Abonnement für einen Supporter und liefert den
|
||||
* "approve"-Link zurück, zu dem der Nutzer weitergeleitet werden muss, um
|
||||
* das Abo direkt bei PayPal zu bestätigen (Abschnitt 6, Schritt 6-7).
|
||||
* custom_id trägt unsere interne supporter_id, damit der Webhook später
|
||||
* weiß, wem die Zahlung gehört.
|
||||
*/
|
||||
export async function createSubscription(env, { supporterId, returnUrl, cancelUrl }) {
|
||||
const data = await paypalFetch(env, "/v1/billing/subscriptions", {
|
||||
method: "POST",
|
||||
headers: { "PayPal-Request-Id": `sub-${supporterId}-${Date.now()}` },
|
||||
body: JSON.stringify({
|
||||
plan_id: env.PAYPAL_PLAN_ID,
|
||||
custom_id: supporterId,
|
||||
application_context: {
|
||||
brand_name: "Dogfather",
|
||||
locale: "de-DE",
|
||||
shipping_preference: "NO_SHIPPING",
|
||||
user_action: "SUBSCRIBE_NOW",
|
||||
return_url: returnUrl,
|
||||
cancel_url: cancelUrl,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const approveLink = (data.links || []).find((l) => l.rel === "approve");
|
||||
return { paypalSubscriptionId: data.id, approveUrl: approveLink?.href || null, raw: data };
|
||||
}
|
||||
|
||||
export async function getSubscription(env, paypalSubscriptionId) {
|
||||
return paypalFetch(env, `/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}`);
|
||||
}
|
||||
|
||||
/** Kündigung (Abschnitt 22: "verständlich und leicht kündbar"). Zugang bleibt bis Periodenende bestehen. */
|
||||
export async function cancelSubscription(env, paypalSubscriptionId, reason) {
|
||||
await paypalFetch(env, `/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}/cancel`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason: reason || "Kündigung durch Supporter" }),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die Echtheit eines eingehenden Webhooks über PayPals offizielle
|
||||
* Verify-Signature-Funktion (Abschnitt 8, "sichere PayPal-Webhook-Prüfung"
|
||||
* aus den Sicherheitsanforderungen, Abschnitt 29).
|
||||
*/
|
||||
export async function verifyWebhookSignature(env, request, rawBody) {
|
||||
assertConfigured(env);
|
||||
if (!env.PAYPAL_WEBHOOK_ID) throw new PayPalNotConfiguredError();
|
||||
|
||||
const h = request.headers;
|
||||
const verification = await paypalFetch(env, "/v1/notifications/verify-webhook-signature", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
auth_algo: h.get("paypal-auth-algo"),
|
||||
cert_url: h.get("paypal-cert-url"),
|
||||
transmission_id: h.get("paypal-transmission-id"),
|
||||
transmission_sig: h.get("paypal-transmission-sig"),
|
||||
transmission_time: h.get("paypal-transmission-time"),
|
||||
webhook_id: env.PAYPAL_WEBHOOK_ID,
|
||||
webhook_event: JSON.parse(rawBody),
|
||||
}),
|
||||
});
|
||||
return verification.verification_status === "SUCCESS";
|
||||
}
|
||||
@@ -56,6 +56,11 @@ export const PERMISSIONS = {
|
||||
SETTINGS_VIEW_STATS: "Statistiken ansehen",
|
||||
SETTINGS_VIEW_ACTIVITY_LOG: "Aktivitätsverlauf ansehen",
|
||||
SETTINGS_EDIT_GENERAL: "Allgemeine Einstellungen bearbeiten",
|
||||
|
||||
// Supporter-Abo (Dogfather; die Teddy-Kollektion entsteht in Kooperation mit VanVan)
|
||||
SUPPORTER_VIEW: "Supporter-Abonnenten ansehen",
|
||||
SUPPORTER_MANAGE_PREMIUMS: "Supporter-Prämien & Versand verwalten",
|
||||
SUPPORTER_MANAGE_COLLECTIONS: "Supporter-Kollektionen verwalten",
|
||||
};
|
||||
|
||||
export const PERMISSION_GROUPS = [
|
||||
@@ -65,6 +70,7 @@ export const PERMISSION_GROUPS = [
|
||||
{ label: "Interne Notizen", keys: ["NOTES_READ", "NOTES_WRITE", "NOTES_EDIT_OWN", "NOTES_EDIT_OTHERS", "NOTES_DELETE"] },
|
||||
{ label: "Teamverwaltung", keys: ["TEAM_VIEW", "TEAM_PREPARE_USER", "TEAM_LOCK_USER", "TEAM_ASSIGN_ROLE", "TEAM_CREATE_ROLE", "TEAM_EDIT_PERMISSIONS"] },
|
||||
{ label: "Einstellungen", keys: ["SETTINGS_EDIT_FORMS", "SETTINGS_EDIT_STATUS_TYPES", "SETTINGS_VIEW_STATS", "SETTINGS_VIEW_ACTIVITY_LOG", "SETTINGS_EDIT_GENERAL"] },
|
||||
{ label: "DogiCrew 🩵", keys: ["SUPPORTER_VIEW", "SUPPORTER_MANAGE_PREMIUMS", "SUPPORTER_MANAGE_COLLECTIONS"] },
|
||||
];
|
||||
|
||||
// Nur zur Dokumentation/Klarheit — diese Aktionen sind NIE als Permission
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/* =====================================================================
|
||||
lib/supporter-auth.js — Registrierung, E-Mail-Bestätigung, Magic-Link-
|
||||
Login und Sessions für den ÖFFENTLICHEN Supporter-Bereich.
|
||||
|
||||
Bewusst komplett getrennt von auth.js (das ist für das interne
|
||||
Team-Zugangssystem mit Rollen/Rechten). Supporter sind Fans/Kund:innen
|
||||
ohne jegliche Berechtigungen im internen Bereich.
|
||||
|
||||
Login-Prinzip (Abschnitt 5/116 im Auftrag: "bevorzugt … sicherer
|
||||
E-Mail-Code oder Magic Link"): kein Passwort nötig. Ein 6-stelliger Code
|
||||
wird an die hinterlegte E-Mail-Adresse geschickt, 15 Minuten gültig,
|
||||
einmal verwendbar, nur der Hash liegt in der Datenbank.
|
||||
===================================================================== */
|
||||
import { sha256Hex, generateSessionToken, generateId, nowIso } from "./crypto.js";
|
||||
|
||||
const CODE_TTL_MS = 15 * 60 * 1000;
|
||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 Tage — Supporter sollen nicht ständig neu einloggen müssen
|
||||
const INACTIVITY_MS = 14 * 24 * 60 * 60 * 1000; // 14 Tage ohne Aktivität → Session verfällt
|
||||
|
||||
export function normalizeTikTokUsername(input) {
|
||||
if (!input) return null;
|
||||
return String(input).trim().replace(/^@+/, "").toLowerCase() || null;
|
||||
}
|
||||
|
||||
export function normalizeEmail(input) {
|
||||
return String(input || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function generateNumericCode() {
|
||||
const n = crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000;
|
||||
return String(n).padStart(6, "0");
|
||||
}
|
||||
|
||||
/** Erzeugt+speichert einen neuen 6-stelligen Code für E-Mail-Bestätigung oder Login. */
|
||||
export async function issueAuthCode(env, supporterId, purpose) {
|
||||
const code = generateNumericCode();
|
||||
const codeHash = await sha256Hex(code);
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO supporter_auth_codes (id, supporter_id, purpose, code_hash, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.bind(generateId(), supporterId, purpose, codeHash, new Date(Date.now() + CODE_TTL_MS).toISOString(), nowIso())
|
||||
.run();
|
||||
return code;
|
||||
}
|
||||
|
||||
/** Prüft einen eingegebenen Code; markiert ihn bei Erfolg als verbraucht (einmal gültig). */
|
||||
export async function verifyAuthCode(env, supporterId, purpose, code) {
|
||||
const codeHash = await sha256Hex(String(code || "").trim());
|
||||
const row = await env.DB.prepare(
|
||||
`SELECT id, expires_at, used_at FROM supporter_auth_codes
|
||||
WHERE supporter_id = ? AND purpose = ? AND code_hash = ?
|
||||
ORDER BY created_at DESC LIMIT 1`
|
||||
)
|
||||
.bind(supporterId, purpose, codeHash)
|
||||
.first();
|
||||
if (!row) return false;
|
||||
if (row.used_at) return false;
|
||||
if (new Date(row.expires_at).getTime() < Date.now()) return false;
|
||||
await env.DB.prepare(`UPDATE supporter_auth_codes SET used_at = ? WHERE id = ?`).bind(nowIso(), row.id).run();
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function createSupporterSession(env, supporterId) {
|
||||
const token = generateSessionToken();
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const now = new Date();
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO supporter_sessions (token_hash, supporter_id, created_at, last_active_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.bind(tokenHash, supporterId, now.toISOString(), now.toISOString(), new Date(now.getTime() + SESSION_MAX_AGE_MS).toISOString())
|
||||
.run();
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function resolveSupporterSession(env, token) {
|
||||
if (!token) return null;
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const session = await env.DB.prepare(`SELECT * FROM supporter_sessions WHERE token_hash = ?`).bind(tokenHash).first();
|
||||
if (!session) return null;
|
||||
|
||||
const now = Date.now();
|
||||
if (new Date(session.expires_at).getTime() < now || now - new Date(session.last_active_at).getTime() > INACTIVITY_MS) {
|
||||
await env.DB.prepare(`DELETE FROM supporter_sessions WHERE token_hash = ?`).bind(tokenHash).run();
|
||||
return null;
|
||||
}
|
||||
await env.DB.prepare(`UPDATE supporter_sessions SET last_active_at = ? WHERE token_hash = ?`).bind(nowIso(), tokenHash).run();
|
||||
|
||||
const supporter = await env.DB.prepare(
|
||||
`SELECT * FROM supporter_users WHERE id = ? AND status = 'active'`
|
||||
)
|
||||
.bind(session.supporter_id)
|
||||
.first();
|
||||
if (!supporter) return null;
|
||||
return supporter;
|
||||
}
|
||||
|
||||
export async function endSupporterSession(env, token) {
|
||||
if (!token) return;
|
||||
await env.DB.prepare(`DELETE FROM supporter_sessions WHERE token_hash = ?`).bind(await sha256Hex(token)).run();
|
||||
}
|
||||
|
||||
export async function requireSupporterSession(request, env) {
|
||||
const auth = request.headers.get("Authorization") || "";
|
||||
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
|
||||
return resolveSupporterSession(env, token);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/* =====================================================================
|
||||
lib/supporter-cycle.js — Reine Rechenlogik für den 30-Monats-
|
||||
Prämienzyklus des Dogfather Supporter-Abos (die Teddy-Kollektion
|
||||
entsteht in Kooperation mit VanVan, das Abo selbst gehört nur Dogfather).
|
||||
|
||||
Verbindliche Regeln (siehe Dogfather_VanVan_Supporter_Abo.odt,
|
||||
Abschnitt 11-13, wortwörtlich umgesetzt, NICHT verändert):
|
||||
Monat 6 → kleiner Hasen-Teddy
|
||||
Monat 12 → mittelgroßer Hasen-Teddy
|
||||
Monat 18 → großer Hasen-Teddy
|
||||
Monat 24 → Tasse + Autogrammkarte
|
||||
Monat 30 → Hoodie (Zyklus damit abgeschlossen)
|
||||
ab 31 → neuer Zyklus, neue Kollektion, Fortschritt bei 0
|
||||
|
||||
Reine Funktionen ohne D1-/Netzwerkzugriff — dadurch leicht von Hand
|
||||
nachrechenbar und ohne Testrunner überprüfbar (siehe Beispiel im
|
||||
Auftrag: 37 bezahlte Monate → Zyklus 2, Fortschritt 7 von 30).
|
||||
===================================================================== */
|
||||
|
||||
export const CYCLE_LENGTH = 30;
|
||||
|
||||
// Reihenfolge ist bewusst fest — bestimmt auch premium_type-Zuordnung.
|
||||
export const MILESTONES = [
|
||||
{ month: 6, type: "teddy_klein", labelDe: "Kleiner Hasen-Teddy" },
|
||||
{ month: 12, type: "teddy_mittel", labelDe: "Mittelgroßer Hasen-Teddy" },
|
||||
{ month: 18, type: "teddy_gross", labelDe: "Großer Hasen-Teddy" },
|
||||
{ month: 24, type: "tasse_karte", labelDe: "Tasse & Autogrammkarte" },
|
||||
{ month: 30, type: "hoodie", labelDe: "Exklusiver Hoodie" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Aus der GESAMTEN Anzahl erfolgreich angerechneter Abo-Monate (bleibt für
|
||||
* immer erhalten, auch über Kündigung/Reaktivierung hinweg) errechnet sich
|
||||
* rein rechnerisch: wie viele 30er-Zyklen sind vollständig abgeschlossen,
|
||||
* in welchem Zyklus befindet man sich gerade (1-basiert) und wie weit ist
|
||||
* der aktuelle Zyklus fortgeschritten (1..30).
|
||||
*/
|
||||
export function computeProgress(totalPaidMonths) {
|
||||
const total = Math.max(0, Math.floor(totalPaidMonths || 0));
|
||||
if (total === 0) {
|
||||
return { totalPaidMonths: 0, completedCycles: 0, cycleNumber: 1, currentCycleMonth: 0 };
|
||||
}
|
||||
const completedCycles = Math.floor((total - 1) / CYCLE_LENGTH);
|
||||
const currentCycleMonth = total - completedCycles * CYCLE_LENGTH; // 1..30
|
||||
return {
|
||||
totalPaidMonths: total,
|
||||
completedCycles,
|
||||
cycleNumber: completedCycles + 1, // 1-basiert = welche Kollektion gerade läuft
|
||||
currentCycleMonth,
|
||||
};
|
||||
}
|
||||
|
||||
/** Welche Meilensteine sind im AKTUELLEN, laufenden Zyklus schon erreicht? */
|
||||
export function reachedMilestonesInCurrentCycle(currentCycleMonth) {
|
||||
return MILESTONES.filter((m) => currentCycleMonth >= m.month);
|
||||
}
|
||||
|
||||
/** Nächster noch offener Meilenstein im laufenden Zyklus (oder null, wenn Zyklus fertig). */
|
||||
export function nextMilestone(currentCycleMonth) {
|
||||
return MILESTONES.find((m) => currentCycleMonth < m.month) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert ALLE (Zyklus, Meilenstein)-Kombinationen, die bei der gegebenen
|
||||
* Gesamtmonatszahl freigeschaltet sein MÜSSEN — über alle bereits
|
||||
* abgeschlossenen Zyklen hinweg plus den laufenden Zyklus. Wird beim
|
||||
* Verarbeiten einer erfolgreichen Zahlung benutzt, um herauszufinden,
|
||||
* welche supporter_premiums-Zeilen neu angelegt werden müssen.
|
||||
*/
|
||||
export function allUnlockedMilestones(totalPaidMonths) {
|
||||
const { completedCycles, currentCycleMonth } = computeProgress(totalPaidMonths);
|
||||
const unlocked = [];
|
||||
// Vollständig abgeschlossene Zyklen: ALLE 5 Meilensteine gelten als erreicht.
|
||||
for (let c = 1; c <= completedCycles; c++) {
|
||||
for (const m of MILESTONES) unlocked.push({ cycleNumber: c, ...m });
|
||||
}
|
||||
// Laufender Zyklus: nur die bereits erreichten.
|
||||
const cycleNumber = completedCycles + 1;
|
||||
for (const m of reachedMilestonesInCurrentCycle(currentCycleMonth)) {
|
||||
unlocked.push({ cycleNumber, ...m });
|
||||
}
|
||||
return unlocked;
|
||||
}
|
||||
|
||||
/** Für die Fortschritts-Zeitleiste im Supporter-Bereich (Abschnitt 10, Fenster 2). */
|
||||
export function timelineForCurrentCycle(totalPaidMonths) {
|
||||
const { currentCycleMonth, cycleNumber } = computeProgress(totalPaidMonths);
|
||||
return {
|
||||
cycleNumber,
|
||||
currentCycleMonth,
|
||||
milestones: MILESTONES.map((m) => ({
|
||||
...m,
|
||||
reached: currentCycleMonth >= m.month,
|
||||
isNext: nextMilestone(currentCycleMonth)?.month === m.month,
|
||||
monthsRemaining: Math.max(0, m.month - currentCycleMonth),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/* =====================================================================
|
||||
lib/supporter-mail.js — Transaktions-E-Mails für den Supporter-Bereich
|
||||
(E-Mail-Bestätigung, Login-Codes, Zahlungs-/Prämien-Benachrichtigungen).
|
||||
|
||||
WICHTIG — offener Punkt, siehe auch README.md "Supporter-Abo Setup":
|
||||
Es existiert aktuell KEIN E-Mail-Versand-Anbieter (kein Resend/
|
||||
MailChannels o.ä. eingerichtet) und KEINE eigene Domain (die Seite läuft
|
||||
noch auf *.workers.dev) — beides wird für zuverlässigen E-Mail-Versand
|
||||
gebraucht (Absender-Domain muss per SPF/DKIM verifiziert sein, sonst
|
||||
landet alles im Spam oder wird abgelehnt).
|
||||
|
||||
Diese Datei ist deshalb bewusst als AUSTAUSCHBARER Adapter gebaut:
|
||||
Sobald RESEND_API_KEY (+ RESEND_FROM) als Secret gesetzt ist, wird über
|
||||
die Resend-API (https://resend.com, kostenloser Tier reicht für den
|
||||
Start) verschickt. Fehlt der Key, wird NICHTS verschickt und ein
|
||||
sprechender Fehler zurückgegeben — bewusst "fail closed" statt den Code
|
||||
irgendwo im Klartext zu loggen (Sicherheitsprinzip wie beim Rest des
|
||||
Zugangssystems, siehe audit.js).
|
||||
===================================================================== */
|
||||
|
||||
export class EmailNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super("E-Mail-Versand ist noch nicht eingerichtet (RESEND_API_KEY fehlt).");
|
||||
this.name = "EmailNotConfiguredError";
|
||||
this.code = "EMAIL_NOT_CONFIGURED";
|
||||
}
|
||||
}
|
||||
|
||||
async function send(env, { to, subject, html }) {
|
||||
if (!env.RESEND_API_KEY || !env.RESEND_FROM) throw new EmailNotConfiguredError();
|
||||
const res = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.RESEND_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ from: env.RESEND_FROM, to: [to], subject, html }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`E-Mail-Versand fehlgeschlagen (${res.status}): ${text.slice(0, 300)}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const WRAP = (title, bodyHtml) => `
|
||||
<div style="font-family:Arial,sans-serif;background:#0b0d10;color:#f3f5f7;padding:32px;">
|
||||
<div style="max-width:480px;margin:0 auto;background:#171b21;border-radius:14px;padding:28px;border:1px solid #262c35;">
|
||||
<h1 style="color:#8fd9ea;font-size:20px;margin:0 0 16px;">${title}</h1>
|
||||
${bodyHtml}
|
||||
<p style="color:#9aa4b2;font-size:12px;margin-top:24px;">DogiCrew 🩵</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export async function sendVerifyEmailCode(env, { to, name, code }) {
|
||||
return send(env, {
|
||||
to,
|
||||
subject: "Bestätige deine E-Mail-Adresse — DogiCrew 🩵",
|
||||
html: WRAP(
|
||||
`Willkommen, ${name}! 🩵`,
|
||||
`<p style="line-height:1.6;">Bestätige deine E-Mail-Adresse mit diesem Code:</p>
|
||||
<p style="font-size:32px;font-weight:800;letter-spacing:4px;color:#fff;">${code}</p>
|
||||
<p style="color:#9aa4b2;font-size:13px;">Der Code ist 15 Minuten gültig.</p>`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendLoginCode(env, { to, name, code }) {
|
||||
return send(env, {
|
||||
to,
|
||||
subject: "Dein Login-Code — Dogfather Supporter-Bereich",
|
||||
html: WRAP(
|
||||
`Hallo ${name} 👋`,
|
||||
`<p style="line-height:1.6;">Dein Login-Code für den Supporter-Bereich:</p>
|
||||
<p style="font-size:32px;font-weight:800;letter-spacing:4px;color:#fff;">${code}</p>
|
||||
<p style="color:#9aa4b2;font-size:13px;">Der Code ist 15 Minuten gültig. Hast du das nicht angefordert, ignoriere diese E-Mail.</p>`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendMilestoneUnlocked(env, { to, name, premiumLabel }) {
|
||||
return send(env, {
|
||||
to,
|
||||
subject: `Neuer Meilenstein freigeschaltet — ${premiumLabel} 🎉`,
|
||||
html: WRAP(
|
||||
`Glückwunsch, ${name}! 🩵`,
|
||||
`<p style="line-height:1.6;">Du hast einen neuen Meilenstein erreicht und kannst jetzt deine Prämie einlösen:</p>
|
||||
<p style="font-size:20px;font-weight:800;color:#fff;">${premiumLabel}</p>
|
||||
<p style="color:#9aa4b2;font-size:13px;">Logge dich in deinen Supporter-Bereich ein, um die Prämie einzulösen.</p>`
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/* =====================================================================
|
||||
routes/supporter-admin.js — Administrationsbereich für das Supporter-
|
||||
Abo (Dogfather_VanVan_Supporter_Abo.odt Abschnitt 27).
|
||||
|
||||
Nutzt die BESTEHENDE interne Team-Session (auth.js/requireSession),
|
||||
NICHT die öffentliche Supporter-Session — nur berechtigte
|
||||
Administrator:innen (Owner oder Rolle mit SUPPORTER_*-Rechten) dürfen
|
||||
hierauf zugreifen.
|
||||
===================================================================== */
|
||||
import { generateId, nowIso } from "../lib/crypto.js";
|
||||
import { hasPermission } from "../lib/permissions.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { computeProgress, timelineForCurrentCycle } from "../lib/supporter-cycle.js";
|
||||
|
||||
function can(session, key) {
|
||||
if (!session) return false;
|
||||
if (session.isOwner) return true;
|
||||
return hasPermission(session.permissions, session.overrides, key);
|
||||
}
|
||||
|
||||
function forbidden() {
|
||||
return json({ ok: false, error: "Keine Berechtigung." }, 403);
|
||||
}
|
||||
|
||||
/** Abonnentenübersicht mit Filtern (Abschnitt 27). */
|
||||
export async function listSupporters(request, env, session) {
|
||||
if (!can(session, "SUPPORTER_VIEW")) return forbidden();
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const filter = body.filter || "alle";
|
||||
|
||||
const rows = await env.DB.prepare(
|
||||
`SELECT u.id, u.display_name, u.tiktok_username, u.email, u.email_verified, u.created_at,
|
||||
s.id as sub_id, s.paypal_subscription_id, s.status as sub_status, s.started_at,
|
||||
s.next_payment_at, s.paid_period_end_at, s.cancelled_at, s.total_paid_months
|
||||
FROM supporter_users u
|
||||
LEFT JOIN supporter_subscriptions s ON s.supporter_id = u.id
|
||||
WHERE u.status = 'active'
|
||||
ORDER BY u.created_at DESC`
|
||||
).all();
|
||||
|
||||
let list = rows.results || [];
|
||||
const FILTER_MAP = {
|
||||
aktiv: (r) => r.sub_status === "active",
|
||||
gekuendigt: (r) => r.sub_status === "cancelled",
|
||||
zahlung_fehlgeschlagen: (r) => r.sub_status === "suspended",
|
||||
monat_6: (r) => r.total_paid_months >= 6,
|
||||
monat_12: (r) => r.total_paid_months >= 12,
|
||||
monat_18: (r) => r.total_paid_months >= 18,
|
||||
monat_24: (r) => r.total_paid_months >= 24,
|
||||
monat_30: (r) => r.total_paid_months >= 30,
|
||||
};
|
||||
if (FILTER_MAP[filter]) list = list.filter(FILTER_MAP[filter]);
|
||||
|
||||
// Fortschritt (Zyklus/Meilenstein) rein rechnerisch aus total_paid_months
|
||||
// ergänzen (Nutzer-Wunsch 04.08.2026: "wo die Leute mit den Abos dran
|
||||
// sind ... alles einsehen können") — dieselbe Logik wie im Supporter-
|
||||
// Bereich selbst (lib/supporter-cycle.js), damit nichts auseinanderläuft.
|
||||
list = list.map((r) => ({ ...r, progress: computeProgress(r.total_paid_months || 0) }));
|
||||
|
||||
return json({ ok: true, supporters: list });
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersicht über ALLE Supporter hinweg (Nutzer-Wunsch 04.08.2026: "ein
|
||||
* festes Fenster, wo man drauf klicken muss um zu sehen wer was hat, wie
|
||||
* weit ist" — statt sich erst durch jeden einzelnen Supporter klicken zu
|
||||
* müssen). Liefert die komplette Prämien-Warteschlange (für den Versand)
|
||||
* und die letzten Zahlungen über alle Supporter, jeweils mit Name/E-Mail
|
||||
* dazu — bewusst EIN kombinierter Aufruf statt vieler Einzelabfragen.
|
||||
*/
|
||||
export async function getAdminOverview(request, env, session) {
|
||||
if (!can(session, "SUPPORTER_VIEW")) return forbidden();
|
||||
|
||||
const premiumsRes = await env.DB.prepare(
|
||||
`SELECT p.*, c.name as collection_name, u.display_name, u.email, u.tiktok_username
|
||||
FROM supporter_premiums p
|
||||
JOIN supporter_collections c ON c.id = p.collection_id
|
||||
JOIN supporter_users u ON u.id = p.supporter_id
|
||||
ORDER BY
|
||||
CASE p.redeem_status
|
||||
WHEN 'wird_geprueft' THEN 0
|
||||
WHEN 'wird_vorbereitet' THEN 1
|
||||
WHEN 'versandbereit' THEN 2
|
||||
WHEN 'problem' THEN 3
|
||||
WHEN 'ersatz_wird_vorbereitet' THEN 4
|
||||
WHEN 'verfuegbar' THEN 5
|
||||
WHEN 'versendet' THEN 6
|
||||
WHEN 'zugestellt' THEN 7
|
||||
WHEN 'erhalten' THEN 8
|
||||
ELSE 9
|
||||
END,
|
||||
p.unlocked_at DESC`
|
||||
).all();
|
||||
|
||||
const paymentsRes = await env.DB.prepare(
|
||||
`SELECT pay.*, u.display_name, u.email
|
||||
FROM supporter_payments pay
|
||||
JOIN supporter_users u ON u.id = pay.supporter_id
|
||||
ORDER BY pay.paid_at DESC LIMIT 100`
|
||||
).all();
|
||||
|
||||
return json({ ok: true, premiums: premiumsRes.results || [], payments: paymentsRes.results || [] });
|
||||
}
|
||||
|
||||
/** Details + Prämien eines einzelnen Supporters. */
|
||||
export async function getSupporterDetail(request, env, session) {
|
||||
if (!can(session, "SUPPORTER_VIEW")) return forbidden();
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const supporter = await env.DB.prepare(`SELECT * FROM supporter_users WHERE id = ?`).bind(body.supporterId).first();
|
||||
if (!supporter) return json({ ok: false, error: "Nicht gefunden." }, 404);
|
||||
|
||||
const sub = await env.DB.prepare(
|
||||
`SELECT * FROM supporter_subscriptions WHERE supporter_id = ? ORDER BY created_at DESC LIMIT 1`
|
||||
)
|
||||
.bind(supporter.id)
|
||||
.first();
|
||||
const premiums = await env.DB.prepare(
|
||||
`SELECT p.*, c.name as collection_name FROM supporter_premiums p
|
||||
JOIN supporter_collections c ON c.id = p.collection_id
|
||||
WHERE p.supporter_id = ? ORDER BY p.cycle_number, p.milestone_month`
|
||||
)
|
||||
.bind(supporter.id)
|
||||
.all();
|
||||
const payments = await env.DB.prepare(
|
||||
`SELECT * FROM supporter_payments WHERE supporter_id = ? ORDER BY paid_at DESC LIMIT 50`
|
||||
)
|
||||
.bind(supporter.id)
|
||||
.all();
|
||||
|
||||
const totalPaidMonths = sub?.total_paid_months || 0;
|
||||
const progress = computeProgress(totalPaidMonths);
|
||||
const timeline = timelineForCurrentCycle(totalPaidMonths);
|
||||
|
||||
return json({
|
||||
ok: true, supporter, subscription: sub, premiums: premiums.results || [], payments: payments.results || [],
|
||||
progress, timeline,
|
||||
});
|
||||
}
|
||||
|
||||
/** Prämie/Versand manuell bearbeiten (Abschnitt 27 "Prämienverwaltung"). */
|
||||
export async function updatePremium(request, env, session) {
|
||||
if (!can(session, "SUPPORTER_MANAGE_PREMIUMS")) return forbidden();
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const premium = await env.DB.prepare(`SELECT * FROM supporter_premiums WHERE id = ?`).bind(body.premiumId).first();
|
||||
if (!premium) return json({ ok: false, error: "Prämie nicht gefunden." }, 404);
|
||||
|
||||
const ALLOWED_STATUS = [
|
||||
"verfuegbar", "eingeloest", "wird_geprueft", "wird_vorbereitet",
|
||||
"versandbereit", "versendet", "zugestellt", "erhalten", "problem", "ersatz_wird_vorbereitet",
|
||||
];
|
||||
const nextStatus = ALLOWED_STATUS.includes(body.redeemStatus) ? body.redeemStatus : premium.redeem_status;
|
||||
const trackingNumber = body.trackingNumber !== undefined ? body.trackingNumber : premium.tracking_number;
|
||||
const shippedAt = nextStatus === "versendet" && !premium.shipped_at ? nowIso() : premium.shipped_at;
|
||||
const deliveredAt = nextStatus === "zugestellt" && !premium.delivered_at ? nowIso() : premium.delivered_at;
|
||||
|
||||
await env.DB.prepare(
|
||||
`UPDATE supporter_premiums SET redeem_status = ?, tracking_number = ?, shipped_at = ?, delivered_at = ?, admin_note = ? WHERE id = ?`
|
||||
)
|
||||
.bind(nextStatus, trackingNumber || null, shippedAt, deliveredAt, body.adminNote ?? premium.admin_note, premium.id)
|
||||
.run();
|
||||
|
||||
await logAction(env, session.actor, "supporter.premium_updated", premium.id, {
|
||||
from: premium.redeem_status, to: nextStatus,
|
||||
});
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
/** Kollektionen anlegen/bearbeiten (Abschnitt 27 "Kollektionen verwalten"). */
|
||||
export async function listCollections(request, env, session) {
|
||||
if (!can(session, "SUPPORTER_VIEW")) return forbidden();
|
||||
const rows = await env.DB.prepare(`SELECT * FROM supporter_collections ORDER BY collection_number ASC`).all();
|
||||
return json({ ok: true, collections: rows.results || [] });
|
||||
}
|
||||
|
||||
export async function upsertCollection(request, env, session) {
|
||||
if (!can(session, "SUPPORTER_MANAGE_COLLECTIONS")) return forbidden();
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const num = Number(body.collectionNumber);
|
||||
if (!Number.isFinite(num) || num < 1) return json({ ok: false, error: "Ungültige Kollektionsnummer." }, 400);
|
||||
|
||||
const existing = await env.DB.prepare(`SELECT id FROM supporter_collections WHERE collection_number = ?`).bind(num).first();
|
||||
const id = existing?.id || generateId();
|
||||
const fields = {
|
||||
name: body.name || `Kollektion ${String(num).padStart(2, "0")}`,
|
||||
theme: body.theme || null,
|
||||
description: body.description || null,
|
||||
cover_image: body.coverImage || null,
|
||||
small_teddy_design: body.smallTeddyDesign || null,
|
||||
medium_teddy_design: body.mediumTeddyDesign || null,
|
||||
large_teddy_design: body.largeTeddyDesign || null,
|
||||
mug_design: body.mugDesign || null,
|
||||
card_design: body.cardDesign || null,
|
||||
hoodie_design: body.hoodieDesign || null,
|
||||
available_hoodie_sizes: JSON.stringify(body.availableHoodieSizes || ["S", "M", "L", "XL", "XXL"]),
|
||||
status: body.status === "published" ? "published" : "draft",
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await env.DB.prepare(
|
||||
`UPDATE supporter_collections SET name=?, theme=?, description=?, cover_image=?, small_teddy_design=?,
|
||||
medium_teddy_design=?, large_teddy_design=?, mug_design=?, card_design=?, hoodie_design=?,
|
||||
available_hoodie_sizes=?, status=? WHERE id=?`
|
||||
)
|
||||
.bind(
|
||||
fields.name, fields.theme, fields.description, fields.cover_image, fields.small_teddy_design,
|
||||
fields.medium_teddy_design, fields.large_teddy_design, fields.mug_design, fields.card_design,
|
||||
fields.hoodie_design, fields.available_hoodie_sizes, fields.status, id
|
||||
)
|
||||
.run();
|
||||
} else {
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO supporter_collections
|
||||
(id, collection_number, name, theme, description, cover_image, small_teddy_design, medium_teddy_design,
|
||||
large_teddy_design, mug_design, card_design, hoodie_design, available_hoodie_sizes, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.bind(
|
||||
id, num, fields.name, fields.theme, fields.description, fields.cover_image, fields.small_teddy_design,
|
||||
fields.medium_teddy_design, fields.large_teddy_design, fields.mug_design, fields.card_design,
|
||||
fields.hoodie_design, fields.available_hoodie_sizes, fields.status, nowIso()
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
await logAction(env, session.actor, "supporter.collection_saved", id, { collectionNumber: num });
|
||||
return json({ ok: true, id });
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/* =====================================================================
|
||||
routes/supporter-paypal.js — PayPal-Abo erstellen + Webhook-Verarbeitung
|
||||
(Dogfather_VanVan_Supporter_Abo.odt Abschnitt 6-8).
|
||||
===================================================================== */
|
||||
import { generateId, nowIso } from "../lib/crypto.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { createSubscription, verifyWebhookSignature, cancelSubscription, PayPalNotConfiguredError } from "../lib/paypal.js";
|
||||
import { allUnlockedMilestones } from "../lib/supporter-cycle.js";
|
||||
import { sendMilestoneUnlocked } from "../lib/supporter-mail.js";
|
||||
|
||||
const REDEEM_LABELS = {
|
||||
teddy_klein: "Kleiner Hasen-Teddy",
|
||||
teddy_mittel: "Mittelgroßer Hasen-Teddy",
|
||||
teddy_gross: "Großer Hasen-Teddy",
|
||||
tasse_karte: "Tasse & Autogrammkarte",
|
||||
hoodie: "Exklusiver Hoodie",
|
||||
};
|
||||
|
||||
function paypalNotConfiguredResponse() {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
code: "PAYPAL_NOT_CONFIGURED",
|
||||
error:
|
||||
"PayPal ist auf dieser Website noch nicht eingerichtet. Der Websitebetreiber muss zuerst " +
|
||||
"PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, PAYPAL_PLAN_ID und PAYPAL_WEBHOOK_ID setzen " +
|
||||
"(siehe cloudflare-worker/README.md, Abschnitt 'Supporter-Abo Setup').",
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
/** Startet ein PayPal-Abonnement für den eingeloggten Supporter (Abschnitt 6, Schritt 6). */
|
||||
export async function startPaypalSubscription(request, env, supporter) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const origin = body.origin || "https://dogfather-universe.dogfather1608.workers.dev";
|
||||
|
||||
try {
|
||||
const { paypalSubscriptionId, approveUrl } = await createSubscription(env, {
|
||||
supporterId: supporter.id,
|
||||
returnUrl: `${origin}/supporter.html?paypal=success`,
|
||||
cancelUrl: `${origin}/abonnieren.html?paypal=cancelled`,
|
||||
});
|
||||
if (!approveUrl) return json({ ok: false, error: "PayPal hat keinen Bestätigungslink geliefert." }, 502);
|
||||
|
||||
const subId = generateId();
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO supporter_subscriptions
|
||||
(id, supporter_id, paypal_subscription_id, paypal_plan_id, status, total_paid_months, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)`
|
||||
)
|
||||
.bind(subId, supporter.id, paypalSubscriptionId, env.PAYPAL_PLAN_ID, nowIso(), nowIso())
|
||||
.run();
|
||||
|
||||
await logAction(env, "system", "supporter.subscription_started", supporter.id, { paypalSubscriptionId });
|
||||
return json({ ok: true, approveUrl });
|
||||
} catch (err) {
|
||||
if (err instanceof PayPalNotConfiguredError) return paypalNotConfiguredResponse();
|
||||
return json({ ok: false, error: "PayPal-Abo konnte nicht erstellt werden." }, 502);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gegenstück zu den PayPal Smart-Payment-Buttons im Frontend: dort erzeugt
|
||||
* das PayPal-JS-SDK die Subscription direkt im Browser (zeigt dabei
|
||||
* automatisch alle für die Käuferin/den Käufer verfügbaren Zahlarten an —
|
||||
* PayPal-Guthaben, Bankkonto, Kreditkarte als Gast, je nach Land/Konto),
|
||||
* damit landet das Geld direkt auf Dogis PayPal-Konto. Der Browser
|
||||
* übergibt uns danach nur noch die fertige `subscriptionID`, die wir hier
|
||||
* mit dem Supporter-Konto verknüpfen. Der tatsächliche Aktivierungsstatus
|
||||
* kommt weiterhin ausschließlich über den geprüften Webhook (Abschnitt 6:
|
||||
* "Eine reine Weiterleitung... darf nicht ausreichen, um das Abo als aktiv
|
||||
* zu markieren").
|
||||
*/
|
||||
export async function confirmPaypalSubscription(request, env, supporter) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const paypalSubscriptionId = String(body.subscriptionId || "").trim();
|
||||
if (!paypalSubscriptionId) return json({ ok: false, error: "Keine PayPal-Subscription-ID übergeben." }, 400);
|
||||
|
||||
// Rechts-Update 04.08.2026: Nachweis der Widerrufsrecht-Zustimmung
|
||||
// (sofortiger Leistungsbeginn bei digitalen Inhalten, § 356 Abs. 5 BGB /
|
||||
// entspr. Regelung, siehe agb.html Ziffer 6). Die Checkbox im Frontend
|
||||
// (abonnieren.html) blockiert das Laden der PayPal-Buttons bereits
|
||||
// clientseitig — diese serverseitige Prüfung verhindert zusätzlich eine
|
||||
// Umgehung per direktem API-Aufruf ohne echte Zustimmung.
|
||||
const widerrufConsentAt = String(body.widerrufConsentAt || "").trim();
|
||||
const consentDatum = widerrufConsentAt ? new Date(widerrufConsentAt) : null;
|
||||
const consentGueltig = consentDatum && !Number.isNaN(consentDatum.getTime()) && consentDatum.getTime() <= Date.now();
|
||||
if (!consentGueltig) {
|
||||
return json({ ok: false, error: "Bitte bestätige zuerst die Checkbox zum sofortigen Leistungsbeginn." }, 400);
|
||||
}
|
||||
|
||||
const existing = await env.DB.prepare(`SELECT id FROM supporter_subscriptions WHERE paypal_subscription_id = ?`)
|
||||
.bind(paypalSubscriptionId)
|
||||
.first();
|
||||
if (existing) return json({ ok: true }); // schon verknüpft (z.B. doppelter Klick) — kein Fehler
|
||||
|
||||
const subId = generateId();
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO supporter_subscriptions
|
||||
(id, supporter_id, paypal_subscription_id, paypal_plan_id, status, total_paid_months, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)`
|
||||
)
|
||||
.bind(subId, supporter.id, paypalSubscriptionId, env.PAYPAL_PLAN_ID, nowIso(), nowIso())
|
||||
.run();
|
||||
|
||||
await logAction(env, "system", "supporter.subscription_confirmed_client_side", supporter.id, {
|
||||
paypalSubscriptionId,
|
||||
widerrufConsentAt: consentDatum.toISOString(),
|
||||
});
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
export async function cancelPaypalSubscription(request, env, supporter) {
|
||||
const sub = await env.DB.prepare(
|
||||
`SELECT * FROM supporter_subscriptions WHERE supporter_id = ? AND status IN ('active','pending','suspended') ORDER BY created_at DESC LIMIT 1`
|
||||
)
|
||||
.bind(supporter.id)
|
||||
.first();
|
||||
if (!sub) return json({ ok: false, error: "Kein aktives Abonnement gefunden." }, 404);
|
||||
|
||||
try {
|
||||
if (sub.paypal_subscription_id) await cancelSubscription(env, sub.paypal_subscription_id);
|
||||
} catch (err) {
|
||||
if (err instanceof PayPalNotConfiguredError) return paypalNotConfiguredResponse();
|
||||
return json({ ok: false, error: "Kündigung bei PayPal fehlgeschlagen." }, 502);
|
||||
}
|
||||
|
||||
await env.DB.prepare(
|
||||
`UPDATE supporter_subscriptions SET status = 'cancelled', cancelled_at = ?, updated_at = ? WHERE id = ?`
|
||||
)
|
||||
.bind(nowIso(), nowIso(), sub.id)
|
||||
.run();
|
||||
await logAction(env, "system", "supporter.subscription_cancelled", supporter.id, null);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
/* ---------- Webhook (öffentlich erreichbar, aber signaturgeprüft) ---------- */
|
||||
|
||||
async function findSubscriptionByPaypalId(env, paypalSubscriptionId) {
|
||||
if (!paypalSubscriptionId) return null;
|
||||
return env.DB.prepare(`SELECT * FROM supporter_subscriptions WHERE paypal_subscription_id = ?`)
|
||||
.bind(paypalSubscriptionId)
|
||||
.first();
|
||||
}
|
||||
|
||||
async function alreadyProcessed(env, eventId) {
|
||||
if (!eventId) return false;
|
||||
const row = await env.DB.prepare(`SELECT id FROM supporter_payments WHERE paypal_event_id = ?`).bind(eventId).first();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/** Erfolgreiche Zahlung: Monat anrechnen, ggf. neue Prämien freischalten (Abschnitt 8, 11-13). */
|
||||
async function handleSuccessfulPayment(env, sub, event, resource) {
|
||||
const isSandbox = env.PAYPAL_ENV !== "live";
|
||||
const eventId = event.id;
|
||||
const paymentId = generateId();
|
||||
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO supporter_payments
|
||||
(id, supporter_id, subscription_id, paypal_transaction_id, paypal_event_id, amount, currency,
|
||||
payment_status, is_sandbox, counted_for_progress, paid_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'completed', ?, ?, ?, ?)`
|
||||
)
|
||||
.bind(
|
||||
paymentId, sub.supporter_id, sub.id, resource.id || null, eventId,
|
||||
resource.amount?.total || resource.amount?.value || null,
|
||||
resource.amount?.currency || "EUR",
|
||||
isSandbox ? 1 : 0, isSandbox ? 0 : 1, nowIso(), nowIso()
|
||||
)
|
||||
.run();
|
||||
|
||||
if (isSandbox) return; // Testzahlungen zählen NIE als echter Monat (Abschnitt 8/34).
|
||||
|
||||
const oldTotal = sub.total_paid_months;
|
||||
const newTotal = oldTotal + 1;
|
||||
await env.DB.prepare(`UPDATE supporter_subscriptions SET total_paid_months = ?, updated_at = ? WHERE id = ?`)
|
||||
.bind(newTotal, nowIso(), sub.id)
|
||||
.run();
|
||||
|
||||
const before = new Set(allUnlockedMilestones(oldTotal).map((m) => `${m.cycleNumber}:${m.month}`));
|
||||
const after = allUnlockedMilestones(newTotal);
|
||||
const newlyUnlocked = after.filter((m) => !before.has(`${m.cycleNumber}:${m.month}`));
|
||||
|
||||
for (const m of newlyUnlocked) {
|
||||
// Kollektion für diesen Zyklus ermitteln (per collection_number = cycleNumber),
|
||||
// fällt auf Kollektion 1 zurück, falls eine spätere Kollektion im Admin-
|
||||
// Bereich noch nicht angelegt wurde — damit niemand ins Leere freigeschaltet wird.
|
||||
const collection =
|
||||
(await env.DB.prepare(`SELECT id FROM supporter_collections WHERE collection_number = ?`).bind(m.cycleNumber).first()) ||
|
||||
(await env.DB.prepare(`SELECT id FROM supporter_collections WHERE collection_number = 1`).first());
|
||||
if (!collection) continue;
|
||||
|
||||
await env.DB.prepare(
|
||||
`INSERT OR IGNORE INTO supporter_premiums
|
||||
(id, supporter_id, collection_id, cycle_number, milestone_month, premium_type, unlocked_at, redeem_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'verfuegbar')`
|
||||
)
|
||||
.bind(generateId(), sub.supporter_id, collection.id, m.cycleNumber, m.month, m.type, nowIso())
|
||||
.run();
|
||||
|
||||
const supporterRow = await env.DB.prepare(`SELECT display_name, email FROM supporter_users WHERE id = ?`)
|
||||
.bind(sub.supporter_id)
|
||||
.first();
|
||||
if (supporterRow) {
|
||||
await sendMilestoneUnlocked(env, {
|
||||
to: supporterRow.email,
|
||||
name: supporterRow.display_name,
|
||||
premiumLabel: REDEEM_LABELS[m.type] || m.type,
|
||||
}).catch(() => {}); // E-Mail-Fehler dürfen die Webhook-Verarbeitung nie blockieren.
|
||||
}
|
||||
await logAction(env, "system", "supporter.milestone_unlocked", sub.supporter_id, { cycle: m.cycleNumber, month: m.month, type: m.type });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReversedPayment(env, sub, event, resource) {
|
||||
const eventId = event.id;
|
||||
const paymentId = generateId();
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO supporter_payments
|
||||
(id, supporter_id, subscription_id, paypal_transaction_id, paypal_event_id, amount, currency,
|
||||
payment_status, is_sandbox, counted_for_progress, paid_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`
|
||||
)
|
||||
.bind(
|
||||
paymentId, sub.supporter_id, sub.id, resource.id || null, eventId,
|
||||
resource.amount?.total || resource.amount?.value || null, resource.amount?.currency || "EUR",
|
||||
event.event_type.includes("REFUND") ? "refunded" : "reversed",
|
||||
env.PAYPAL_ENV !== "live" ? 1 : 0, nowIso(), nowIso()
|
||||
)
|
||||
.run();
|
||||
|
||||
// Zurückgebuchte/zurückerstattete Zahlung: betroffenen Monat zur manuellen
|
||||
// Prüfung markieren statt automatisch zu löschen (Abschnitt 24 — "muss
|
||||
// manuell durch einen Administrator geprüft werden").
|
||||
await logAction(env, "admin", "supporter.payment_flagged_for_review", sub.supporter_id, {
|
||||
eventType: event.event_type,
|
||||
transactionId: resource.id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handlePaypalWebhook(request, env) {
|
||||
const rawBody = await request.text();
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return json({ ok: false, error: "Ungültiger Webhook-Body." }, 400);
|
||||
}
|
||||
|
||||
let verified = false;
|
||||
try {
|
||||
verified = await verifyWebhookSignature(env, request, rawBody);
|
||||
} catch (err) {
|
||||
if (err instanceof PayPalNotConfiguredError) return paypalNotConfiguredResponse();
|
||||
return json({ ok: false, error: "Webhook-Signaturprüfung fehlgeschlagen." }, 400);
|
||||
}
|
||||
if (!verified) return json({ ok: false, error: "Ungültige Webhook-Signatur." }, 400);
|
||||
|
||||
if (await alreadyProcessed(env, event.id)) return json({ ok: true, duplicate: true });
|
||||
|
||||
const resource = event.resource || {};
|
||||
const type = event.event_type;
|
||||
|
||||
try {
|
||||
if (type === "BILLING.SUBSCRIPTION.ACTIVATED") {
|
||||
const sub = await findSubscriptionByPaypalId(env, resource.id);
|
||||
if (sub) {
|
||||
await env.DB.prepare(
|
||||
`UPDATE supporter_subscriptions SET status = 'active', started_at = COALESCE(started_at, ?),
|
||||
next_payment_at = ?, updated_at = ? WHERE id = ?`
|
||||
)
|
||||
.bind(nowIso(), resource.billing_info?.next_billing_time || null, nowIso(), sub.id)
|
||||
.run();
|
||||
}
|
||||
} else if (type === "BILLING.SUBSCRIPTION.UPDATED") {
|
||||
const sub = await findSubscriptionByPaypalId(env, resource.id);
|
||||
if (sub) {
|
||||
await env.DB.prepare(`UPDATE supporter_subscriptions SET next_payment_at = ?, updated_at = ? WHERE id = ?`)
|
||||
.bind(resource.billing_info?.next_billing_time || sub.next_payment_at, nowIso(), sub.id)
|
||||
.run();
|
||||
}
|
||||
} else if (type === "BILLING.SUBSCRIPTION.CANCELLED") {
|
||||
const sub = await findSubscriptionByPaypalId(env, resource.id);
|
||||
if (sub) {
|
||||
await env.DB.prepare(
|
||||
`UPDATE supporter_subscriptions SET status = 'cancelled', cancelled_at = ?,
|
||||
paid_period_end_at = COALESCE(paid_period_end_at, next_payment_at), updated_at = ? WHERE id = ?`
|
||||
)
|
||||
.bind(nowIso(), nowIso(), sub.id)
|
||||
.run();
|
||||
}
|
||||
} else if (type === "BILLING.SUBSCRIPTION.SUSPENDED") {
|
||||
const sub = await findSubscriptionByPaypalId(env, resource.id);
|
||||
if (sub) await env.DB.prepare(`UPDATE supporter_subscriptions SET status = 'suspended', updated_at = ? WHERE id = ?`).bind(nowIso(), sub.id).run();
|
||||
} else if (type === "BILLING.SUBSCRIPTION.EXPIRED") {
|
||||
const sub = await findSubscriptionByPaypalId(env, resource.id);
|
||||
if (sub) await env.DB.prepare(`UPDATE supporter_subscriptions SET status = 'expired', updated_at = ? WHERE id = ?`).bind(nowIso(), sub.id).run();
|
||||
} else if (type === "PAYMENT.SALE.COMPLETED") {
|
||||
const sub = await findSubscriptionByPaypalId(env, resource.billing_agreement_id);
|
||||
if (sub) await handleSuccessfulPayment(env, sub, event, resource);
|
||||
} else if (type === "PAYMENT.SALE.DENIED") {
|
||||
const sub = await findSubscriptionByPaypalId(env, resource.billing_agreement_id);
|
||||
if (sub) await logAction(env, "system", "supporter.payment_failed", sub.supporter_id, { transactionId: resource.id });
|
||||
} else if (type === "PAYMENT.SALE.REFUNDED" || type === "PAYMENT.SALE.REVERSED") {
|
||||
const sub = await findSubscriptionByPaypalId(env, resource.billing_agreement_id);
|
||||
if (sub) await handleReversedPayment(env, sub, event, resource);
|
||||
}
|
||||
// Unbekannte/nicht relevante Ereignistypen werden bewusst stillschweigend bestätigt
|
||||
// (PayPal wiederholt Zustellversuche sonst unnötig).
|
||||
} catch (err) {
|
||||
// Bewusst 200 statt 500 an PayPal zurückgeben nur, wenn der Fehler NICHT
|
||||
// die Zahlungsverarbeitung selbst betrifft — hier lassen wir echte
|
||||
// Fehler durchschlagen, damit PayPal es später erneut zustellt.
|
||||
return json({ ok: false, error: "Webhook-Verarbeitung fehlgeschlagen." }, 500);
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
/* =====================================================================
|
||||
routes/supporter.js — Öffentlicher Supporter-Bereich: Registrierung,
|
||||
E-Mail-Bestätigung, Magic-Link-Login, persönliches Dashboard, Prämien-
|
||||
Einlösung, Aboverwaltung.
|
||||
|
||||
Setzt Dogfather_VanVan_Supporter_Abo.odt Abschnitt 5, 6, 9-18, 21-25 um.
|
||||
===================================================================== */
|
||||
import { generateId, nowIso } from "../lib/crypto.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import {
|
||||
normalizeEmail, normalizeTikTokUsername, issueAuthCode, verifyAuthCode,
|
||||
createSupporterSession, endSupporterSession,
|
||||
} from "../lib/supporter-auth.js";
|
||||
import { EmailNotConfiguredError, sendVerifyEmailCode, sendLoginCode } from "../lib/supporter-mail.js";
|
||||
import { computeProgress, timelineForCurrentCycle, MILESTONES } from "../lib/supporter-cycle.js";
|
||||
import { verifyGoogleIdToken, GoogleAuthNotConfiguredError } from "../lib/google-auth.js";
|
||||
|
||||
function bad(msg, status = 400) {
|
||||
return json({ ok: false, error: msg }, status);
|
||||
}
|
||||
|
||||
function emailErrorResponse(err) {
|
||||
if (err instanceof EmailNotConfiguredError || err.code === "EMAIL_NOT_CONFIGURED") {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error:
|
||||
"Der E-Mail-Versand ist auf dieser Website noch nicht eingerichtet (RESEND_API_KEY fehlt). " +
|
||||
"Bitte zuerst im Worker-Setup konfigurieren, siehe cloudflare-worker/README.md.",
|
||||
code: "EMAIL_NOT_CONFIGURED",
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
return json({ ok: false, error: "E-Mail konnte nicht verschickt werden." }, 502);
|
||||
}
|
||||
|
||||
/* ---------- Registrierung & Login (öffentlich, keine Session) ---------- */
|
||||
|
||||
export async function registerSupporter(request, env) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const displayName = String(body.displayName || "").trim().slice(0, 60);
|
||||
const tiktokUsername = normalizeTikTokUsername(body.tiktokUsername);
|
||||
const email = normalizeEmail(body.email);
|
||||
|
||||
if (!displayName) return bad("Bitte gib einen Namen oder Anzeigenamen an.");
|
||||
if (!email || !email.includes("@")) return bad("Bitte gib eine gültige E-Mail-Adresse an.");
|
||||
|
||||
const existing = await env.DB.prepare(`SELECT id, email_verified FROM supporter_users WHERE email = ?`)
|
||||
.bind(email)
|
||||
.first();
|
||||
|
||||
let supporterId;
|
||||
if (existing) {
|
||||
if (existing.email_verified) {
|
||||
return bad("Für diese E-Mail-Adresse existiert bereits ein Supporter-Konto. Bitte einloggen.", 409);
|
||||
}
|
||||
supporterId = existing.id;
|
||||
await env.DB.prepare(`UPDATE supporter_users SET display_name = ?, tiktok_username = ? WHERE id = ?`)
|
||||
.bind(displayName, tiktokUsername, supporterId)
|
||||
.run();
|
||||
} else {
|
||||
supporterId = generateId();
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO supporter_users (id, display_name, tiktok_username, email, email_verified, created_at)
|
||||
VALUES (?, ?, ?, ?, 0, ?)`
|
||||
)
|
||||
.bind(supporterId, displayName, tiktokUsername, email, nowIso())
|
||||
.run();
|
||||
await logAction(env, "system", "supporter.registered", supporterId, { email });
|
||||
}
|
||||
|
||||
const code = await issueAuthCode(env, supporterId, "verify_email");
|
||||
try {
|
||||
await sendVerifyEmailCode(env, { to: email, name: displayName, code });
|
||||
} catch (err) {
|
||||
return emailErrorResponse(err);
|
||||
}
|
||||
return json({ ok: true, needsVerification: true });
|
||||
}
|
||||
|
||||
export async function verifySupporterEmail(request, env) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const email = normalizeEmail(body.email);
|
||||
const code = String(body.code || "").trim();
|
||||
if (!email || !code) return bad("E-Mail und Code werden benötigt.");
|
||||
|
||||
const supporter = await env.DB.prepare(`SELECT id, display_name FROM supporter_users WHERE email = ?`).bind(email).first();
|
||||
if (!supporter) return bad("Kein Supporter-Konto mit dieser E-Mail-Adresse gefunden.", 404);
|
||||
|
||||
const valid = await verifyAuthCode(env, supporter.id, "verify_email", code);
|
||||
if (!valid) return bad("Der Code ist ungültig oder abgelaufen.", 401);
|
||||
|
||||
await env.DB.prepare(`UPDATE supporter_users SET email_verified = 1, last_login_at = ? WHERE id = ?`)
|
||||
.bind(nowIso(), supporter.id)
|
||||
.run();
|
||||
await logAction(env, "system", "supporter.email_verified", supporter.id, null);
|
||||
|
||||
const token = await createSupporterSession(env, supporter.id);
|
||||
return json({ ok: true, token });
|
||||
}
|
||||
|
||||
export async function requestSupporterLogin(request, env) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const email = normalizeEmail(body.email);
|
||||
if (!email) return bad("Bitte gib deine E-Mail-Adresse an.");
|
||||
|
||||
const supporter = await env.DB.prepare(
|
||||
`SELECT id, display_name, email_verified FROM supporter_users WHERE email = ? AND status = 'active'`
|
||||
)
|
||||
.bind(email)
|
||||
.first();
|
||||
// Bewusst IMMER ok:true nach außen (keine Auskunft, ob ein Konto existiert).
|
||||
if (!supporter || !supporter.email_verified) return json({ ok: true });
|
||||
|
||||
const code = await issueAuthCode(env, supporter.id, "login");
|
||||
try {
|
||||
await sendLoginCode(env, { to: email, name: supporter.display_name, code });
|
||||
} catch (err) {
|
||||
return emailErrorResponse(err);
|
||||
}
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
export async function verifySupporterLogin(request, env) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const email = normalizeEmail(body.email);
|
||||
const code = String(body.code || "").trim();
|
||||
if (!email || !code) return bad("E-Mail und Code werden benötigt.");
|
||||
|
||||
const supporter = await env.DB.prepare(`SELECT id FROM supporter_users WHERE email = ? AND status = 'active'`).bind(email).first();
|
||||
if (!supporter) return bad("Kein Supporter-Konto gefunden.", 404);
|
||||
|
||||
const valid = await verifyAuthCode(env, supporter.id, "login", code);
|
||||
if (!valid) return bad("Der Code ist ungültig oder abgelaufen.", 401);
|
||||
|
||||
await env.DB.prepare(`UPDATE supporter_users SET last_login_at = ? WHERE id = ?`).bind(nowIso(), supporter.id).run();
|
||||
const token = await createSupporterSession(env, supporter.id);
|
||||
return json({ ok: true, token });
|
||||
}
|
||||
|
||||
/**
|
||||
* "Mit Google anmelden" — deckt Registrierung UND Login gleichzeitig ab:
|
||||
* Google hat die E-Mail-Adresse bereits selbst bestätigt, deshalb entfällt
|
||||
* hier der sonst nötige Bestätigungscode. Existiert schon ein Konto mit
|
||||
* dieser E-Mail (egal ob per Code oder vorher schon per Google angelegt),
|
||||
* wird es einfach eingeloggt statt doppelt erzeugt.
|
||||
*/
|
||||
export async function googleLoginSupporter(request, env) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
let result;
|
||||
try {
|
||||
result = await verifyGoogleIdToken(env, body.credential);
|
||||
} catch (err) {
|
||||
if (err instanceof GoogleAuthNotConfiguredError) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
code: "GOOGLE_AUTH_NOT_CONFIGURED",
|
||||
error: "Google-Login ist auf dieser Website noch nicht eingerichtet.",
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
return json({ ok: false, error: "Google-Anmeldung fehlgeschlagen." }, 502);
|
||||
}
|
||||
if (!result.ok) return bad(result.reason || "Google-Anmeldung fehlgeschlagen.", 401);
|
||||
|
||||
let supporter = await env.DB.prepare(`SELECT id FROM supporter_users WHERE email = ? AND status = 'active'`)
|
||||
.bind(result.email)
|
||||
.first();
|
||||
|
||||
if (!supporter) {
|
||||
const id = generateId();
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO supporter_users (id, display_name, email, email_verified, created_at, last_login_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?)`
|
||||
)
|
||||
.bind(id, result.name, result.email, nowIso(), nowIso())
|
||||
.run();
|
||||
supporter = { id };
|
||||
await logAction(env, "system", "supporter.registered_via_google", supporter.id, { email: result.email });
|
||||
} else {
|
||||
await env.DB.prepare(`UPDATE supporter_users SET last_login_at = ? WHERE id = ?`).bind(nowIso(), supporter.id).run();
|
||||
}
|
||||
|
||||
const token = await createSupporterSession(env, supporter.id);
|
||||
return json({ ok: true, token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentliche, NICHT geheime Konfiguration fürs Frontend (Client-IDs sind
|
||||
* per Design öffentlich — nur die zugehörigen Secrets sind es nicht).
|
||||
* Fehlt ein Wert, bleibt der jeweilige Button im Frontend einfach
|
||||
* ausgeblendet statt kaputt zu sein.
|
||||
*/
|
||||
export async function getPublicConfig(request, env) {
|
||||
return json({
|
||||
ok: true,
|
||||
googleClientId: env.GOOGLE_CLIENT_ID || null,
|
||||
paypalClientId: env.PAYPAL_CLIENT_ID || null,
|
||||
paypalPlanId: env.PAYPAL_PLAN_ID || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function logoutSupporter(request, env) {
|
||||
const auth = request.headers.get("Authorization") || "";
|
||||
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
|
||||
await endSupporterSession(env, token);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
/* ---------- Geschützter Supporter-Bereich (Session erforderlich) ---------- */
|
||||
|
||||
const REDEEM_LABELS = {
|
||||
teddy_klein: "Kleiner Hasen-Teddy",
|
||||
teddy_mittel: "Mittelgroßer Hasen-Teddy",
|
||||
teddy_gross: "Großer Hasen-Teddy",
|
||||
tasse_karte: "Tasse & Autogrammkarte",
|
||||
hoodie: "Exklusiver Hoodie",
|
||||
};
|
||||
|
||||
/** Baut die vollständige Dashboard-Antwort (Fenster 1+2+Archiv, Abschnitt 10/15). */
|
||||
async function buildDashboard(env, supporter) {
|
||||
const sub = await env.DB.prepare(
|
||||
`SELECT * FROM supporter_subscriptions WHERE supporter_id = ? ORDER BY created_at DESC LIMIT 1`
|
||||
)
|
||||
.bind(supporter.id)
|
||||
.first();
|
||||
|
||||
const totalPaidMonths = sub?.total_paid_months || 0;
|
||||
const progress = computeProgress(totalPaidMonths);
|
||||
const timeline = timelineForCurrentCycle(totalPaidMonths);
|
||||
|
||||
const premiumsRes = await env.DB.prepare(
|
||||
`SELECT p.*, c.name as collection_name, c.collection_number
|
||||
FROM supporter_premiums p JOIN supporter_collections c ON c.id = p.collection_id
|
||||
WHERE p.supporter_id = ? ORDER BY p.cycle_number ASC, p.milestone_month ASC`
|
||||
)
|
||||
.bind(supporter.id)
|
||||
.all();
|
||||
const premiums = (premiumsRes.results || []).map((p) => ({ ...p, label: REDEEM_LABELS[p.premium_type] || p.premium_type }));
|
||||
|
||||
// Archiv: nach Kollektion gruppiert (Abschnitt 15 — abgeschlossene Kollektionen bleiben sichtbar).
|
||||
const byCollection = new Map();
|
||||
for (const p of premiums) {
|
||||
const key = p.collection_number;
|
||||
if (!byCollection.has(key)) byCollection.set(key, { collectionNumber: key, collectionName: p.collection_name, premiums: [] });
|
||||
byCollection.get(key).premiums.push(p);
|
||||
}
|
||||
|
||||
// Zahlungshistorie (Nutzer-Wunsch 04.08.2026: "die Kisten sollen beim
|
||||
// Klicken viel mehr Infos zeigen") — für die Detailansicht "Nächste
|
||||
// Zahlung" im Frontend. sub kann null sein (noch nie ein Abo gehabt).
|
||||
let payments = [];
|
||||
if (sub) {
|
||||
const paymentsRes = await env.DB.prepare(
|
||||
`SELECT amount, currency, payment_status, paid_at, is_sandbox
|
||||
FROM supporter_payments WHERE supporter_id = ? ORDER BY paid_at DESC LIMIT 36`
|
||||
)
|
||||
.bind(supporter.id)
|
||||
.all();
|
||||
payments = paymentsRes.results || [];
|
||||
}
|
||||
|
||||
return {
|
||||
profile: {
|
||||
displayName: supporter.display_name,
|
||||
tiktokUsername: supporter.tiktok_username,
|
||||
email: supporter.email,
|
||||
hasShippingAddress: !!(supporter.ship_street && supporter.ship_zip),
|
||||
},
|
||||
subscription: sub
|
||||
? {
|
||||
status: sub.status,
|
||||
startedAt: sub.started_at,
|
||||
nextPaymentAt: sub.next_payment_at,
|
||||
paidPeriodEndAt: sub.paid_period_end_at,
|
||||
cancelledAt: sub.cancelled_at,
|
||||
monthlyPrice: "4,99 €",
|
||||
totalPaidMonths,
|
||||
}
|
||||
: { status: "none", totalPaidMonths: 0, monthlyPrice: "4,99 €" },
|
||||
progress,
|
||||
timeline,
|
||||
premiums,
|
||||
archive: [...byCollection.values()].sort((a, b) => a.collectionNumber - b.collectionNumber),
|
||||
payments,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSupporterMe(request, env, supporter) {
|
||||
return json({ ok: true, ...(await buildDashboard(env, supporter)) });
|
||||
}
|
||||
|
||||
export async function updateSupporterProfile(request, env, supporter) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const displayName = body.displayName !== undefined ? String(body.displayName).trim().slice(0, 60) : supporter.display_name;
|
||||
const tiktokUsername =
|
||||
body.tiktokUsername !== undefined ? normalizeTikTokUsername(body.tiktokUsername) : supporter.tiktok_username;
|
||||
if (!displayName) return bad("Der Name darf nicht leer sein.");
|
||||
|
||||
await env.DB.prepare(`UPDATE supporter_users SET display_name = ?, tiktok_username = ? WHERE id = ?`)
|
||||
.bind(displayName, tiktokUsername, supporter.id)
|
||||
.run();
|
||||
await logAction(env, "system", "supporter.profile_updated", supporter.id, null);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
export async function requestSupporterEmailChange(request, env, supporter) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const newEmail = normalizeEmail(body.newEmail);
|
||||
if (!newEmail || !newEmail.includes("@")) return bad("Bitte gib eine gültige neue E-Mail-Adresse an.");
|
||||
|
||||
const clash = await env.DB.prepare(`SELECT id FROM supporter_users WHERE email = ? AND id != ?`)
|
||||
.bind(newEmail, supporter.id)
|
||||
.first();
|
||||
if (clash) return bad("Diese E-Mail-Adresse wird bereits verwendet.", 409);
|
||||
|
||||
await env.DB.prepare(`UPDATE supporter_users SET pending_email = ? WHERE id = ?`).bind(newEmail, supporter.id).run();
|
||||
const code = await issueAuthCode(env, supporter.id, "change_email");
|
||||
try {
|
||||
await sendVerifyEmailCode(env, { to: newEmail, name: supporter.display_name, code });
|
||||
} catch (err) {
|
||||
return emailErrorResponse(err);
|
||||
}
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
export async function confirmSupporterEmailChange(request, env, supporter) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const code = String(body.code || "").trim();
|
||||
if (!supporter.pending_email) return bad("Es liegt keine E-Mail-Änderung vor.");
|
||||
const valid = await verifyAuthCode(env, supporter.id, "change_email", code);
|
||||
if (!valid) return bad("Der Code ist ungültig oder abgelaufen.", 401);
|
||||
|
||||
await env.DB.prepare(`UPDATE supporter_users SET email = pending_email, pending_email = NULL WHERE id = ?`)
|
||||
.bind(supporter.id)
|
||||
.run();
|
||||
await logAction(env, "system", "supporter.email_changed", supporter.id, null);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
/** Prämie einlösen (Abschnitt 17) — Versandadresse erst hier, nicht bei der Registrierung. */
|
||||
export async function redeemPremium(request, env, supporter) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const premium = await env.DB.prepare(`SELECT * FROM supporter_premiums WHERE id = ? AND supporter_id = ?`)
|
||||
.bind(body.premiumId, supporter.id)
|
||||
.first();
|
||||
if (!premium) return bad("Prämie nicht gefunden.", 404);
|
||||
if (premium.redeem_status !== "verfuegbar") return bad("Diese Prämie wurde bereits eingelöst oder ist gesperrt.", 409);
|
||||
|
||||
const useSaved = !!body.useSavedAddress && supporter.ship_street;
|
||||
const addr = useSaved
|
||||
? {
|
||||
name: supporter.ship_name, street: supporter.ship_street, zip: supporter.ship_zip,
|
||||
city: supporter.ship_city, country: supporter.ship_country,
|
||||
}
|
||||
: {
|
||||
name: String(body.shipName || "").trim(),
|
||||
street: String(body.shipStreet || "").trim(),
|
||||
zip: String(body.shipZip || "").trim(),
|
||||
city: String(body.shipCity || "").trim(),
|
||||
country: String(body.shipCountry || "").trim(),
|
||||
};
|
||||
if (!addr.name || !addr.street || !addr.zip || !addr.city || !addr.country) {
|
||||
return bad("Bitte fülle alle Adressfelder aus.");
|
||||
}
|
||||
|
||||
if (premium.premium_type === "hoodie" && !body.hoodieSize) {
|
||||
return bad("Bitte wähle eine Hoodie-Größe.");
|
||||
}
|
||||
|
||||
await env.DB.prepare(
|
||||
`UPDATE supporter_premiums SET redeem_status = 'eingeloest', redeemed_at = ?,
|
||||
ship_name = ?, ship_street = ?, ship_zip = ?, ship_city = ?, ship_country = ?,
|
||||
hoodie_size = ?, hoodie_variant = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.bind(
|
||||
nowIso(), addr.name, addr.street, addr.zip, addr.city, addr.country,
|
||||
body.hoodieSize || null, body.hoodieVariant || null, premium.id
|
||||
)
|
||||
.run();
|
||||
|
||||
if (body.saveAddress) {
|
||||
await env.DB.prepare(
|
||||
`UPDATE supporter_users SET ship_name=?, ship_street=?, ship_zip=?, ship_city=?, ship_country=?, ship_save_address=1 WHERE id=?`
|
||||
)
|
||||
.bind(addr.name, addr.street, addr.zip, addr.city, addr.country, supporter.id)
|
||||
.run();
|
||||
}
|
||||
|
||||
await logAction(env, "system", "supporter.premium_redeemed", supporter.id, { premiumId: premium.id, type: premium.premium_type });
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
export async function deleteSupporterAccount(request, env, supporter) {
|
||||
// Datenschutz (Abschnitt 30): Konto löschbar. Vertrags-/Zahlungsdaten
|
||||
// bleiben aus gesetzlichen Aufbewahrungspflichten bestehen (nur an den
|
||||
// Nutzerdatensatz selbst wird nicht mehr referenziert) — deshalb hier
|
||||
// bewusst "soft delete" statt hartem DELETE der Zahlungshistorie.
|
||||
await env.DB.prepare(`UPDATE supporter_users SET status = 'deleted', email = email || '.geloescht.' || id WHERE id = ?`)
|
||||
.bind(supporter.id)
|
||||
.run();
|
||||
await logAction(env, "system", "supporter.account_deleted", supporter.id, null);
|
||||
return json({ ok: true });
|
||||
}
|
||||
@@ -41,6 +41,19 @@ import {
|
||||
import { listAuditLog } from "./routes/audit.js";
|
||||
import { checkTikTokLive } from "./lib/tiktok-live.js";
|
||||
import { logAction } from "./lib/audit.js";
|
||||
import { requireSupporterSession } from "./lib/supporter-auth.js";
|
||||
import {
|
||||
registerSupporter, verifySupporterEmail, requestSupporterLogin, verifySupporterLogin, logoutSupporter,
|
||||
googleLoginSupporter, getPublicConfig,
|
||||
getSupporterMe, updateSupporterProfile, requestSupporterEmailChange, confirmSupporterEmailChange,
|
||||
redeemPremium, deleteSupporterAccount,
|
||||
} from "./routes/supporter.js";
|
||||
import {
|
||||
startPaypalSubscription, confirmPaypalSubscription, cancelPaypalSubscription, handlePaypalWebhook,
|
||||
} from "./routes/supporter-paypal.js";
|
||||
import {
|
||||
listSupporters, getSupporterDetail, updatePremium, listCollections, upsertCollection, getAdminOverview,
|
||||
} from "./routes/supporter-admin.js";
|
||||
|
||||
/* =====================================================================
|
||||
LIVE-STATUS
|
||||
@@ -225,6 +238,32 @@ const AUTHED_ROUTES = {
|
||||
"POST /admin/users/update-meta": (req, env, session) => updateUserMeta(req, env, session),
|
||||
|
||||
"POST /admin/audit/list": (req, env, session) => listAuditLog(req, env, session),
|
||||
|
||||
// Supporter-Abo — Administrationsbereich (interne Team-Session + eigene
|
||||
// SUPPORTER_*-Rechte, siehe permissions.js).
|
||||
"POST /admin/overview": (req, env, session) => getAdminOverview(req, env, session),
|
||||
"POST /admin/supporter/list": (req, env, session) => listSupporters(req, env, session),
|
||||
"POST /admin/supporter/detail": (req, env, session) => getSupporterDetail(req, env, session),
|
||||
"POST /admin/supporter/premium/update": (req, env, session) => updatePremium(req, env, session),
|
||||
"POST /admin/supporter/collections/list": (req, env, session) => listCollections(req, env, session),
|
||||
"POST /admin/supporter/collections/upsert": (req, env, session) => upsertCollection(req, env, session),
|
||||
};
|
||||
|
||||
// Routen des ÖFFENTLICHEN Supporter-Bereichs, die eine gültige
|
||||
// Supporter-Session brauchen (eigenes, von AUTHED_ROUTES komplett
|
||||
// getrenntes Token-System — siehe lib/supporter-auth.js). `supporter`
|
||||
// (die volle supporter_users-Zeile) wird injiziert statt `session`.
|
||||
const SUPPORTER_AUTHED_ROUTES = {
|
||||
"POST /supporter/logout": (req, env) => logoutSupporter(req, env),
|
||||
"POST /supporter/me": (req, env, supporter) => getSupporterMe(req, env, supporter),
|
||||
"POST /supporter/update-profile": (req, env, supporter) => updateSupporterProfile(req, env, supporter),
|
||||
"POST /supporter/request-email-change": (req, env, supporter) => requestSupporterEmailChange(req, env, supporter),
|
||||
"POST /supporter/confirm-email-change": (req, env, supporter) => confirmSupporterEmailChange(req, env, supporter),
|
||||
"POST /supporter/redeem-premium": (req, env, supporter) => redeemPremium(req, env, supporter),
|
||||
"POST /supporter/delete-account": (req, env, supporter) => deleteSupporterAccount(req, env, supporter),
|
||||
"POST /supporter/paypal/start": (req, env, supporter) => startPaypalSubscription(req, env, supporter),
|
||||
"POST /supporter/paypal/confirm": (req, env, supporter) => confirmPaypalSubscription(req, env, supporter),
|
||||
"POST /supporter/paypal/cancel": (req, env, supporter) => cancelPaypalSubscription(req, env, supporter),
|
||||
};
|
||||
|
||||
export default {
|
||||
@@ -249,6 +288,23 @@ export default {
|
||||
return login(request, env);
|
||||
}
|
||||
|
||||
// Supporter-Abo — öffentliche Routen ohne Session (Registrierung/Login).
|
||||
if (url.pathname === "/supporter/register" && request.method === "POST") return registerSupporter(request, env);
|
||||
if (url.pathname === "/supporter/verify-email" && request.method === "POST") return verifySupporterEmail(request, env);
|
||||
if (url.pathname === "/supporter/login-request" && request.method === "POST") return requestSupporterLogin(request, env);
|
||||
if (url.pathname === "/supporter/verify-login" && request.method === "POST") return verifySupporterLogin(request, env);
|
||||
if (url.pathname === "/supporter/google-login" && request.method === "POST") return googleLoginSupporter(request, env);
|
||||
if (url.pathname === "/supporter/public-config" && request.method === "GET") return getPublicConfig(request, env);
|
||||
// PayPal ruft diese Adresse direkt auf — eigene Signaturprüfung statt Session (siehe paypal.js).
|
||||
if (url.pathname === "/supporter/paypal-webhook" && request.method === "POST") return handlePaypalWebhook(request, env);
|
||||
|
||||
const supporterHandler = SUPPORTER_AUTHED_ROUTES[routeKey];
|
||||
if (supporterHandler) {
|
||||
const supporter = await requireSupporterSession(request, env);
|
||||
if (!supporter) return json({ ok: false, error: "Sitzung abgelaufen oder ungültig. Bitte erneut einloggen." }, 401);
|
||||
return supporterHandler(request, env, supporter);
|
||||
}
|
||||
|
||||
const handler = AUTHED_ROUTES[routeKey];
|
||||
if (handler) {
|
||||
const session = await requireSession(request, env);
|
||||
|
||||
@@ -11,6 +11,15 @@ compatibility_date = "2024-01-01"
|
||||
[vars]
|
||||
TIKTOK_USERNAME = "dogfather0804"
|
||||
|
||||
# Supporter-Abo (siehe README.md, Abschnitt "Supporter-Abo Setup").
|
||||
# Unkritische Konfiguration — die eigentlichen Geheimnisse/IDs (PAYPAL_CLIENT_ID/
|
||||
# SECRET, PAYPAL_PLAN_ID, PAYPAL_WEBHOOK_ID, RESEND_API_KEY, GOOGLE_CLIENT_ID)
|
||||
# werden NIE hier, sondern ausschließlich per `wrangler secret put` gesetzt —
|
||||
# auch GOOGLE_CLIENT_ID, obwohl er technisch nicht geheim ist (einfach für
|
||||
# einheitliches Setup, siehe README).
|
||||
PAYPAL_ENV = "sandbox"
|
||||
# RESEND_FROM = "Dogfather Team <[email protected]>" # erst setzen, sobald eine eigene Domain existiert
|
||||
|
||||
# Cron: jede Minute bei TikTok nachsehen, ob gerade gesendet wird.
|
||||
[triggers]
|
||||
crons = ["* * * * *"]
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Die Community — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Team Dogi ist Familie — die Community rund um DogFather und HasiDog." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/community.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/community.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Die Community — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="Team Dogi ist Familie — die Community rund um DogFather und HasiDog." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Creator im Team — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Content Creator, die Teil von DogFather / Spicy Media sind." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/creator.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/creator.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Creator im Team — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="Content Creator, die Teil von DogFather / Spicy Media sind." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Die Person, die DogFather erschuf — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Wer ist DogFather wirklich? Persönlichkeit, Werte und die Geschichte hinter dem Content." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/dogfather.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/dogfather.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Die Person, die DogFather erschuf — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="Wer ist DogFather wirklich? Persönlichkeit, Werte und die Geschichte hinter dem Content." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
@@ -45,7 +45,7 @@
|
||||
<div>
|
||||
<h2 data-i18n="df_wer_h2">Wer ist Filipe?</h2>
|
||||
<p class="lead" data-i18n="df_wer_lead">Content Creator, Streamer auf TikTok und das Herz einer wachsenden Community.</p>
|
||||
<a class="btn-tiktok" href="https://www.tiktok.com/@dogfather0804" target="_blank" rel="noopener" style="margin-bottom:1rem;"><span>TikTok</span></a>
|
||||
<a class="btn-tiktok" href="https://www.tiktok.com/@dogfather0804" target="_blank" rel="noopener" style="margin-bottom:1rem;"><span class="btn-tiktok-note" aria-hidden="true">♪</span><span class="btn-tiktok-text">TikTok</span></a>
|
||||
<p data-i18n="df_wer_1">Filipe ist 34 Jahre alt, in Luxemburg geboren und aufgewachsen — mit portugiesischen Wurzeln und dem Temperament, das ihn ausmacht. Sein bisheriger Weg führte ihn durch verschiedene Orte, unterschiedliche Lebensabschnitte und Erfahrungen, die ihn zu dem Menschen gemacht haben, der er heute ist.</p>
|
||||
<p data-i18n="df_wer_2">An seiner Seite: Husky <a href="casper.html">Casper</a> und Kater Simba — treue Begleiter, die längst Teil der Marke geworden sind. Mit seinem Core-Team baut DogFather jeden Tag daran, dass sich Menschen in seiner Community nicht allein fühlen.</p>
|
||||
<!-- Hinweis: Alter, Herkunft und Wohnort sind bewusst öffentlich, da der Nutzer diese Fakten selbst in seinem persönlichen Vorstellungstext (siehe streamer.html) veröffentlicht hat. Weiterhin bewusst NICHT genannt: Nachname und weitere bürgerliche Daten. -->
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/* =====================================================================
|
||||
gate-worker.js — Zugangs-Schranke vor der ganzen statischen Website.
|
||||
|
||||
Warum ein eigener Worker-Einstiegspunkt statt reiner "[assets]"-Auslieferung:
|
||||
Solange die Seite noch nicht öffentlich sein soll (Dogi will weiter daran
|
||||
arbeiten), aber Dogi + VanVan trotzdem jederzeit von überall draufkommen
|
||||
müssen, reicht eine reine statische Auslieferung nicht — es braucht eine
|
||||
Prüfung VOR jeder Anfrage. Cloudflare Access (Zero Trust) wäre die
|
||||
"Enterprise"-Lösung, kostet aber ab einer gewissen Nutzerzahl und bräuchte
|
||||
einen separaten Login-Flow; diese Lösung hier ist funktional gleichwertig
|
||||
für 2 Personen, komplett kostenlos und lebt im selben Repo.
|
||||
|
||||
Ablauf:
|
||||
- Kein/ungültiges Session-Cookie -> Zugangscode-Seite (gate.html) statt
|
||||
der eigentlichen Seite, Zielseite wird als ?next= mitgegeben.
|
||||
- POST /gate-auth mit korrektem Code -> signiertes, HttpOnly-Cookie
|
||||
"dogi_session" (90 Tage) wird gesetzt + ein zweites, NICHT-HttpOnly
|
||||
Cookie "dogi_role" (dogi/vanvan) — rein zur Anzeige des
|
||||
Vorschau-Umschalters im Frontend (main.js), kein Sicherheits-Token.
|
||||
- Gültiges Session-Cookie -> normale Auslieferung über env.ASSETS.
|
||||
|
||||
Die Signatur (HMAC-SHA256 über Web Crypto, in jeder Workers-Runtime
|
||||
eingebaut) verhindert, dass jemand sich einfach selbst ein
|
||||
"dogi_session=irgendwas"-Cookie setzt.
|
||||
===================================================================== */
|
||||
|
||||
const COOKIE_NAME = "dogi_session";
|
||||
const ROLE_COOKIE_NAME = "dogi_role";
|
||||
const SESSION_TAGE = 90;
|
||||
|
||||
function b64urlEncode(bytes) {
|
||||
let bin = "";
|
||||
bytes.forEach((b) => (bin += String.fromCharCode(b)));
|
||||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
function b64urlDecodeToBytes(str) {
|
||||
str = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (str.length % 4) str += "=";
|
||||
const bin = atob(str);
|
||||
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function hmacKey(secret) {
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"]
|
||||
);
|
||||
}
|
||||
|
||||
async function signSession(role, secret) {
|
||||
const payload = JSON.stringify({ role, exp: Date.now() + SESSION_TAGE * 24 * 60 * 60 * 1000 });
|
||||
const payloadB64 = b64urlEncode(new TextEncoder().encode(payload));
|
||||
const key = await hmacKey(secret);
|
||||
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payloadB64));
|
||||
const sigB64 = b64urlEncode(new Uint8Array(sig));
|
||||
return `${payloadB64}.${sigB64}`;
|
||||
}
|
||||
|
||||
async function verifySession(token, secret) {
|
||||
if (!token || !token.includes(".")) return null;
|
||||
const [payloadB64, sigB64] = token.split(".");
|
||||
try {
|
||||
const key = await hmacKey(secret);
|
||||
const valid = await crypto.subtle.verify(
|
||||
"HMAC",
|
||||
key,
|
||||
b64urlDecodeToBytes(sigB64),
|
||||
new TextEncoder().encode(payloadB64)
|
||||
);
|
||||
if (!valid) return null;
|
||||
const payload = JSON.parse(new TextDecoder().decode(b64urlDecodeToBytes(payloadB64)));
|
||||
if (!payload.exp || payload.exp < Date.now()) return null;
|
||||
return payload; // { role, exp }
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getCookie(request, name) {
|
||||
const header = request.headers.get("Cookie") || "";
|
||||
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
/* html_handling = "none" (wrangler.toml) liefert NUR exakt passende
|
||||
Dateipfade aus — verhindert zwar die Redirect-Schleife zwischen
|
||||
/gate.html und /gate (siehe Kommentar in wrangler.toml), heißt aber
|
||||
auch: der bloße Wurzelpfad "/" wird NICHT mehr automatisch auf
|
||||
"/index.html" abgebildet (es gibt ja keine Datei namens "/"). Ohne
|
||||
diesen Ausgleich hier landete man nach erfolgreichem Login auf einer
|
||||
404-Seite, weil sowohl der Standard-"next"-Wert als auch direkte
|
||||
Aufrufe der nackten Domain auf "/" zeigen. Deshalb: "/" bei der
|
||||
Auslieferung IMMER auf "/index.html" umbiegen. */
|
||||
function assetsFetch(request, env) {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/") {
|
||||
url.pathname = "/index.html";
|
||||
request = new Request(url, request);
|
||||
}
|
||||
return env.ASSETS.fetch(request);
|
||||
}
|
||||
|
||||
async function handleAuth(request, env) {
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ ok: false, error: "Ungültige Anfrage." }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
const code = (body.code || "").trim();
|
||||
// "/" wird HIER schon aussortiert (nicht erst in assetsFetch weiter unten
|
||||
// abgefangen) — Grund: ein bereits offener, veralteter gate.html-Tab
|
||||
// (z.B. mit "?next=%2F" von vor diesem Fix in der Adresszeile) würde
|
||||
// sonst weiterhin exakt "/" an den Server schicken und dieser hätte es
|
||||
// unverändert durchgereicht ("/" beginnt ja mit "/", bestand also den
|
||||
// alten Check). Das war der eigentliche Rest-Bug, wieso die 404-Seite
|
||||
// nach dem Einloggen trotz der vorherigen Korrektur weiter auftauchte:
|
||||
// der Server war nicht wirklich die "Quelle der Wahrheit", sondern hat
|
||||
// dem Client teilweise vertraut. Jetzt: "/" gilt serverseitig IMMER als
|
||||
// ungültiges Ziel, unabhängig davon, was der (evtl. veraltete) Client
|
||||
// schickt.
|
||||
const next =
|
||||
typeof body.next === "string" && body.next.startsWith("/") && body.next !== "/"
|
||||
? body.next
|
||||
: "/index.html";
|
||||
|
||||
let role = null;
|
||||
if (env.SITE_ACCESS_CODE_DOGI && code === env.SITE_ACCESS_CODE_DOGI) role = "dogi";
|
||||
else if (env.SITE_ACCESS_CODE_VANVAN && code === env.SITE_ACCESS_CODE_VANVAN) role = "vanvan";
|
||||
|
||||
if (!role) {
|
||||
return new Response(JSON.stringify({ ok: false, error: "Falscher Zugangscode." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const session = await signSession(role, env.SITE_ACCESS_SECRET);
|
||||
const maxAge = SESSION_TAGE * 24 * 60 * 60;
|
||||
const headers = new Headers({ "Content-Type": "application/json" });
|
||||
headers.append(
|
||||
"Set-Cookie",
|
||||
`${COOKIE_NAME}=${encodeURIComponent(session)}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`
|
||||
);
|
||||
headers.append(
|
||||
"Set-Cookie",
|
||||
`${ROLE_COOKIE_NAME}=${role}; Path=/; Max-Age=${maxAge}; Secure; SameSite=Lax`
|
||||
);
|
||||
return new Response(JSON.stringify({ ok: true, next }), { status: 200, headers });
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Fail-open-Absicherung umgekehrt: fehlen die Zugangs-Secrets (z.B. noch
|
||||
// nicht gesetzt), lieber die Schranke NICHT scharf schalten, als Dogi
|
||||
// selbst auszusperren, während er live daran arbeitet.
|
||||
if (!env.SITE_ACCESS_SECRET || (!env.SITE_ACCESS_CODE_DOGI && !env.SITE_ACCESS_CODE_VANVAN)) {
|
||||
return assetsFetch(request, env);
|
||||
}
|
||||
|
||||
if (url.pathname === "/gate-auth" && request.method === "POST") {
|
||||
return handleAuth(request, env);
|
||||
}
|
||||
|
||||
const sessionToken = getCookie(request, COOKIE_NAME);
|
||||
const payload = sessionToken ? await verifySession(sessionToken, env.SITE_ACCESS_SECRET) : null;
|
||||
|
||||
if (payload) {
|
||||
return assetsFetch(request, env);
|
||||
}
|
||||
|
||||
// gate.html selbst + seine Assets (CSS/Bilder) müssen ohne Session
|
||||
// ladbar sein, sonst kommt niemand überhaupt bis zur Eingabemaske.
|
||||
// /assets/ generell mit freizugeben ist bewusst in Kauf genommen (reines
|
||||
// Design-CSS/JS, kein Inhalt) — hält gate.html im selben Look wie die
|
||||
// restliche Seite, ohne alles inline duplizieren zu müssen.
|
||||
// manifest.json/sw.js/Icons ebenfalls IMMER freigeben (04.08.2026-Fix):
|
||||
// der Browser fragt diese rein technischen PWA-Dateien manchmal in
|
||||
// einem Kontext ab, der kein Session-Cookie mitschickt — landete dann
|
||||
// in der Login-Weiterleitung statt echtem JSON ("Manifest: Syntax
|
||||
// error" in der Konsole, Icons/PWA-Installierbarkeit kaputt). Kein
|
||||
// schützenswerter Inhalt, nur Technik.
|
||||
if (
|
||||
url.pathname === "/gate.html" ||
|
||||
url.pathname.startsWith("/assets/") ||
|
||||
url.pathname === "/manifest.json" ||
|
||||
url.pathname === "/sw.js" ||
|
||||
url.pathname === "/favicon.ico"
|
||||
) {
|
||||
return assetsFetch(request, env);
|
||||
}
|
||||
|
||||
// "/" selbst soll ebenfalls zu gate.html führen, aber als spätere
|
||||
// Zielseite "/index.html" mitgeben (siehe next-Kommentar oben) statt
|
||||
// des nicht auflösbaren "/".
|
||||
const next = url.pathname === "/" ? "/index.html" : url.pathname + url.search;
|
||||
return Response.redirect(`${url.origin}/gate.html?next=${encodeURIComponent(next)}`, 302);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="dogfather">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Zugang — DOGFATHER UNIVERSE</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="icon" type="image/png" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<style>
|
||||
/* =====================================================================
|
||||
Zugangsseite — bewusst kein braves Standard-Login-Formular, sondern
|
||||
die volle Bühne für das Logo (Nutzer-Wunsch 03.08.2026: "übelst geil",
|
||||
"überrasch mich", Logo verwenden). Reines CSS, keine zusätzlichen
|
||||
Bild-Assets nötig — nur das vorhandene Logo + prozedural erzeugte
|
||||
Sterne/Funken (siehe Skript unten, gleiche Machart wie der
|
||||
Nebel-Effekt in main.js).
|
||||
|
||||
Lektion aus diesem Projekt beachtet: rotierende Ringe verzerren sich
|
||||
auf nicht-runden Formen (siehe TikTok-Button-Lektion) — hier deshalb
|
||||
bewusst KEIN rotierender Ring um das (unregelmäßig geformte) Logo,
|
||||
stattdessen nur eine ruhig pulsierende Glut dahinter. overflow:hidden
|
||||
am Body sorgt dafür, dass nichts über den Rand hinausblutet.
|
||||
===================================================================== */
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-height: 100vh; padding: 2rem 1.2rem; position: relative;
|
||||
overflow: hidden; background: radial-gradient(ellipse at 50% 30%, #12161d 0%, #06070a 62%, #030304 100%);
|
||||
}
|
||||
|
||||
/* ---------- Aurora-Nebel, langsam driftend (3 Farbtöne der Marke) ---------- */
|
||||
.gate-aurora { position: fixed; inset: -10%; z-index: 0; pointer-events: none; filter: blur(2px); }
|
||||
.gate-aurora span { position: absolute; border-radius: 50%; opacity: .5; filter: blur(70px); mix-blend-mode: screen; }
|
||||
.gate-aurora .a1 { width: 55vw; height: 55vw; left: -10%; top: -8%; background: radial-gradient(circle, rgba(143,217,234,.55), transparent 70%); animation: gate-drift-1 26s ease-in-out infinite; }
|
||||
.gate-aurora .a2 { width: 50vw; height: 50vw; right: -12%; top: 8%; background: radial-gradient(circle, rgba(139,92,246,.5), transparent 70%); animation: gate-drift-2 32s ease-in-out infinite; }
|
||||
.gate-aurora .a3 { width: 46vw; height: 46vw; left: 20%; bottom: -18%; background: radial-gradient(circle, rgba(212,175,55,.4), transparent 70%); animation: gate-drift-3 38s ease-in-out infinite; }
|
||||
@keyframes gate-drift-1 { 0%,100% { transform: translate(0,0) scale(1); } 50% { transform: translate(6vw,4vh) scale(1.12); } }
|
||||
@keyframes gate-drift-2 { 0%,100% { transform: translate(0,0) scale(1); } 50% { transform: translate(-5vw,5vh) scale(1.08); } }
|
||||
@keyframes gate-drift-3 { 0%,100% { transform: translate(0,0) scale(1); } 50% { transform: translate(4vw,-4vh) scale(1.1); } }
|
||||
@media (prefers-reduced-motion: reduce) { .gate-aurora span { animation: none; } }
|
||||
|
||||
/* ---------- Sternenstaub/Funken (per JS positioniert, siehe unten) ---------- */
|
||||
.gate-funken { position: fixed; inset: 0; z-index: 1; pointer-events: none; }
|
||||
.gate-funken span {
|
||||
position: absolute; width: 3px; height: 3px; border-radius: 50%;
|
||||
background: #ffe9a8; box-shadow: 0 0 6px 1px rgba(255,233,168,.9);
|
||||
animation: gate-funke-twinkle 3.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes gate-funke-twinkle { 0%,100% { opacity: .15; transform: scale(.7); } 50% { opacity: 1; transform: scale(1.3); } }
|
||||
@media (prefers-reduced-motion: reduce) { .gate-funken span { animation: none; opacity: .5; } }
|
||||
|
||||
/* ---------- Bühne: Logo + Karte übereinander ----------
|
||||
Nutzer-Feedback 03.08.2026: das Logo sollte nicht wie ein Sticker in
|
||||
einer weißen Kreisscheibe auf dem Hintergrund "kleben", sondern direkt
|
||||
IM Hintergrund stecken. Grund für den weißen Kreis vorher: die
|
||||
Original-PNG hatte selbst einen weißen Hintergrund eingebrannt (kein
|
||||
Alphakanal). Freigestellt (Rand-Flood-Fill in Python/Pillow, nur die
|
||||
mit dem Bildrand verbundene Fläche wurde transparent gemacht — innen-
|
||||
liegende helle Stellen wie Augen-Glanzlicht blieben unangetastet) nach
|
||||
logo-dogfather-transparent.png. Dadurch kein Kreis-Container/Ring mehr
|
||||
nötig — nur noch eine weiche Glut dahinter, die durch die jetzt echten
|
||||
Konturen des Hunde-Kopfs hindurch scheint. */
|
||||
.gate-stage { position: relative; z-index: 2; display: flex; flex-direction: column; align-items: center; width: 100%; max-width: 440px; }
|
||||
|
||||
.gate-logo-wrap {
|
||||
position: relative; width: 260px; margin-bottom: -34px; z-index: 3;
|
||||
animation: gate-logo-float 6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes gate-logo-float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
|
||||
@media (prefers-reduced-motion: reduce) { .gate-logo-wrap { animation: none; } }
|
||||
|
||||
/* Pulsierende Glut hinter der freigestellten Form (rund, aber sitzt HINTER
|
||||
dem Logo statt es einzurahmen — scheint an den Konturen vorbei durch). */
|
||||
.gate-logo-glow {
|
||||
position: absolute; inset: -22%; border-radius: 50%; z-index: -1;
|
||||
background: radial-gradient(circle, rgba(255,233,168,.65) 0%, rgba(143,217,234,.35) 40%, rgba(139,92,246,.2) 62%, transparent 76%);
|
||||
filter: blur(26px); animation: gate-glow-puls 3.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes gate-glow-puls { 0%,100% { opacity: .6; transform: scale(.92); } 50% { opacity: 1; transform: scale(1.05); } }
|
||||
|
||||
.gate-logo-img {
|
||||
position: relative; display: block; width: 100%; height: auto;
|
||||
filter: drop-shadow(0 0 18px rgba(255,233,168,.5)) drop-shadow(0 22px 34px rgba(0,0,0,.65));
|
||||
}
|
||||
|
||||
/* ---------- Die eigentliche Karte ---------- */
|
||||
.gate-box {
|
||||
position: relative; width: 100%; text-align: center;
|
||||
padding: 4.2rem 2rem 2.4rem;
|
||||
background: linear-gradient(180deg, rgba(20,23,29,.92), rgba(10,12,15,.96));
|
||||
backdrop-filter: blur(14px);
|
||||
border: 1.5px solid rgba(212,175,55,.4);
|
||||
box-shadow: 0 0 0 1px rgba(212,175,55,.06) inset, 0 40px 80px -30px rgba(0,0,0,.75);
|
||||
overflow: hidden;
|
||||
}
|
||||
.gate-box::before {
|
||||
content: ""; position: absolute; top: 0; left: 8%; right: 8%; height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,214,102,.95), transparent);
|
||||
animation: gate-goldglanz 4.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes gate-goldglanz { 0%,100% { opacity: .3; } 50% { opacity: 1; } }
|
||||
|
||||
.gate-box .eyebrow { color: #ffe9a8; border-color: rgba(255,214,102,.5); }
|
||||
.gate-box h1 {
|
||||
font-size: clamp(1.6rem, 4vw, 2.1rem); margin: .5rem 0 .7rem; letter-spacing: -.01em;
|
||||
background: linear-gradient(135deg, #fff, #ffe9a8 55%, #d4af37);
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
}
|
||||
.gate-box p { font-size: 1rem; }
|
||||
.gate-box input {
|
||||
width: 100%; box-sizing: border-box; padding: 1rem 1.1rem; border-radius: 12px; text-align: center;
|
||||
letter-spacing: .1em; font-size: 1.1rem; font-weight: 600; margin: 1.4rem 0 1rem;
|
||||
background: rgba(255,255,255,.04); border: 1.5px solid var(--border); color: var(--text);
|
||||
transition: border-color .15s ease, box-shadow .15s ease, background .15s ease;
|
||||
}
|
||||
.gate-box input::placeholder { letter-spacing: normal; font-weight: 400; color: rgba(154,164,178,.55); }
|
||||
.gate-box input:focus {
|
||||
outline: none; border-color: rgba(212,175,55,.7); background: rgba(255,255,255,.06);
|
||||
box-shadow: 0 0 0 4px rgba(212,175,55,.14);
|
||||
}
|
||||
/* "Weiter"-Knopf: der schon fertig gebaute, aber bislang ungenutzte
|
||||
.btn-holo-Stil (main.css) — Trading-Card-Holo-Folie in genau den drei
|
||||
Farben, die auch schon den Hintergrund dieser Seite tragen (Babyblau/
|
||||
Lila/Gold, siehe .gate-aurora), daher hier bewusst statt des
|
||||
generischen .btn-primary. Volle Breite + zentrierter Text sind eine
|
||||
seiteneigene Ergänzung (der Stil selbst ist an anderer Stelle als
|
||||
Inline-Button mit automatischer Breite gedacht). */
|
||||
.gate-box .btn-holo { width: 100%; justify-content: center; margin-top: .2rem; }
|
||||
.gate-fehler { color: #ff9a9a; font-size: .9rem; margin-top: .9rem; display: none; }
|
||||
.gate-fehler.zeige { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="gate-aurora" aria-hidden="true"><span class="a1"></span><span class="a2"></span><span class="a3"></span></div>
|
||||
<div class="gate-funken" id="gate-funken" aria-hidden="true"></div>
|
||||
|
||||
<div class="gate-stage">
|
||||
<div class="gate-logo-wrap" aria-hidden="true">
|
||||
<div class="gate-logo-glow"></div>
|
||||
<img class="gate-logo-img" src="assets/img/logo-dogfather-transparent.png" alt="" width="472" height="476" />
|
||||
</div>
|
||||
|
||||
<div class="card gate-box">
|
||||
<!-- Nutzer-Wunsch 04.08.2026: Besucher ohne Zugangscode (z.B. über einen
|
||||
gefundenen/geteilten Link) sollen HIER nicht mehr "Noch nicht
|
||||
öffentlich / im Aufbau" lesen — stattdessen ein normal wirkender
|
||||
Link auf die eigene Domain (die spätere, öffentliche Hauptseite).
|
||||
Das Eingabefeld darunter bleibt unverändert für Dogi/VanVan. -->
|
||||
<h1>DOGFATHER UNIVERSE</h1>
|
||||
<p class="small" style="opacity:.85;">
|
||||
<a href="https://dogfather-universe.com/" style="color:#8fd9ea; font-weight:700; text-decoration: underline; text-underline-offset: .2em;">dogfather-universe.com</a>
|
||||
</p>
|
||||
<form id="gate-form">
|
||||
<input type="text" id="gate-code" placeholder="Zugangscode" autocomplete="off" autocapitalize="off" spellcheck="false" autofocus />
|
||||
<button type="submit" class="btn-holo">
|
||||
<span class="btn-holo-spark" aria-hidden="true">✨</span>
|
||||
<span class="btn-holo-text">Weiter</span>
|
||||
<span class="btn-holo-spark" aria-hidden="true">✨</span>
|
||||
</button>
|
||||
</form>
|
||||
<p class="gate-fehler" id="gate-fehler"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// Sternenstaub — gleiche Machart wie der Nebel-Effekt in main.js
|
||||
// (renderFog): zufällig positionierte, unterschiedlich große/verzögerte
|
||||
// Punkte statt einer festen Deko.
|
||||
const feld = document.getElementById("gate-funken");
|
||||
let html = "";
|
||||
for (let i = 0; i < 44; i++) {
|
||||
const size = 2 + Math.round(Math.random() * 3);
|
||||
const top = Math.round(Math.random() * 100);
|
||||
const left = Math.round(Math.random() * 100);
|
||||
const delay = (Math.random() * 3.5).toFixed(1);
|
||||
const dauer = (2.5 + Math.random() * 2.5).toFixed(1);
|
||||
html += `<span style="width:${size}px;height:${size}px;top:${top}%;left:${left}%;animation-delay:${delay}s;animation-duration:${dauer}s;"></span>`;
|
||||
}
|
||||
feld.innerHTML = html;
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const next = params.get("next") || "/index.html";
|
||||
const form = document.getElementById("gate-form");
|
||||
const fehlerEl = document.getElementById("gate-fehler");
|
||||
form.addEventListener("submit", async function (e) {
|
||||
e.preventDefault();
|
||||
fehlerEl.classList.remove("zeige");
|
||||
const code = document.getElementById("gate-code").value.trim();
|
||||
if (!code) return;
|
||||
try {
|
||||
const res = await fetch("/gate-auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code, next }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) {
|
||||
fehlerEl.textContent = (data && data.error) || "Falscher Zugangscode.";
|
||||
fehlerEl.classList.add("zeige");
|
||||
return;
|
||||
}
|
||||
location.href = data.next || "/index.html";
|
||||
} catch {
|
||||
fehlerEl.textContent = "Verbindung fehlgeschlagen. Bitte nochmal versuchen.";
|
||||
fehlerEl.classList.add("zeige");
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -12,12 +12,12 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="HasiDog-Welt — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="HasiDog: Maskottchen, Musikprojekt und die verspielte, familiäre Seite von Team Dogi." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/hasidog.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/hasidog.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="HasiDog-Welt — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="HasiDog: Maskottchen, Musikprojekt und die verspielte, familiäre Seite von Team Dogi." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
@@ -46,7 +46,7 @@
|
||||
<h2 data-i18n="hd_was_h2">Wer ist HasiDog?</h2>
|
||||
<p data-i18n="hd_was_1">HasiDog ist mehr als ein Spitzname — es ist die verspielte, familiäre Marke rund um DogFathers Community. Unter dem Namen „Hasidog" erscheint auch die eigene Musik auf <a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">Spotify</a>.</p>
|
||||
<div class="btn-row">
|
||||
<a class="btn-legendary" href="streamer.html"><span class="btn-legendary-spark" aria-hidden="true">✨</span><span class="btn-legendary-text" data-i18n="hd_btn_erschaffer">Erschaffer</span><span class="btn-legendary-spark" aria-hidden="true">✨</span></a>
|
||||
<a class="btn-holo" href="streamer.html"><span class="btn-holo-spark" aria-hidden="true">💎</span><span class="btn-holo-text" data-i18n="hd_btn_erschaffer">Erschaffer</span><span class="btn-holo-spark" aria-hidden="true">💎</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="DOGFATHER UNIVERSE — Streamer. Community. HasiDog. Manager." />
|
||||
<meta property="og:description" content="DOGFATHER UNIVERSE: die Heimat von DogFather als Streamer, der HasiDog-Community und Manager DogFather bei Spicy Media." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/index.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/index.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="DOGFATHER UNIVERSE — Streamer. Community. HasiDog. Manager." />
|
||||
<meta name="twitter:description" content="DOGFATHER UNIVERSE: die Heimat von DogFather als Streamer, der HasiDog-Community und Manager DogFather bei Spicy Media." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
@@ -179,7 +179,7 @@
|
||||
<section class="section-tight team-bg">
|
||||
<div class="container">
|
||||
<h2 class="text-center" data-i18n="idx_team_h2">Team Dogi 🩵</h2>
|
||||
<p class="lead text-center" data-i18n="idx_team_lead" style="max-width:60ch;margin:0 auto;">Von VanVan & die Modis — die Menschen, die DOGFATHER UNIVERSE tragen.</p>
|
||||
<p class="lead text-center" data-i18n="idx_team_lead" style="max-width:60ch;margin:0 auto;">VanVan & Modis — die Menschen, die DOGFATHER UNIVERSE tragen.</p>
|
||||
<div id="team-preview" class="grid grid-4"></div>
|
||||
<div class="btn-row" style="justify-content:center;">
|
||||
<a class="btn btn-outline" href="team-modis.html" data-i18n="idx_team_btn_modi">Modi-Team ansehen</a>
|
||||
@@ -251,9 +251,9 @@
|
||||
<script src="assets/js/i18n-index.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script>
|
||||
// Gleiche Worker-URL wie in assets/js/forms.js / postfach.html eintragen,
|
||||
// sobald der Cloudflare Worker deployed ist.
|
||||
const API_BASE_URL = "https://dogfather-universe-postfach.dogfather1608.workers.dev";
|
||||
// Gleiche URL wie in assets/js/forms.js / postfach.html — läuft seit
|
||||
// 05.08.2026 auf dem eigenen Server statt auf Cloudflare Workers.
|
||||
const API_BASE_URL = "https://postfach.dogfather-universe.com";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Foto-Collage: solange echte Fotos fehlen, hübschen Platzhalter statt
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Kontakt & Rechtliches — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Kontakt, Impressum und Datenschutz von DOGFATHER UNIVERSE." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/kontakt.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/kontakt.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Kontakt & Rechtliches — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="Kontakt, Impressum und Datenschutz von DOGFATHER UNIVERSE." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
@@ -40,8 +40,7 @@
|
||||
<div class="card">
|
||||
<h3 data-i18n="kt_kontakt_h3">Kontakt</h3>
|
||||
<p data-i18n="kt_kontakt_p">Für Anfragen zu Kooperationen, Presse oder allgemeine Fragen:</p>
|
||||
<p><a href="mailto:kontakt@dogfather-universe.example">[email protected]</a></p>
|
||||
<!-- TODO: echte Kontakt-E-Mail eintragen (auch in assets/js/forms.js -> CONTACT_EMAIL anpassen). -->
|
||||
<p><a href="mailto:dogfather[email protected]">[email protected]</a></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 data-i18n="kt_social_h3">Social</h3>
|
||||
@@ -53,13 +52,16 @@
|
||||
<section>
|
||||
<div class="container">
|
||||
<h2 data-i18n="kt_impressum_h2">Impressum</h2>
|
||||
<!-- TODO: WICHTIG — offene rechtliche Frage: Ein Impressum ist in Deutschland/EU für geschäftlich genutzte Websites gesetzlich vorgeschrieben (§5 TMG bzw. Nachfolgeregelungen) und verlangt Angaben wie Name, ladungsfähige Anschrift und Kontakt des Betreibers. Das steht im Spannungsfeld zur Entscheidung „nur Persona-Name, keine echten bürgerlichen Daten öffentlich". Mögliche Lösungswege (mit Dogi klären, bevor die Seite live geht): 1) Impressum über die Spicy-Media-Firmendaten statt Privatperson, 2) rechtliche Beratung einholen, welche Mindestangaben nötig sind, 3) vorerst nur lokal/offline nutzen, bis das geklärt ist. -->
|
||||
<p class="small muted" data-i18n="kt_impressum_p">
|
||||
Angaben gemäß § 5 TMG<br>
|
||||
[Firmenname / Betreiber]<br>
|
||||
[Anschrift]<br>
|
||||
[Kontakt]<br>
|
||||
Vertreten durch: [Name]
|
||||
<p class="small muted" data-i18n="kt_impressum_hinweis">Rechtstexte werden auf Deutsch bereitgestellt, um Übersetzungsfehler bei rechtlich verbindlichen Angaben zu vermeiden. / Legal texts are provided in German only to avoid translation errors in legally binding information.</p>
|
||||
<p class="small muted">
|
||||
Angaben gemäß Anbieterkennzeichnung (u.a. § 5 TMG bzw. vergleichbare Vorschriften)<br>
|
||||
Filipe Pereira Queiroz<br>
|
||||
3, rue Arthur Thinnes<br>
|
||||
L-3919 Mondercange<br>
|
||||
Luxemburg<br><br>
|
||||
Kontakt: <a href="mailto:[email protected]">[email protected]</a><br><br>
|
||||
Verantwortlich für den Inhalt (auch i.S.v. § 55 Abs. 2 RStV bzw. vergleichbarer Vorschriften): Filipe Pereira Queiroz, Anschrift wie oben.<br><br>
|
||||
DOGFATHER UNIVERSE ist ein persönliches Content-/Community-Projekt von Filipe Pereira Queiroz (Künstlername „Dogfather"). Es besteht keine Verbindung dieser Angaben zu Namen weiterer auf der Seite gezeigter Personen (z. B. Team-/Community-Mitglieder), die ausschließlich mit ihren Spitz-/Künstlernamen auftreten.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -67,8 +69,54 @@
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<h2 data-i18n="kt_datenschutz_h2">Datenschutz</h2>
|
||||
<p data-i18n="kt_datenschutz_p">Diese Website wird aktuell lokal/offline entwickelt und ist noch nicht öffentlich gehostet. Sobald ein Hosting-Anbieter feststeht und Formulare (z.B. Bewerbung) echte Daten versenden, wird hier eine vollständige Datenschutzerklärung ergänzt (Verarbeitungszwecke, Rechtsgrundlage, Speicherdauer, Empfänger, Rechte der Nutzer:innen).</p>
|
||||
<!-- TODO: Datenschutzerklärung final ausformulieren, sobald Hosting/Formular-Backend feststehen. -->
|
||||
<div class="small muted" style="line-height:1.7;">
|
||||
<p><strong>1. Verantwortlicher</strong><br>
|
||||
Verantwortlich für die Datenverarbeitung auf dieser Website ist:<br>
|
||||
Filipe Pereira Queiroz, 3, rue Arthur Thinnes, L-3919 Mondercange, Luxemburg — <a href="mailto:[email protected]">[email protected]</a></p>
|
||||
|
||||
<p><strong>2. Hosting & Server-Logdaten</strong><br>
|
||||
Diese Website wird über Cloudflare (Cloudflare, Inc., 101 Townsend St, San Francisco, USA; für EU-Nutzer:innen ggf. über Cloudflare-Rechenzentren in der EU) bereitgestellt. Beim Aufruf der Seite verarbeitet Cloudflare technisch notwendige Daten (u. a. IP-Adresse, Zeitstempel, aufgerufene URL, User-Agent) zur Bereitstellung, Absicherung (z. B. DDoS-Schutz) und Fehlerbehebung. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an sicherem, funktionsfähigem Betrieb). Näheres: <a href="https://www.cloudflare.com/privacypolicy/" target="_blank" rel="noopener">cloudflare.com/privacypolicy</a>.</p>
|
||||
|
||||
<p><strong>3. Cookies & lokaler Speicher</strong><br>
|
||||
Diese Website nutzt derzeit keine Marketing- oder Tracking-Cookies. Eingesetzt werden ausschließlich technisch notwendige Cookies bzw. Speicherungen im Browser:</p>
|
||||
<ul>
|
||||
<li><code>dogi_session</code> — signiertes, verschlüsseltes Sitzungs-Cookie für den Zugangsschutz der Seite (HttpOnly, 90 Tage), Rechtsgrundlage Art. 6 Abs. 1 lit. f DSGVO.</li>
|
||||
<li><code>dogi_role</code> — nicht-technisches Rollen-Cookie (z. B. Vorschau-Ansicht), rein lokal zur Steuerung der Anzeige.</li>
|
||||
<li>Lokaler Browser-Speicher (localStorage/sessionStorage) für Login-Sitzungen im Supporter-/DogiCrew-Bereich sowie im internen Verwaltungsbereich — verbleibt ausschließlich auf deinem Gerät und wird nicht an Dritte weitergegeben.</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>4. „Weiter mit Google" (Google Sign-In)</strong><br>
|
||||
Im DogiCrew-/Supporter-Bereich kann optional die Anmeldung über Google genutzt werden. Dabei findet eine Datenübertragung an Google Ireland Limited (Gordon House, Barrow Street, Dublin 4, Irland) statt. Es werden nur Name, E-Mail-Adresse und Profilbild übermittelt, um ein Konto zu erstellen bzw. zuzuordnen. Rechtsgrundlage ist deine Einwilligung (Art. 6 Abs. 1 lit. a DSGVO) durch aktive Nutzung des Buttons. Näheres: <a href="https://policies.google.com/privacy" target="_blank" rel="noopener">policies.google.com/privacy</a>.</p>
|
||||
|
||||
<p><strong>5. Zahlungsabwicklung (PayPal)</strong><br>
|
||||
Zahlungen für das DogiCrew-Supporter-Abo können über PayPal abgewickelt werden (PayPal (Europe) S.à r.l. et Cie, S.C.A., 22-24 Boulevard Royal, L-2449 Luxemburg). Dabei übermittelt PayPal die zur Zahlungsabwicklung notwendigen Daten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung). Näheres: <a href="https://www.paypal.com/de/webapps/mpp/ua/privacy-full" target="_blank" rel="noopener">paypal.com/privacy</a>.</p>
|
||||
|
||||
<p><strong>6. Bewerbungsformular</strong><br>
|
||||
Wer sich über ein Bewerbungsformular auf dieser Seite meldet, übermittelt die dort freiwillig angegebenen Daten (z. B. Name/Spitzname, Kontaktmöglichkeit, Nachricht). Diese werden ausschließlich zur Bearbeitung der Bewerbung genutzt und in einer Cloudflare-D1-Datenbank gespeichert, Zugriff haben nur berechtigte Teammitglieder. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b bzw. f DSGVO.</p>
|
||||
|
||||
<p><strong>7. DogiCrew-/Supporter-Registrierung</strong><br>
|
||||
Bei der Registrierung als Supporter:in werden Name/Anzeigename, TikTok-Benutzername, E-Mail-Adresse, ein Passwort (nur als Hash gespeichert, im Klartext nicht einsehbar) sowie Abo-/Zahlungsstatus gespeichert, um den Zugang zum Supporter-Bereich zu verwalten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung). Ein eigenständiges Löschen des Kontos ist im Supporter-Bereich jederzeit selbst möglich. Beim Abschluss des kostenpflichtigen Abos wird zusätzlich die ausdrückliche Zustimmung zum sofortigen Leistungsbeginn samt Erlöschen des Widerrufsrechts (siehe <a href="agb.html">AGB</a>, Ziffer 6) mit Zeitstempel gespeichert, um dies im Streitfall nachweisen zu können.</p>
|
||||
|
||||
<p><strong>7a. Versandadresse für physische Zusatzprämien</strong><br>
|
||||
Wer im Supporter-Bereich eine physische Zusatzprämie (z. B. Teddy, Hoodie) einlösen möchte, kann dort freiwillig Name, Straße, PLZ, Ort und Land als Versandadresse hinterlegen. Diese Daten werden ausschließlich zur Abwicklung des Versands genutzt und optional (auf eigenen Wunsch) für künftige Prämien gespeichert; sie können jederzeit im Supporter-Bereich geändert oder gelöscht werden. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung).</p>
|
||||
|
||||
<p><strong>8. Kontaktaufnahme per E-Mail</strong><br>
|
||||
Bei Kontakt per E-Mail werden deine Angaben zur Bearbeitung der Anfrage gespeichert. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der Beantwortung von Anfragen).</p>
|
||||
|
||||
<p><strong>9. Speicherdauer</strong><br>
|
||||
Daten werden nur so lange gespeichert, wie es für den jeweiligen Zweck erforderlich ist bzw. gesetzliche Aufbewahrungspflichten bestehen. Danach werden sie gelöscht.</p>
|
||||
|
||||
<p><strong>10. Deine Rechte</strong><br>
|
||||
Du hast das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18), Datenübertragbarkeit (Art. 20) sowie Widerspruch (Art. 21) gegen die Verarbeitung deiner Daten. Wende dich dazu an <a href="mailto:[email protected]">[email protected]</a>. Außerdem besteht ein Beschwerderecht bei einer Datenschutz-Aufsichtsbehörde, z. B. der luxemburgischen Commission Nationale pour la Protection des Données (CNPD).</p>
|
||||
|
||||
<p><strong>11. Keine automatisierte Entscheidungsfindung</strong><br>
|
||||
Es findet keine automatisierte Entscheidungsfindung oder Profiling im Sinne von Art. 22 DSGVO statt.</p>
|
||||
|
||||
<p><strong>12. Änderungen</strong><br>
|
||||
Diese Datenschutzerklärung wird bei Bedarf angepasst, wenn sich die Website oder Rechtslage ändert. Stand: August 2026.</p>
|
||||
|
||||
<p class="small muted" style="margin-top:1.5rem;"><em>Hinweis: Dieser Text wurde nach bestem Wissen und aktuellem Stand der Website erstellt, ersetzt aber keine individuelle Rechtsberatung.</em></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -12,17 +12,39 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Alle Links — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Alle Social-Media- und Musik-Links von DogFather an einem Ort — plus QR-Codes." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/links.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/links.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Alle Links — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="Alle Social-Media- und Musik-Links von DogFather an einem Ort — plus QR-Codes." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<style>
|
||||
/* Rabattcodes: an das Supporter-Abo gekoppelt (Nutzer-Feedback
|
||||
03.08.2026: "verbinde es mit dem Abo... wenn die Leute drauf drücken
|
||||
wollen es nicht geht"). Eigener Knopf bleibt bestehen (führt weiter
|
||||
eigenständig zu rabattcodes.html), wird aber nur für Supporter
|
||||
tatsächlich anklickbar — sonst erscheint ein Hinweis mit Link zum Abo
|
||||
statt der Navigation. Prüfung ist bewusst rein clientseitig (derselbe
|
||||
zwischengespeicherte Login-Stand wie beim Nav-Abo-Knopf, siehe
|
||||
renderAboButton() in main.js) — kein Aufruf-Zwang gegen den Worker bei
|
||||
jedem Seitenaufruf, und da hier (noch) keine echten Rabattcodes
|
||||
angezeigt werden, ist eine reine Client-Sperre für den jetzigen Stand
|
||||
ausreichend statt eines vollen Server-Gates. */
|
||||
.card-rabatt.gesperrt { cursor: not-allowed; }
|
||||
.card-rabatt.gesperrt:hover { transform: none; }
|
||||
.rabatt-gesperrt-hinweis {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
|
||||
max-width: 1040px; margin: 1rem auto 0; padding: 1rem 1.4rem; border-radius: 12px;
|
||||
border: 1.5px solid rgba(212,175,55,.4); background: rgba(212,175,55,.08); color: #ffe9a8;
|
||||
}
|
||||
.rabatt-gesperrt-hinweis[hidden] { display: none; }
|
||||
.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');">
|
||||
<div id="site-header"></div>
|
||||
@@ -37,6 +59,26 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight" style="padding-bottom:0;">
|
||||
<div class="container">
|
||||
<div style="max-width:1040px;margin:0 auto;">
|
||||
<a class="card card-brand card-feature card-feature-wide card-rabatt" href="rabattcodes.html" id="card-rabattcodes">
|
||||
<span class="card-feature-ring" aria-hidden="true"></span>
|
||||
<span class="card-feature-badge" id="rabatt-badge" data-i18n="lk_rabatt_badge">🔒 Nur für Supporter</span>
|
||||
<span class="icon-badge">🏷️</span>
|
||||
<span class="card-feature-text">
|
||||
<h3 data-i18n="lk_rabatt_h3">Rabattcodes</h3>
|
||||
<p class="card-feature-desc" data-i18n="lk_rabatt_desc">Codes von Dogis Partnern — exklusiv für Dogfather-Supporter.</p>
|
||||
</span>
|
||||
</a>
|
||||
<div class="rabatt-gesperrt-hinweis" id="rabatt-gesperrt-hinweis" hidden>
|
||||
<p data-i18n="lk_rabatt_gesperrt_hinweis">🔒 Rabattcodes sind nur für Dogfather-Supporter zugänglich. Werde jetzt Supporter, um sie freizuschalten.</p>
|
||||
<a class="btn btn-primary" href="abonnieren.html" data-i18n="lk_rabatt_btn_abo">Jetzt Supporter werden 👑</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<div class="grid grid-3" style="max-width:1040px;margin:0 auto;">
|
||||
@@ -100,5 +142,35 @@
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-links.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var karte = document.getElementById("card-rabattcodes");
|
||||
var badge = document.getElementById("rabatt-badge");
|
||||
var hinweis = document.getElementById("rabatt-gesperrt-hinweis");
|
||||
if (!karte) return;
|
||||
|
||||
// Gleicher zwischengespeicherter Login-/Abo-Stand wie beim Nav-Abo-
|
||||
// Knopf (renderAboButton() in main.js) — kein API-Aufruf bei jedem
|
||||
// Seitenaufruf nötig.
|
||||
var status = localStorage.getItem("supporterSubStatus");
|
||||
var hatAbo = ["active", "pending", "suspended", "cancelled"].indexOf(status) !== -1;
|
||||
|
||||
if (hatAbo) {
|
||||
karte.classList.remove("gesperrt");
|
||||
if (badge && window.dogiUebersetzen) badge.textContent = window.dogiUebersetzen("lk_rabatt_badge_frei");
|
||||
return;
|
||||
}
|
||||
|
||||
karte.classList.add("gesperrt");
|
||||
karte.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
if (!hinweis) return;
|
||||
hinweis.hidden = false;
|
||||
hinweis.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Manager DogFather — Spicy Media — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="DogFather als Manager bei der Spicy Media Agency: Scouts, Creator und faire Zusammenarbeit." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/manager.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/manager.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Manager DogFather — Spicy Media — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="DogFather als Manager bei der Spicy Media Agency: Scouts, Creator und faire Zusammenarbeit." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Medien — Casper — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Fotos und Videos rund um Husky Casper an einem Ort." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/medien-casper.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/medien-casper.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Medien — Casper — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="Fotos und Videos rund um Husky Casper an einem Ort." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Medien — DogFather — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Fotos, Videos und Events aus DogFathers Welt an einem Ort." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/medien-dogfather.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/medien-dogfather.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Medien — DogFather — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="Fotos, Videos und Events aus DogFathers Welt an einem Ort." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Medien — HasiDog — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Fotos, Videos und Events aus der HasiDog-Welt an einem Ort." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/medien-hasidog.html" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/medien-hasidog.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Medien — HasiDog — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="Fotos, Videos und Events aus der HasiDog-Welt an einem Ort." />
|
||||
<meta name="twitter:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Profil — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Team Dogi — persönliche Vorstellung." />
|
||||
<meta property="og:image" content="https://REPLACE-WITH-YOUR-DOMAIN.tld/assets/img/og-cover.jpg" />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="robots" content="noindex" /> <!-- generische URL, echte Team-Seiten verlinken hierher -->
|
||||
|
||||
@@ -26,6 +26,17 @@
|
||||
.profil-badge { display: none; margin-bottom: .6rem; }
|
||||
.profil-role { color: var(--accent); font-weight: 600; margin-bottom: 1.1rem; }
|
||||
.profil-bio { font-size: 1.05rem; line-height: 1.7; }
|
||||
/* profil-bio ist jetzt ein <div> statt <p> (kann selbst mehrere <p>
|
||||
enthalten, z.B. bei mehrabsätzigen intro-Texten wie bei Patrick/
|
||||
Bananenstift) — Absatzabstand hier nachbilden, sonst kleben die
|
||||
Absätze aneinander. */
|
||||
.profil-bio p { margin: 0 0 1rem; }
|
||||
.profil-bio p:last-child { margin-bottom: 0; }
|
||||
.profil-bio .quote { display: block; margin-top: 1rem; }
|
||||
/* Namen/Hervorhebungen im Fließtext (z.B. "Bananenstift", Datumsangaben)
|
||||
sollen sich deutlicher vom umgebenden Text abheben als reines Fett auf
|
||||
hellem Grund es tut — daher zusätzlich Akzentfarbe. */
|
||||
.profil-bio strong { color: var(--accent); }
|
||||
.profil-quote { display: none; font-style: italic; font-size: 1.05rem; margin-top: 1.2rem; color: var(--text-muted); }
|
||||
.profil-social { margin-top: 1.4rem; }
|
||||
@media (max-width: 760px) { .profil-photo { max-width: 320px; } }
|
||||
@@ -57,7 +68,7 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
.profil-bio-rich h3:first-child { margin-top: 0; }
|
||||
.profil-bio-rich strong { color: var(--text); }
|
||||
.profil-bio-rich strong { color: var(--accent); }
|
||||
/* Aufzählungen (z.B. Bananenstifts "Dabei helfe ich unter anderem
|
||||
bei:"-Liste) wirkten ohne eigene Regeln optisch "fett", weil sie —
|
||||
anders als <p> — keine gedimmte Textfarbe geerbt hatten und dadurch
|
||||
@@ -96,7 +107,7 @@
|
||||
<span class="badge rank-1 profil-badge" id="profil-badge"></span>
|
||||
<h2 id="profil-name" style="margin-top:0;"></h2>
|
||||
<div class="profil-role" id="profil-role"></div>
|
||||
<p class="profil-bio" id="profil-bio"></p>
|
||||
<div class="profil-bio" id="profil-bio"></div>
|
||||
<p class="profil-quote" id="profil-quote"></p>
|
||||
<div class="btn-row profil-social" id="profil-social"></div>
|
||||
</div>
|
||||
@@ -236,15 +247,23 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("profil-hero-role").textContent = feld(person.role);
|
||||
document.getElementById("profil-role").textContent = feld(person.role);
|
||||
|
||||
// intro (kurz, beim Foto) und bioHtml (lang, breiter Bereich darunter)
|
||||
// sind unabhängig voneinander nutzbar: eine Person kann NUR intro haben
|
||||
// (die komplette Vorstellung erscheint dann direkt neben dem Foto),
|
||||
// NUR bioHtml (Vorstellung komplett unten im breiten Bereich), oder
|
||||
// beides zusammen (kurzer Teaser neben dem Foto + ausführlicher Text
|
||||
// darunter, siehe Bananenstift).
|
||||
const introText = feld(person.intro);
|
||||
const bioHtmlText = feld(person.bioHtml);
|
||||
if (bioHtmlText) {
|
||||
if (introText) document.getElementById("profil-bio").innerHTML = introText;
|
||||
document.getElementById("profil-bio-rich").innerHTML = bioHtmlText;
|
||||
} else {
|
||||
if (introText) {
|
||||
document.getElementById("profil-bio").innerHTML = introText;
|
||||
} else if (!bioHtmlText) {
|
||||
document.getElementById("profil-bio").textContent =
|
||||
feld(person.bio) || t("pr_bio_fallback").replace("{name}", person.name);
|
||||
}
|
||||
if (bioHtmlText) {
|
||||
document.getElementById("profil-bio-rich").innerHTML = bioHtmlText;
|
||||
}
|
||||
|
||||
const kurzbeschreibung = feld(person.bio)
|
||||
|| (bioHtmlText ? bioHtmlText.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 160) : "")
|
||||
@@ -287,9 +306,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// normalen Outline-Button-Stil.
|
||||
if (plattform === "tiktok") {
|
||||
a.className = "btn-tiktok";
|
||||
const note = document.createElement("span");
|
||||
note.className = "btn-tiktok-note";
|
||||
note.setAttribute("aria-hidden", "true");
|
||||
note.textContent = "♪";
|
||||
const span = document.createElement("span");
|
||||
span.className = "btn-tiktok-text";
|
||||
span.textContent = socialLabels[plattform];
|
||||
a.appendChild(span);
|
||||
a.append(note, span);
|
||||
socialWrap.appendChild(a);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="dogfather">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Rabattcodes — DOGFATHER UNIVERSE</title>
|
||||
<meta name="description" content="Rabattcodes von Dogis Partnern für Team Dogi — spar bares Geld bei ausgewählten Marken." />
|
||||
<link rel="icon" type="image/png" href="assets/img/favicon.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#0b0d10" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="DOGFATHER UNIVERSE" />
|
||||
<meta property="og:title" content="Rabattcodes — DOGFATHER UNIVERSE" />
|
||||
<meta property="og:description" content="Rabattcodes von Dogis Partnern für Team Dogi — spar bares Geld bei ausgewählten Marken." />
|
||||
<meta property="og:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
<meta property="og:url" content="https://dogfather-universe.com/rabattcodes.html" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Rabattcodes — DOGFATHER UNIVERSE" />
|
||||
<meta name="twitter:description" content="Rabattcodes von Dogis Partnern für Team Dogi — spar bares Geld bei ausgewählten Marken." />
|
||||
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
|
||||
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-dogfather.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-hasidog.css" />
|
||||
<link rel="stylesheet" href="assets/css/theme-spicymedia.css" />
|
||||
<style>
|
||||
/* Leere-Zustand-Kachel im "Gutschein"-Look: gestrichelter Rand wie ein
|
||||
ausgeschnittener Coupon, damit auch OHNE echte Codes schon klar ist,
|
||||
wie hier später einzelne Rabattcode-Karten reinkommen. */
|
||||
.code-platzhalter {
|
||||
max-width: 620px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
.code-siegel {
|
||||
width: 96px; height: 96px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin: 0 auto 1.4rem;
|
||||
font-size: 2.6rem;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 32% 28%, rgba(52,211,153,.35), rgba(11,13,16,.9) 70%);
|
||||
border: 1.5px solid rgba(52,211,153,.45);
|
||||
box-shadow: 0 0 0 6px rgba(52,211,153,.06), 0 10px 30px rgba(0,0,0,.5);
|
||||
animation: code-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes code-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 6px rgba(52,211,153,.06), 0 10px 30px rgba(0,0,0,.5); }
|
||||
50% { box-shadow: 0 0 0 10px rgba(52,211,153,.12), 0 10px 34px rgba(0,0,0,.55); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .code-siegel { animation: none; } }
|
||||
.code-karte-vorschau {
|
||||
position: relative;
|
||||
max-width: 420px;
|
||||
margin: 1.8rem auto 0;
|
||||
padding: 1.2rem 1.4rem;
|
||||
border: 1.5px dashed rgba(52,211,153,.4);
|
||||
border-radius: 14px;
|
||||
background: rgba(52,211,153,.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.code-karte-vorschau .marke { font-weight: 700; }
|
||||
.code-karte-vorschau .prozent {
|
||||
font-weight: 800;
|
||||
color: #5eeaa7;
|
||||
font-size: 1.1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Gesperrt-Zustand: an das Supporter-Abo gekoppelt (Nutzer-Feedback
|
||||
03.08.2026), gleiche Prüfung wie die Karte auf links.html. Direkter
|
||||
Aufruf dieser Seite (Lesezeichen, geteilter Link) soll die Sperre
|
||||
genauso zeigen wie der Knopf dort — nicht nur auf links.html blockiert
|
||||
sein. */
|
||||
.code-gesperrt-siegel {
|
||||
width: 96px; height: 96px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin: 0 auto 1.4rem;
|
||||
font-size: 2.6rem;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 32% 28%, rgba(212,175,55,.35), rgba(11,13,16,.9) 70%);
|
||||
border: 1.5px solid rgba(212,175,55,.45);
|
||||
box-shadow: 0 0 0 6px rgba(212,175,55,.06), 0 10px 30px rgba(0,0,0,.5);
|
||||
}
|
||||
</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');">
|
||||
<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="container">
|
||||
<span class="eyebrow" data-i18n="rc_eyebrow">🏷️ Für Team Dogi</span>
|
||||
<h1 data-i18n="rc_h1">Rabattcodes</h1>
|
||||
<p class="lead worlds" data-i18n="rc_lead">Codes von Dogis Partnern — damit deine Community bares Geld spart.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<!-- Nur für Supporter sichtbar (siehe Skript unten) -->
|
||||
<div class="card code-platzhalter" id="rc-inhalt" hidden>
|
||||
<div class="code-siegel" aria-hidden="true">🏷️</div>
|
||||
<h2 data-i18n="rc_titel">Codes folgen in Kürze</h2>
|
||||
<p data-i18n="rc_text">Als Creator bekommt DogFather eigene Rabattcodes bei ausgewählten Partnern — die gibt er direkt an Team Dogi weiter. Sobald die ersten Kooperationen live sind, stehen die Codes genau hier.</p>
|
||||
<div class="code-karte-vorschau" aria-hidden="true">
|
||||
<span class="marke" data-i18n="rc_beispiel_marke">Partner-Marke</span>
|
||||
<span class="prozent" data-i18n="rc_beispiel_code">CODE „DOGFATHER" · –10 %</span>
|
||||
</div>
|
||||
<div class="btn-row" style="justify-content:center;margin-top:1.8rem;">
|
||||
<a class="btn btn-primary" href="kontakt.html" data-i18n="rc_btn_kontakt">Auf dem Laufenden bleiben</a>
|
||||
<a class="btn btn-outline" href="links.html" data-i18n="rc_btn_zurueck">← Zurück zu allen Links</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nur für Nicht-Supporter sichtbar (siehe Skript unten) -->
|
||||
<div class="card code-platzhalter" id="rc-gesperrt" hidden>
|
||||
<div class="code-gesperrt-siegel" aria-hidden="true">🔒</div>
|
||||
<h2 data-i18n="rc_gesperrt_titel">Nur für Dogfather-Supporter</h2>
|
||||
<p data-i18n="rc_gesperrt_text">Rabattcodes sind ein exklusiver Vorteil der DogiCrew 🩵. Werde jetzt Teil davon, um sie freizuschalten, sobald die ersten Kooperationen live sind.</p>
|
||||
<div class="btn-row" style="justify-content:center;margin-top:1.8rem;">
|
||||
<a class="btn btn-primary" href="abonnieren.html" data-i18n="rc_btn_abo">Jetzt Supporter werden 👑</a>
|
||||
<a class="btn btn-outline" href="links.html" data-i18n="rc_btn_zurueck">← Zurück zu allen Links</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="site-footer"></div>
|
||||
<script src="assets/js/i18n-rabattcodes.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var status = localStorage.getItem("supporterSubStatus");
|
||||
var hatAbo = ["active", "pending", "suspended", "cancelled"].indexOf(status) !== -1;
|
||||
var inhalt = document.getElementById("rc-inhalt");
|
||||
var gesperrt = document.getElementById("rc-gesperrt");
|
||||
if (inhalt) inhalt.hidden = !hatAbo;
|
||||
if (gesperrt) gesperrt.hidden = hatAbo;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,9 +1,8 @@
|
||||
# DOGFATHER UNIVERSE — robots.txt
|
||||
# TODO: sobald die Domain feststeht, die Sitemap-Zeile unten mit der
|
||||
# echten Domain aktivieren (siehe sitemap.xml TODO).
|
||||
# Domain seit 03.08.2026 final: dogfather-universe.com
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /cloudflare-worker/
|
||||
|
||||
Sitemap: https://REPLACE-WITH-YOUR-DOMAIN.tld/sitemap.xml
|
||||
Sitemap: https://dogfather-universe.com/sitemap.xml
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Vorlage — echte Werte kommen in eine eigene ".env"-Datei (nie committen,
|
||||
# nie in den Vault, nie in den Chat). 1:1 die gleichen Werte, die vorher per
|
||||
# `wrangler secret put` bzw. in wrangler.toml [vars] gesetzt waren.
|
||||
|
||||
PORT=4200
|
||||
DB_PATH=/var/lib/dogfather-internal/dogfather-internal.db
|
||||
|
||||
# ---- Owner-Zugang (Postfach/Team-Bereich) ----
|
||||
# Erst-Login-Code des Hauptadministrators (Dogi). Nach dem ersten Login kann
|
||||
# er über "Code ändern" durch einen in der DB gehashten Code ersetzt werden
|
||||
# (siehe routes/auth.js setOwnerCode) — POSTFACH_CODE bleibt dann als reiner
|
||||
# Fallback bestehen, wird aber nicht mehr benutzt, solange owner_code_hash
|
||||
# in app_settings gesetzt ist.
|
||||
POSTFACH_CODE=
|
||||
|
||||
# AES-256-Schlüssel (base64, 32 Bytes) für die reversible Team-Code-
|
||||
# Verschlüsselung. Erzeugen z.B. mit: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# ---- TikTok-Live-Erkennung ----
|
||||
TIKTOK_USERNAME=dogfather0804
|
||||
|
||||
# ---- Bewerbungen: Ticketanizer ----
|
||||
TICKETANIZER_API_KEY=
|
||||
|
||||
# ---- Bewerbungen: Discord-Benachrichtigungen (3 verschiedene Kanäle) ----
|
||||
DISCORD_WEBHOOK_MODI=
|
||||
DISCORD_WEBHOOK_KOOPERATION=
|
||||
DISCORD_WEBHOOK_SCOUT_MANAGER=
|
||||
|
||||
# ---- Supporter-Abo: E-Mail (Resend) ----
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM=Dogfather Team <[email protected]>
|
||||
|
||||
# ---- Supporter-Abo: Google-Login ----
|
||||
# Öffentliche Client-ID (kein Geheimnis), aber bewusst nur per .env gesetzt,
|
||||
# nicht hart im Frontend-Code, damit sie zusammen mit den anderen Werten an
|
||||
# einer Stelle verwaltet wird.
|
||||
GOOGLE_CLIENT_ID=
|
||||
|
||||
# ---- Supporter-Abo: PayPal Subscriptions ----
|
||||
# "sandbox" zum Testen, "live" für echte Zahlungen.
|
||||
PAYPAL_ENV=sandbox
|
||||
PAYPAL_CLIENT_ID=
|
||||
PAYPAL_CLIENT_SECRET=
|
||||
PAYPAL_PLAN_ID=
|
||||
PAYPAL_WEBHOOK_ID=
|
||||
@@ -0,0 +1,51 @@
|
||||
/* =====================================================================
|
||||
db.js — SQLite-Datenbank (better-sqlite3), Ersatz für Cloudflare D1.
|
||||
Führt beim ersten Start alle vier Migrationen aus ../cloudflare-worker/migrations/
|
||||
in der ursprünglichen Reihenfolge aus (0001 Basis-Schema, 0002 app_settings,
|
||||
0003 Supporter-Abo, 0004 TikTok/Discord-Spalten) — anders als bei VanVans Shop
|
||||
ist hier NIE alles in einer konsolidierten schema.sql zusammengefasst worden,
|
||||
deshalb müssen wir hier tatsächlich alle vier der Reihe nach anwenden.
|
||||
===================================================================== */
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DB_PATH = process.env.DB_PATH || "/var/lib/dogfather-internal/dogfather-internal.db";
|
||||
const MIGRATIONS_DIR = join(__dirname, "..", "cloudflare-worker", "migrations");
|
||||
|
||||
mkdirSync(dirname(DB_PATH), { recursive: true });
|
||||
|
||||
export const db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
const MIGRATION_FILES = [
|
||||
"0001_init.sql",
|
||||
"0002_app_settings.sql",
|
||||
"0003_supporter_abo.sql",
|
||||
"0004_supporter_oauth_providers.sql",
|
||||
];
|
||||
|
||||
function alreadyApplied(name) {
|
||||
const row = db.prepare(`SELECT 1 FROM _migrations WHERE name = ?`).get(name);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function initDb() {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`);
|
||||
for (const file of MIGRATION_FILES) {
|
||||
if (alreadyApplied(file)) continue;
|
||||
const path = join(MIGRATIONS_DIR, file);
|
||||
if (!existsSync(path)) {
|
||||
console.warn(`⚠️ Migration ${file} nicht gefunden unter ${path}, übersprungen.`);
|
||||
continue;
|
||||
}
|
||||
const sql = readFileSync(path, "utf-8");
|
||||
db.exec(sql);
|
||||
db.prepare(`INSERT INTO _migrations (name, applied_at) VALUES (?, ?)`).run(file, new Date().toISOString());
|
||||
console.log(`Migration angewendet: ${file}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/* =====================================================================
|
||||
DOGFATHER UNIVERSE — Interner Bereich (Postfach, Team-Verwaltung,
|
||||
DogiCrew-Supporter-Abo). Node.js/Express-Server als 1:1-Ersatz für den
|
||||
Cloudflare Worker "dogfather-universe-postfach"
|
||||
(siehe ../cloudflare-worker/src/worker.js für das Original).
|
||||
|
||||
Architektur (unverändert zum Original):
|
||||
db.js SQLite-Datenbank (better-sqlite3), Migrationen
|
||||
lib/crypto.js Hashing, AES-GCM (Code-Verschlüsselung), IDs
|
||||
lib/auth.js Login-Prüfung, Sessions, Rate-Limiting (Team)
|
||||
lib/supporter-auth.js Login/Sessions für den öffentlichen Bereich
|
||||
lib/permissions.js Vollständiger Permission-Katalog
|
||||
lib/audit.js Aktivitätsprotokoll schreiben
|
||||
lib/http.js JSON-Response-Helfer
|
||||
lib/live-status.js TikTok-Live-Erkennung (ersetzt Cloudflare-Cron)
|
||||
routes/*.js Endpoint-Handler pro Bereich
|
||||
|
||||
Unterschiede zum Original (rein technisch, gleiches Verhalten):
|
||||
- D1 (async) → better-sqlite3 (synchron)
|
||||
- KV BEWERBUNGEN wurde nie für mehr als den Live-Status-Schalter
|
||||
genutzt, der jetzt komplett in app_settings (SQLite) lebt.
|
||||
- ctx.waitUntil() → einfaches "nicht abwarten" (Node-Prozess läuft
|
||||
durchgehend weiter, kein Cold-Start-Risiko wie bei Workers).
|
||||
- scheduled()-Cron → node-cron, gleicher Rhythmus ("* * * * *").
|
||||
===================================================================== */
|
||||
import "dotenv/config";
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import cron from "node-cron";
|
||||
|
||||
import { initDb } from "./db.js";
|
||||
import { json } from "./lib/http.js";
|
||||
import { requireSession } from "./lib/auth.js";
|
||||
import { requireSupporterSession } from "./lib/supporter-auth.js";
|
||||
import { handleLiveStatusGet, handleLiveStatusSet, refreshLiveFromTikTok } from "./lib/live-status.js";
|
||||
|
||||
import { login, logout, setOwnerCode } from "./routes/auth.js";
|
||||
import { listRoles, createRole, updateRole, deleteRole } from "./routes/roles.js";
|
||||
import {
|
||||
listUsers, prepareUser, finalizeUser, setUserCode, revealUserCode,
|
||||
lockUser, unlockUser, deleteUser, setUserRole, setUserPermissions,
|
||||
endUserSessions, updateUserMeta,
|
||||
} from "./routes/users.js";
|
||||
import {
|
||||
submitApplication, listApplications, changeApplicationStatus,
|
||||
takeOverApplication, assignApplication, archiveApplication,
|
||||
restoreApplication, deleteApplication, markAnswered,
|
||||
listNotes, addNote, deleteNote, listDrafts, saveDraft,
|
||||
} from "./routes/applications.js";
|
||||
import { listAuditLog } from "./routes/audit.js";
|
||||
import {
|
||||
registerSupporter, verifySupporterEmail, requestSupporterLogin, verifySupporterLogin, logoutSupporter,
|
||||
googleLoginSupporter, getPublicConfig,
|
||||
getSupporterMe, updateSupporterProfile, requestSupporterEmailChange, confirmSupporterEmailChange,
|
||||
redeemPremium, deleteSupporterAccount,
|
||||
} from "./routes/supporter.js";
|
||||
import {
|
||||
startPaypalSubscription, confirmPaypalSubscription, cancelPaypalSubscription, handlePaypalWebhook,
|
||||
} from "./routes/supporter-paypal.js";
|
||||
import {
|
||||
listSupporters, getSupporterDetail, updatePremium, listCollections, upsertCollection, getAdminOverview,
|
||||
} from "./routes/supporter-admin.js";
|
||||
|
||||
initDb();
|
||||
|
||||
const PORT = Number(process.env.PORT || 4200);
|
||||
const app = express();
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
app.use(cors());
|
||||
app.use(
|
||||
express.json({
|
||||
limit: "2mb",
|
||||
// Roh-Body wird zusätzlich als String aufbewahrt — die PayPal-Webhook-
|
||||
// Signaturprüfung braucht den unveränderten Original-Text (siehe
|
||||
// routes/supporter-paypal.js), nicht das nachträglich neu serialisierte
|
||||
// req.body.
|
||||
verify: (req, res, buf) => {
|
||||
req.rawBody = buf.toString("utf8");
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
/* ---------- Middlewares: interne Team-Session bzw. Supporter-Session ---------- */
|
||||
|
||||
async function requireTeamSession(req, res, next) {
|
||||
const session = await requireSession(req);
|
||||
if (!session) return json(res, { ok: false, error: "Sitzung abgelaufen oder ungültig. Bitte erneut anmelden." }, 401);
|
||||
req.session = session;
|
||||
next();
|
||||
}
|
||||
|
||||
async function requireSupporterAuth(req, res, next) {
|
||||
const supporter = await requireSupporterSession(req);
|
||||
if (!supporter) return json(res, { ok: false, error: "Sitzung abgelaufen oder ungültig. Bitte erneut einloggen." }, 401);
|
||||
req.supporter = supporter;
|
||||
next();
|
||||
}
|
||||
|
||||
/** Reicht die injizierte session/supporter als drittes Argument an den Handler weiter (wie im Cloudflare-Original). */
|
||||
const withSession = (handler) => (req, res) => handler(req, res, req.session);
|
||||
const withSupporter = (handler) => (req, res) => handler(req, res, req.supporter);
|
||||
|
||||
/* ---------- Öffentliche Routen (keine Session) ---------- */
|
||||
|
||||
app.get("/health", (req, res) => json(res, { ok: true, service: "dogfather-universe-interner-bereich" }));
|
||||
app.post("/submit", submitApplication);
|
||||
app.get("/live-status", handleLiveStatusGet);
|
||||
app.post("/auth/login", login);
|
||||
|
||||
app.post("/supporter/register", registerSupporter);
|
||||
app.post("/supporter/verify-email", verifySupporterEmail);
|
||||
app.post("/supporter/login-request", requestSupporterLogin);
|
||||
app.post("/supporter/verify-login", verifySupporterLogin);
|
||||
app.post("/supporter/google-login", googleLoginSupporter);
|
||||
app.get("/supporter/public-config", getPublicConfig);
|
||||
// PayPal ruft diese Adresse direkt auf — eigene Signaturprüfung statt Session (siehe lib/paypal.js).
|
||||
app.post("/supporter/paypal-webhook", handlePaypalWebhook);
|
||||
|
||||
/* ---------- Team-Bereich (interne Session, requireTeamSession) ---------- */
|
||||
|
||||
app.post("/auth/logout", requireTeamSession, logout);
|
||||
app.post("/admin/owner/set-code", requireTeamSession, withSession(setOwnerCode));
|
||||
app.post("/live-status/set", requireTeamSession, withSession(handleLiveStatusSet));
|
||||
|
||||
app.post("/admin/applications/list", requireTeamSession, withSession(listApplications));
|
||||
app.post("/admin/applications/status", requireTeamSession, withSession(changeApplicationStatus));
|
||||
app.post("/admin/applications/take-over", requireTeamSession, withSession(takeOverApplication));
|
||||
app.post("/admin/applications/assign", requireTeamSession, withSession(assignApplication));
|
||||
app.post("/admin/applications/archive", requireTeamSession, withSession(archiveApplication));
|
||||
app.post("/admin/applications/restore", requireTeamSession, withSession(restoreApplication));
|
||||
app.post("/admin/applications/delete", requireTeamSession, withSession(deleteApplication));
|
||||
app.post("/admin/applications/mark-answered", requireTeamSession, withSession(markAnswered));
|
||||
|
||||
app.post("/admin/notes/list", requireTeamSession, withSession(listNotes));
|
||||
app.post("/admin/notes/add", requireTeamSession, withSession(addNote));
|
||||
app.post("/admin/notes/delete", requireTeamSession, withSession(deleteNote));
|
||||
|
||||
app.post("/admin/drafts/list", requireTeamSession, withSession(listDrafts));
|
||||
app.post("/admin/drafts/save", requireTeamSession, withSession(saveDraft));
|
||||
|
||||
app.post("/admin/roles/list", requireTeamSession, withSession(listRoles));
|
||||
app.post("/admin/roles/create", requireTeamSession, withSession(createRole));
|
||||
app.post("/admin/roles/update", requireTeamSession, withSession(updateRole));
|
||||
app.post("/admin/roles/delete", requireTeamSession, withSession(deleteRole));
|
||||
|
||||
app.post("/admin/users/list", requireTeamSession, withSession(listUsers));
|
||||
app.post("/admin/users/prepare", requireTeamSession, withSession(prepareUser));
|
||||
app.post("/admin/users/finalize", requireTeamSession, withSession(finalizeUser));
|
||||
app.post("/admin/users/set-code", requireTeamSession, withSession(setUserCode));
|
||||
app.post("/admin/users/reveal-code", requireTeamSession, withSession(revealUserCode));
|
||||
app.post("/admin/users/lock", requireTeamSession, withSession(lockUser));
|
||||
app.post("/admin/users/unlock", requireTeamSession, withSession(unlockUser));
|
||||
app.post("/admin/users/delete", requireTeamSession, withSession(deleteUser));
|
||||
app.post("/admin/users/set-role", requireTeamSession, withSession(setUserRole));
|
||||
app.post("/admin/users/set-permissions", requireTeamSession, withSession(setUserPermissions));
|
||||
app.post("/admin/users/end-sessions", requireTeamSession, withSession(endUserSessions));
|
||||
app.post("/admin/users/update-meta", requireTeamSession, withSession(updateUserMeta));
|
||||
|
||||
app.post("/admin/audit/list", requireTeamSession, withSession(listAuditLog));
|
||||
|
||||
// Supporter-Abo — Administrationsbereich (interne Team-Session + eigene
|
||||
// SUPPORTER_*-Rechte, siehe lib/permissions.js).
|
||||
app.post("/admin/overview", requireTeamSession, withSession(getAdminOverview));
|
||||
app.post("/admin/supporter/list", requireTeamSession, withSession(listSupporters));
|
||||
app.post("/admin/supporter/detail", requireTeamSession, withSession(getSupporterDetail));
|
||||
app.post("/admin/supporter/premium/update", requireTeamSession, withSession(updatePremium));
|
||||
app.post("/admin/supporter/collections/list", requireTeamSession, withSession(listCollections));
|
||||
app.post("/admin/supporter/collections/upsert", requireTeamSession, withSession(upsertCollection));
|
||||
|
||||
/* ---------- Öffentlicher Supporter-Bereich (eigene Session, requireSupporterAuth) ---------- */
|
||||
|
||||
app.post("/supporter/logout", requireSupporterAuth, logoutSupporter);
|
||||
app.post("/supporter/me", requireSupporterAuth, withSupporter(getSupporterMe));
|
||||
app.post("/supporter/update-profile", requireSupporterAuth, withSupporter(updateSupporterProfile));
|
||||
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));
|
||||
app.post("/supporter/delete-account", requireSupporterAuth, withSupporter(deleteSupporterAccount));
|
||||
app.post("/supporter/paypal/start", requireSupporterAuth, withSupporter(startPaypalSubscription));
|
||||
app.post("/supporter/paypal/confirm", requireSupporterAuth, withSupporter(confirmPaypalSubscription));
|
||||
app.post("/supporter/paypal/cancel", requireSupporterAuth, withSupporter(cancelPaypalSubscription));
|
||||
|
||||
app.use((req, res) => json(res, { ok: false, error: "Not found." }, 404));
|
||||
|
||||
/* ---------- Cron: läuft jede Minute, hält den Live-Punkt aktuell (ersetzt Cloudflares scheduled()) ---------- */
|
||||
cron.schedule("* * * * *", () => {
|
||||
refreshLiveFromTikTok().catch((err) => console.error("[live-status] Fehler beim Aktualisieren:", err?.message || err));
|
||||
});
|
||||
|
||||
app.listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`DOGFATHER UNIVERSE — Interner Bereich läuft auf http://127.0.0.1:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/* =====================================================================
|
||||
audit.js — Aktivitätsprotokoll. 1:1 portiert, D1 → better-sqlite3.
|
||||
WICHTIG: Codes dürfen hier NIEMALS im Klartext auftauchen.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { nowIso } from "./crypto.js";
|
||||
|
||||
export function logAction(actor, action, target, detail) {
|
||||
db.prepare(`INSERT INTO audit_log (actor, action, target, detail, created_at) VALUES (?, ?, ?, ?, ?)`).run(
|
||||
actor,
|
||||
action,
|
||||
target || null,
|
||||
detail ? JSON.stringify(detail) : null,
|
||||
nowIso()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/* =====================================================================
|
||||
auth.js — Login, Sessions, Rate-Limiting/Lockout für den internen
|
||||
Team-Bereich. 1:1 portiert aus cloudflare-worker/src/lib/auth.js,
|
||||
D1 (async) → better-sqlite3 (synchron).
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { sha256Hex, generateSessionToken, nowIso } from "./crypto.js";
|
||||
import { logAction } from "./audit.js";
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCKOUT_MINUTES = 15;
|
||||
const INACTIVITY_MS = 24 * 60 * 60 * 1000;
|
||||
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function clientKey(req) {
|
||||
return req.headers["cf-connecting-ip"] || req.ip || "unknown";
|
||||
}
|
||||
|
||||
function timingSafeEqual(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
let mismatch = 0;
|
||||
for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return mismatch === 0;
|
||||
}
|
||||
|
||||
export function isLockedOut(key) {
|
||||
const row = db.prepare(`SELECT locked_until FROM login_attempts WHERE key = ?`).get(key);
|
||||
if (!row || !row.locked_until) return false;
|
||||
return new Date(row.locked_until) > new Date();
|
||||
}
|
||||
|
||||
export function recordFailedAttempt(key) {
|
||||
const row = db.prepare(`SELECT count FROM login_attempts WHERE key = ?`).get(key);
|
||||
const count = (row?.count || 0) + 1;
|
||||
let lockedUntil = null;
|
||||
if (count >= MAX_ATTEMPTS) {
|
||||
lockedUntil = new Date(Date.now() + LOCKOUT_MINUTES * 60 * 1000).toISOString();
|
||||
}
|
||||
db.prepare(
|
||||
`INSERT INTO login_attempts (key, count, locked_until, updated_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET count = ?, locked_until = ?, updated_at = ?`
|
||||
).run(key, count, lockedUntil, nowIso(), count, lockedUntil, nowIso());
|
||||
return { count, lockedUntil };
|
||||
}
|
||||
|
||||
export function clearAttempts(key) {
|
||||
db.prepare(`DELETE FROM login_attempts WHERE key = ?`).run(key);
|
||||
}
|
||||
|
||||
/** Prüft einen eingegebenen Code gegen Owner-Secret/-Hash und Team-Datenbank. */
|
||||
export async function verifyCode(code) {
|
||||
const hash = await sha256Hex(code);
|
||||
|
||||
const ownerRow = db.prepare(`SELECT value FROM app_settings WHERE key = 'owner_code_hash'`).get();
|
||||
|
||||
if (ownerRow && ownerRow.value) {
|
||||
if (timingSafeEqual(hash, ownerRow.value)) return { ok: true, isOwner: true };
|
||||
} else if (process.env.POSTFACH_CODE && timingSafeEqual(code, process.env.POSTFACH_CODE)) {
|
||||
return { ok: true, isOwner: true };
|
||||
}
|
||||
|
||||
const user = db
|
||||
.prepare(`SELECT id, name, username, role_id, status, expires_at FROM users WHERE code_hash = ?`)
|
||||
.get(hash);
|
||||
if (!user) return { ok: false };
|
||||
if (user.status !== "active") return { ok: false, reason: "locked" };
|
||||
if (user.expires_at && new Date(user.expires_at) < new Date()) return { ok: false, reason: "expired" };
|
||||
return { ok: true, isOwner: false, user };
|
||||
}
|
||||
|
||||
export async function createSession({ isOwner, userId }) {
|
||||
const token = generateSessionToken();
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + SESSION_MAX_AGE_MS).toISOString();
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (token_hash, user_id, is_owner, created_at, last_active_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(tokenHash, isOwner ? "owner" : userId, isOwner ? 1 : 0, now.toISOString(), now.toISOString(), expiresAt);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function resolveSession(token) {
|
||||
if (!token) return null;
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const session = db.prepare(`SELECT * FROM sessions WHERE token_hash = ?`).get(tokenHash);
|
||||
if (!session) return null;
|
||||
|
||||
const now = Date.now();
|
||||
if (new Date(session.expires_at).getTime() < now) {
|
||||
db.prepare(`DELETE FROM sessions WHERE token_hash = ?`).run(tokenHash);
|
||||
return null;
|
||||
}
|
||||
if (now - new Date(session.last_active_at).getTime() > INACTIVITY_MS) {
|
||||
db.prepare(`DELETE FROM sessions WHERE token_hash = ?`).run(tokenHash);
|
||||
return null;
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE sessions SET last_active_at = ? WHERE token_hash = ?`).run(new Date().toISOString(), tokenHash);
|
||||
|
||||
if (session.is_owner) {
|
||||
return { isOwner: true, actor: "owner" };
|
||||
}
|
||||
|
||||
const user = db
|
||||
.prepare(
|
||||
`SELECT u.id, u.name, u.username, u.role_id, u.status, r.name as role_name, r.permissions
|
||||
FROM users u LEFT JOIN roles r ON r.id = u.role_id WHERE u.id = ?`
|
||||
)
|
||||
.get(session.user_id);
|
||||
if (!user || user.status !== "active") return null;
|
||||
|
||||
const overridesRows = db.prepare(`SELECT permission, allowed FROM user_permission_overrides WHERE user_id = ?`).all(user.id);
|
||||
const overrides = {};
|
||||
for (const r of overridesRows) overrides[r.permission] = !!r.allowed;
|
||||
|
||||
return {
|
||||
isOwner: false,
|
||||
actor: user.username,
|
||||
userId: user.id,
|
||||
name: user.name,
|
||||
roleId: user.role_id,
|
||||
roleName: user.role_name,
|
||||
permissions: user.permissions ? JSON.parse(user.permissions) : [],
|
||||
overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export async function endSession(token) {
|
||||
if (!token) return;
|
||||
const tokenHash = await sha256Hex(token);
|
||||
db.prepare(`DELETE FROM sessions WHERE token_hash = ?`).run(tokenHash);
|
||||
}
|
||||
|
||||
export function endAllSessionsForUser(userId) {
|
||||
db.prepare(`DELETE FROM sessions WHERE user_id = ?`).run(userId);
|
||||
}
|
||||
|
||||
export async function requireSession(req) {
|
||||
const auth = req.headers["authorization"] || "";
|
||||
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
|
||||
return resolveSession(token);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/* =====================================================================
|
||||
crypto.js — Verschlüsselung & Hashing für das Zugangssystem. 1:1 portiert
|
||||
aus cloudflare-worker/src/lib/crypto.js (Web Crypto API, in Node 20+
|
||||
global verfügbar wie im Cloudflare Worker — keine Änderung nötig).
|
||||
===================================================================== */
|
||||
|
||||
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // ohne I,O,0,1
|
||||
|
||||
export function generateFriendlyCode() {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(12));
|
||||
let raw = "";
|
||||
for (const b of bytes) raw += CODE_ALPHABET[b % CODE_ALPHABET.length];
|
||||
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`;
|
||||
}
|
||||
|
||||
export function generateSessionToken() {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function generateId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export async function sha256Hex(text) {
|
||||
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
||||
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function getAesKey() {
|
||||
const raw = Uint8Array.from(atob(process.env.ENCRYPTION_KEY), (c) => c.charCodeAt(0));
|
||||
return crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]);
|
||||
}
|
||||
|
||||
function bufToBase64(buf) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
}
|
||||
function base64ToBuf(b64) {
|
||||
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
/** Verschlüsselt einen Klartext-Code reversibel. Rückgabe: "iv.ciphertext" (base64). */
|
||||
export async function encryptCode(plaintext) {
|
||||
const key = await getAesKey();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(plaintext));
|
||||
return `${bufToBase64(iv)}.${bufToBase64(ciphertext)}`;
|
||||
}
|
||||
|
||||
/** Entschlüsselt einen mit encryptCode() erzeugten Wert. */
|
||||
export async function decryptCode(encoded) {
|
||||
const [ivB64, ctB64] = encoded.split(".");
|
||||
const key = await getAesKey();
|
||||
const iv = base64ToBuf(ivB64);
|
||||
const ciphertext = base64ToBuf(ctB64);
|
||||
const plainBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
|
||||
return new TextDecoder().decode(plainBuf);
|
||||
}
|
||||
|
||||
export function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/* =====================================================================
|
||||
lib/discord-webhooks.js — Bewerbungen als Discord-Nachricht posten.
|
||||
1:1 portiert aus cloudflare-worker/src/lib/discord-webhooks.js
|
||||
(reines fetch(), env.X → process.env.X).
|
||||
===================================================================== */
|
||||
|
||||
const WEBHOOK_ENV_KEYS = {
|
||||
modi: "DISCORD_WEBHOOK_MODI",
|
||||
kooperation: "DISCORD_WEBHOOK_KOOPERATION",
|
||||
scout: "DISCORD_WEBHOOK_SCOUT_MANAGER",
|
||||
creator: "DISCORD_WEBHOOK_SCOUT_MANAGER",
|
||||
};
|
||||
|
||||
const TYPE_LABELS = {
|
||||
modi: "🩵 Neue Bewerbung — Modi (Team Dogi)",
|
||||
kooperation: "🤝 Neue Kooperationsanfrage",
|
||||
scout: "🔎 Neue Bewerbung — Scout (Spicy Media)",
|
||||
creator: "🎬 Neue Bewerbung — Creator / Manager-Bewerbung (Spicy Media)",
|
||||
};
|
||||
|
||||
const TYPE_COLORS = { modi: 0x8fd9ea, kooperation: 0x8b5cf6, scout: 0xd94a5c, creator: 0xd94a5c };
|
||||
|
||||
const FIELD_LABELS = {
|
||||
name: "Name", email: "E-Mail", kontakt: "Kontakt (Discord/TikTok)", erfahrung: "Erfahrung",
|
||||
verfuegbarkeit: "Verfügbarkeit", warum: "Warum möchtest du dabei sein?", platform: "Plattform",
|
||||
content: "Content-Art", firma: "Firma / Projekt", idee: "Idee / Anliegen",
|
||||
};
|
||||
|
||||
function feldWert(wert) {
|
||||
const text = String(wert ?? "").trim();
|
||||
if (!text) return "_(leer)_";
|
||||
return text.length > 1000 ? text.slice(0, 1000) + "…" : text;
|
||||
}
|
||||
|
||||
export async function bewerbungAlsDiscordNachrichtPosten(type, data) {
|
||||
const envKey = WEBHOOK_ENV_KEYS[type];
|
||||
const webhookUrl = envKey && process.env[envKey];
|
||||
if (!webhookUrl) {
|
||||
console.error(`[discord-webhook] Keine Webhook-URL für "${type}" hinterlegt (${envKey}) — übersprungen.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = Object.entries(data || {})
|
||||
.filter(([, v]) => String(v ?? "").trim() !== "")
|
||||
.map(([key, value]) => ({ name: FIELD_LABELS[key] || key, value: feldWert(value), inline: String(value).length < 40 }));
|
||||
|
||||
const embed = {
|
||||
title: TYPE_LABELS[type] || "Neue Bewerbung",
|
||||
color: TYPE_COLORS[type] ?? 0x8fd9ea,
|
||||
fields: fields.length ? fields : [{ name: "Hinweis", value: "Keine Angaben übermittelt." }],
|
||||
footer: { text: "DOGFATHER UNIVERSE — automatisch von der Homepage" },
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
const antwort = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ embeds: [embed] }),
|
||||
});
|
||||
if (!antwort.ok) {
|
||||
const antwortText = await antwort.text().catch(() => "");
|
||||
console.error(`[discord-webhook] Fehler beim Posten (${type}): HTTP ${antwort.status} — ${antwortText}`);
|
||||
}
|
||||
} catch (fehler) {
|
||||
console.error(`[discord-webhook] Ausnahme beim Posten (${type}):`, fehler && fehler.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/* =====================================================================
|
||||
lib/google-auth.js — Verifiziert ein Google-ID-Token ("Mit Google
|
||||
anmelden") serverseitig, für den öffentlichen Supporter-Login.
|
||||
1:1 portiert aus cloudflare-worker/src/lib/google-auth.js, env.X → process.env.X.
|
||||
|
||||
Braucht GOOGLE_CLIENT_ID (öffentlich, KEIN Geheimnis). Ohne gesetzten
|
||||
Wert bleibt "Mit Google anmelden" ausgeblendet (siehe getPublicConfig
|
||||
in routes/supporter.js) statt kaputt zu sein.
|
||||
|
||||
Verifikation läuft über Googles offiziellen tokeninfo-Endpunkt
|
||||
(https://oauth2.googleapis.com/tokeninfo) — Google prüft dort Signatur,
|
||||
Ablaufzeit und Aussteller selbst und gibt die Nutzdaten zurück.
|
||||
===================================================================== */
|
||||
|
||||
export class GoogleAuthNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super("Google-Login ist noch nicht eingerichtet (GOOGLE_CLIENT_ID fehlt).");
|
||||
this.name = "GoogleAuthNotConfiguredError";
|
||||
this.code = "GOOGLE_AUTH_NOT_CONFIGURED";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ein von Google Identity Services im Browser ausgestelltes ID-Token.
|
||||
* Gibt bei Erfolg die bestätigten Nutzerdaten zurück (E-Mail ist von Google
|
||||
* selbst bereits verifiziert — braucht deshalb KEINEN zusätzlichen
|
||||
* E-Mail-Bestätigungscode mehr, siehe routes/supporter.js).
|
||||
*/
|
||||
export async function verifyGoogleIdToken(credential) {
|
||||
if (!process.env.GOOGLE_CLIENT_ID) throw new GoogleAuthNotConfiguredError();
|
||||
if (!credential) return { ok: false, reason: "Kein Token übergeben." };
|
||||
|
||||
const res = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`);
|
||||
if (!res.ok) return { ok: false, reason: "Google-Token konnte nicht geprüft werden." };
|
||||
const data = await res.json();
|
||||
|
||||
if (data.aud !== process.env.GOOGLE_CLIENT_ID) return { ok: false, reason: "Token ist für eine andere App ausgestellt." };
|
||||
if (!data.email || data.email_verified !== "true") return { ok: false, reason: "E-Mail-Adresse ist bei Google nicht bestätigt." };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
email: String(data.email).toLowerCase(),
|
||||
name: data.name || data.given_name || data.email.split("@")[0],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/* =====================================================================
|
||||
lib/http.js — gemeinsamer JSON-Response-Helfer für Express.
|
||||
1:1 dem Sinn nach portiert aus cloudflare-worker/src/lib/http.js
|
||||
(dort baute json() eine Response mit CORS-Headern; CORS läuft hier
|
||||
stattdessen zentral über die "cors"-Middleware in index.js, siehe dort).
|
||||
===================================================================== */
|
||||
|
||||
export function json(res, data, status = 200) {
|
||||
return res.status(status).json(data);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/* =====================================================================
|
||||
lib/live-status.js — Automatische TikTok-Live-Erkennung für den grünen
|
||||
Punkt auf der Startseite. 1:1 portiert aus dem Live-Status-Teil von
|
||||
cloudflare-worker/src/worker.js, D1 → better-sqlite3.
|
||||
|
||||
Ein Cron-Auftrag (node-cron in index.js, ersetzt Cloudflares
|
||||
`scheduled()`) fragt jede Minute bei TikTok nach, ob @dogfather0804
|
||||
gerade sendet, und schreibt das Ergebnis in die Tabelle app_settings.
|
||||
|
||||
Zwei Schlüssel:
|
||||
live_auto – letzter ERFOLGREICH ermittelter Stand (wird bei einem
|
||||
fehlgeschlagenen Abruf bewusst NICHT überschrieben,
|
||||
damit ein TikTok-Aussetzer den Punkt nicht fälschlich
|
||||
auf offline zieht)
|
||||
live_override – manueller Vorrang aus dem Postfach, mit Ablaufzeit.
|
||||
Solange er gilt, schlägt er die Automatik.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { json } from "./http.js";
|
||||
import { logAction } from "./audit.js";
|
||||
import { checkTikTokLive } from "./tiktok-live.js";
|
||||
|
||||
const DEFAULT_OVERRIDE_MINUTES = 180;
|
||||
|
||||
function readSetting(key) {
|
||||
const row = db.prepare(`SELECT value FROM app_settings WHERE key = ?`).get(key);
|
||||
if (!row || !row.value) return null;
|
||||
try {
|
||||
return JSON.parse(row.value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSetting(key, value) {
|
||||
db.prepare(
|
||||
`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
||||
).run(key, JSON.stringify(value), new Date().toISOString());
|
||||
}
|
||||
|
||||
/** Ermittelt den Stand, der nach außen gilt (Vorrang: manuell vor Automatik). */
|
||||
function resolveLiveStatus() {
|
||||
const auto = readSetting("live_auto") || {};
|
||||
const override = readSetting("live_override");
|
||||
|
||||
const overrideActive = override && override.until && new Date(override.until).getTime() > Date.now();
|
||||
|
||||
if (overrideActive) {
|
||||
return {
|
||||
live: !!override.live,
|
||||
source: "manuell",
|
||||
updatedAt: override.setAt || null,
|
||||
overrideUntil: override.until,
|
||||
autoLive: auto.live ?? null,
|
||||
autoCheckedAt: auto.checkedAt || null,
|
||||
autoHealthy: auto.ok !== false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
live: !!auto.live,
|
||||
source: "automatisch",
|
||||
updatedAt: auto.changedAt || auto.checkedAt || null,
|
||||
title: auto.title || null,
|
||||
autoLive: auto.live ?? null,
|
||||
autoCheckedAt: auto.checkedAt || null,
|
||||
autoHealthy: auto.ok !== false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleLiveStatusGet(req, res) {
|
||||
return json(res, { ok: true, ...resolveLiveStatus() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Manueller Eingriff aus dem Postfach.
|
||||
* { live: true|false, minutes?: number } → Vorrang setzen
|
||||
* { auto: true } → Vorrang aufheben, Automatik übernimmt
|
||||
*/
|
||||
export async function handleLiveStatusSet(req, res, session) {
|
||||
if (!session?.isOwner) return json(res, { ok: false, error: "Nur Dogi darf den Live-Status ändern." }, 403);
|
||||
const body = req.body || {};
|
||||
|
||||
if (body.auto === true) {
|
||||
db.prepare(`DELETE FROM app_settings WHERE key = 'live_override'`).run();
|
||||
logAction("owner", "live.override_cleared", null, null);
|
||||
return json(res, { ok: true, ...resolveLiveStatus() });
|
||||
}
|
||||
|
||||
const minutes = Number.isFinite(body.minutes) ? Math.min(Math.max(body.minutes, 5), 1440) : DEFAULT_OVERRIDE_MINUTES;
|
||||
const now = new Date();
|
||||
writeSetting("live_override", {
|
||||
live: !!body.live,
|
||||
setAt: now.toISOString(),
|
||||
until: new Date(now.getTime() + minutes * 60_000).toISOString(),
|
||||
});
|
||||
logAction("owner", "live.override_set", null, { live: !!body.live, minutes });
|
||||
return json(res, { ok: true, ...resolveLiveStatus() });
|
||||
}
|
||||
|
||||
/** Wird vom Cron-Auftrag aufgerufen (index.js) — fragt TikTok ab und schreibt das Ergebnis. */
|
||||
export async function refreshLiveFromTikTok() {
|
||||
const username = process.env.TIKTOK_USERNAME || "dogfather0804";
|
||||
const result = await checkTikTokLive(username);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (!result.ok) {
|
||||
// Fehlgeschlagen: letzten guten Stand behalten, nur den Fehler vermerken.
|
||||
const previous = readSetting("live_auto") || {};
|
||||
writeSetting("live_auto", { ...previous, ok: false, lastError: result.reason, checkedAt: now });
|
||||
return { ok: false, reason: result.reason };
|
||||
}
|
||||
|
||||
const previous = readSetting("live_auto") || {};
|
||||
const changed = previous.live !== result.live;
|
||||
|
||||
writeSetting("live_auto", {
|
||||
ok: true,
|
||||
live: result.live,
|
||||
title: result.title,
|
||||
roomId: result.roomId,
|
||||
rawStatus: result.rawStatus,
|
||||
checkedAt: now,
|
||||
changedAt: changed ? now : previous.changedAt || now,
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
logAction("system", result.live ? "live.started" : "live.ended", null, { title: result.title });
|
||||
}
|
||||
return { ok: true, live: result.live, changed };
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/* =====================================================================
|
||||
lib/paypal.js — PayPal Subscriptions API Anbindung (Supporter-Abo).
|
||||
1:1 portiert aus cloudflare-worker/src/lib/paypal.js, env.X → process.env.X.
|
||||
|
||||
WICHTIG — offener Punkt (wie im Original): vollständig fertig
|
||||
implementiert (OAuth2, Subscription erstellen, Subscription abfragen,
|
||||
Webhook-Signatur prüfen), kann aber erst wirklich Geld verarbeiten,
|
||||
sobald folgende echten Werte in der .env gesetzt sind:
|
||||
PAYPAL_CLIENT_ID
|
||||
PAYPAL_CLIENT_SECRET
|
||||
PAYPAL_PLAN_ID (aus dem PayPal-Business-Konto)
|
||||
PAYPAL_WEBHOOK_ID
|
||||
PAYPAL_ENV = "sandbox" | "live" (unkritisch, kein Secret)
|
||||
Diese Werte kann NUR der Website-Betreiber selbst erzeugen (PayPal-
|
||||
Business-Konto + Developer-App sind an seine eigene Identität/Bank
|
||||
gebunden) — deshalb "fail closed" mit sprechendem Fehler statt eines
|
||||
Absturzes, solange sie fehlen. NIEMALS in .env.example oder Vault!
|
||||
===================================================================== */
|
||||
|
||||
export class PayPalNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super("PayPal ist noch nicht eingerichtet (PAYPAL_CLIENT_ID/SECRET/PLAN_ID fehlen).");
|
||||
this.name = "PayPalNotConfiguredError";
|
||||
this.code = "PAYPAL_NOT_CONFIGURED";
|
||||
}
|
||||
}
|
||||
|
||||
function baseUrl() {
|
||||
return process.env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
|
||||
}
|
||||
|
||||
function assertConfigured() {
|
||||
if (!process.env.PAYPAL_CLIENT_ID || !process.env.PAYPAL_CLIENT_SECRET || !process.env.PAYPAL_PLAN_ID) {
|
||||
throw new PayPalNotConfiguredError();
|
||||
}
|
||||
}
|
||||
|
||||
let cachedToken = null; // { value, expiresAt } — pro Serverprozess, spart Requests
|
||||
|
||||
async function getAccessToken() {
|
||||
assertConfigured();
|
||||
if (cachedToken && cachedToken.expiresAt > Date.now() + 30_000) return cachedToken.value;
|
||||
|
||||
const res = await fetch(`${baseUrl()}/v1/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${btoa(`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`)}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
});
|
||||
if (!res.ok) throw new Error(`PayPal-OAuth fehlgeschlagen (${res.status}): ${await res.text().catch(() => "")}`);
|
||||
const data = await res.json();
|
||||
cachedToken = { value: data.access_token, expiresAt: Date.now() + (data.expires_in || 3600) * 1000 };
|
||||
return cachedToken.value;
|
||||
}
|
||||
|
||||
async function paypalFetch(path, options = {}) {
|
||||
const token = await getAccessToken();
|
||||
const res = await fetch(`${baseUrl()}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) {
|
||||
const err = new Error(`PayPal-Anfrage fehlgeschlagen (${res.status}): ${text.slice(0, 400)}`);
|
||||
err.paypalStatus = res.status;
|
||||
err.paypalBody = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein PayPal-Abonnement für einen Supporter und liefert den
|
||||
* "approve"-Link zurück, zu dem der Nutzer weitergeleitet werden muss, um
|
||||
* das Abo direkt bei PayPal zu bestätigen. custom_id trägt unsere interne
|
||||
* supporter_id, damit der Webhook später weiß, wem die Zahlung gehört.
|
||||
*/
|
||||
export async function createSubscription({ supporterId, returnUrl, cancelUrl }) {
|
||||
const data = await paypalFetch("/v1/billing/subscriptions", {
|
||||
method: "POST",
|
||||
headers: { "PayPal-Request-Id": `sub-${supporterId}-${Date.now()}` },
|
||||
body: JSON.stringify({
|
||||
plan_id: process.env.PAYPAL_PLAN_ID,
|
||||
custom_id: supporterId,
|
||||
application_context: {
|
||||
brand_name: "Dogfather",
|
||||
locale: "de-DE",
|
||||
shipping_preference: "NO_SHIPPING",
|
||||
user_action: "SUBSCRIBE_NOW",
|
||||
return_url: returnUrl,
|
||||
cancel_url: cancelUrl,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const approveLink = (data.links || []).find((l) => l.rel === "approve");
|
||||
return { paypalSubscriptionId: data.id, approveUrl: approveLink?.href || null, raw: data };
|
||||
}
|
||||
|
||||
export async function getSubscription(paypalSubscriptionId) {
|
||||
return paypalFetch(`/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}`);
|
||||
}
|
||||
|
||||
/** Kündigung — Zugang bleibt bis Periodenende bestehen. */
|
||||
export async function cancelSubscription(paypalSubscriptionId, reason) {
|
||||
await paypalFetch(`/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}/cancel`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason: reason || "Kündigung durch Supporter" }),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die Echtheit eines eingehenden Webhooks über PayPals offizielle
|
||||
* Verify-Signature-Funktion. `req` ist ein Express-Request (Header über
|
||||
* req.headers Objekt, nicht .get()/.headers.get() wie bei Cloudflare).
|
||||
*/
|
||||
export async function verifyWebhookSignature(req, rawBody) {
|
||||
assertConfigured();
|
||||
if (!process.env.PAYPAL_WEBHOOK_ID) throw new PayPalNotConfiguredError();
|
||||
|
||||
const h = req.headers;
|
||||
const verification = await paypalFetch("/v1/notifications/verify-webhook-signature", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
auth_algo: h["paypal-auth-algo"],
|
||||
cert_url: h["paypal-cert-url"],
|
||||
transmission_id: h["paypal-transmission-id"],
|
||||
transmission_sig: h["paypal-transmission-sig"],
|
||||
transmission_time: h["paypal-transmission-time"],
|
||||
webhook_id: process.env.PAYPAL_WEBHOOK_ID,
|
||||
webhook_event: JSON.parse(rawBody),
|
||||
}),
|
||||
});
|
||||
return verification.verification_status === "SUCCESS";
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/* =====================================================================
|
||||
permissions.js — Vollständiger Permission-Katalog. 1:1 portiert aus
|
||||
cloudflare-worker/src/lib/permissions.js (keine Datenbank-Abhängigkeit).
|
||||
===================================================================== */
|
||||
|
||||
export const PERMISSIONS = {
|
||||
APPLICATIONS_VIEW_CREATOR: "Creator-Bewerbungen ansehen",
|
||||
APPLICATIONS_VIEW_MANAGER_SCOUT: "Manager-/Scout-Bewerbungen ansehen",
|
||||
APPLICATIONS_VIEW_ARCHIVED: "Archivierte Bewerbungen ansehen",
|
||||
APPLICATIONS_OPEN_FULL: "Vollständige Bewerbungen öffnen",
|
||||
|
||||
APPLICATIONS_TAKE_OVER: "Bewerbungen übernehmen",
|
||||
APPLICATIONS_ASSIGN: "Bewerbungen anderen Personen zuweisen",
|
||||
APPLICATIONS_CHANGE_STATUS: "Status verändern",
|
||||
APPLICATIONS_ARCHIVE: "Bewerbungen archivieren",
|
||||
APPLICATIONS_RESTORE: "Bewerbungen wiederherstellen",
|
||||
APPLICATIONS_DELETE: "Bewerbungen löschen",
|
||||
|
||||
DRAFTS_CREATE: "Antwortentwürfe erstellen",
|
||||
DRAFTS_EDIT_OWN: "Eigene Entwürfe bearbeiten",
|
||||
DRAFTS_EDIT_OTHERS: "Entwürfe anderer Personen bearbeiten",
|
||||
REPLIES_COPY: "Fertige Antworten kopieren",
|
||||
EMAILS_COPY: "E-Mail-Adressen kopieren",
|
||||
APPLICATIONS_MARK_ANSWERED: "Bewerbungen als beantwortet markieren",
|
||||
|
||||
NOTES_READ: "Notizen lesen",
|
||||
NOTES_WRITE: "Notizen schreiben",
|
||||
NOTES_EDIT_OWN: "Eigene Notizen bearbeiten",
|
||||
NOTES_EDIT_OTHERS: "Fremde Notizen bearbeiten",
|
||||
NOTES_DELETE: "Notizen löschen",
|
||||
|
||||
TEAM_VIEW: "Andere Teammitglieder ansehen",
|
||||
TEAM_PREPARE_USER: "Neue Benutzer vorbereiten",
|
||||
TEAM_LOCK_USER: "Benutzer sperren",
|
||||
TEAM_ASSIGN_ROLE: "Rollen vergeben",
|
||||
TEAM_CREATE_ROLE: "Rollen erstellen",
|
||||
TEAM_EDIT_PERMISSIONS: "Allgemeine Berechtigungen verändern",
|
||||
|
||||
SETTINGS_EDIT_FORMS: "Bewerbungsformulare bearbeiten",
|
||||
SETTINGS_EDIT_STATUS_TYPES: "Statusarten verändern",
|
||||
SETTINGS_VIEW_STATS: "Statistiken ansehen",
|
||||
SETTINGS_VIEW_ACTIVITY_LOG: "Aktivitätsverlauf ansehen",
|
||||
SETTINGS_EDIT_GENERAL: "Allgemeine Einstellungen bearbeiten",
|
||||
|
||||
SUPPORTER_VIEW: "Supporter-Abonnenten ansehen",
|
||||
SUPPORTER_MANAGE_PREMIUMS: "Supporter-Prämien & Versand verwalten",
|
||||
SUPPORTER_MANAGE_COLLECTIONS: "Supporter-Kollektionen verwalten",
|
||||
};
|
||||
|
||||
export const PERMISSION_GROUPS = [
|
||||
{ label: "Bewerbungsbereiche", keys: ["APPLICATIONS_VIEW_CREATOR", "APPLICATIONS_VIEW_MANAGER_SCOUT", "APPLICATIONS_VIEW_ARCHIVED", "APPLICATIONS_OPEN_FULL"] },
|
||||
{ label: "Bearbeitung", keys: ["APPLICATIONS_TAKE_OVER", "APPLICATIONS_ASSIGN", "APPLICATIONS_CHANGE_STATUS", "APPLICATIONS_ARCHIVE", "APPLICATIONS_RESTORE", "APPLICATIONS_DELETE"] },
|
||||
{ label: "Antworten", keys: ["DRAFTS_CREATE", "DRAFTS_EDIT_OWN", "DRAFTS_EDIT_OTHERS", "REPLIES_COPY", "EMAILS_COPY", "APPLICATIONS_MARK_ANSWERED"] },
|
||||
{ label: "Interne Notizen", keys: ["NOTES_READ", "NOTES_WRITE", "NOTES_EDIT_OWN", "NOTES_EDIT_OTHERS", "NOTES_DELETE"] },
|
||||
{ label: "Teamverwaltung", keys: ["TEAM_VIEW", "TEAM_PREPARE_USER", "TEAM_LOCK_USER", "TEAM_ASSIGN_ROLE", "TEAM_CREATE_ROLE", "TEAM_EDIT_PERMISSIONS"] },
|
||||
{ label: "Einstellungen", keys: ["SETTINGS_EDIT_FORMS", "SETTINGS_EDIT_STATUS_TYPES", "SETTINGS_VIEW_STATS", "SETTINGS_VIEW_ACTIVITY_LOG", "SETTINGS_EDIT_GENERAL"] },
|
||||
{ label: "DogiCrew 🩵", keys: ["SUPPORTER_VIEW", "SUPPORTER_MANAGE_PREMIUMS", "SUPPORTER_MANAGE_COLLECTIONS"] },
|
||||
];
|
||||
|
||||
export const OWNER_ONLY_ACTIONS = [
|
||||
"CODE_VIEW", "CODE_COPY", "CODE_CREATE", "CODE_EDIT", "CODE_RESET",
|
||||
"EDIT_OWNER_ACCOUNT", "DELETE_OR_LIMIT_OWNER_ROLE", "PROMOTE_TO_OWNER",
|
||||
];
|
||||
|
||||
export function hasPermission(rolePermissions, overrides, key) {
|
||||
if (overrides && Object.prototype.hasOwnProperty.call(overrides, key)) {
|
||||
return !!overrides[key];
|
||||
}
|
||||
return Array.isArray(rolePermissions) && rolePermissions.includes(key);
|
||||
}
|
||||
|
||||
export function isValidPermissionKey(key) {
|
||||
return Object.prototype.hasOwnProperty.call(PERMISSIONS, key);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/* =====================================================================
|
||||
lib/supporter-auth.js — Registrierung, E-Mail-Code-Login, Sessions für
|
||||
den öffentlichen Supporter-Bereich. 1:1 portiert, D1 → better-sqlite3.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { sha256Hex, generateSessionToken, generateId, nowIso } from "./crypto.js";
|
||||
|
||||
const CODE_TTL_MS = 15 * 60 * 1000;
|
||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const INACTIVITY_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function normalizeTikTokUsername(input) {
|
||||
if (!input) return null;
|
||||
return String(input).trim().replace(/^@+/, "").toLowerCase() || null;
|
||||
}
|
||||
|
||||
export function normalizeEmail(input) {
|
||||
return String(input || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function generateNumericCode() {
|
||||
const n = crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000;
|
||||
return String(n).padStart(6, "0");
|
||||
}
|
||||
|
||||
export async function issueAuthCode(supporterId, purpose) {
|
||||
const code = generateNumericCode();
|
||||
const codeHash = await sha256Hex(code);
|
||||
db.prepare(
|
||||
`INSERT INTO supporter_auth_codes (id, supporter_id, purpose, code_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(generateId(), supporterId, purpose, codeHash, new Date(Date.now() + CODE_TTL_MS).toISOString(), nowIso());
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function verifyAuthCode(supporterId, purpose, code) {
|
||||
const codeHash = await sha256Hex(String(code || "").trim());
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, expires_at, used_at FROM supporter_auth_codes
|
||||
WHERE supporter_id = ? AND purpose = ? AND code_hash = ? ORDER BY created_at DESC LIMIT 1`
|
||||
)
|
||||
.get(supporterId, purpose, codeHash);
|
||||
if (!row) return false;
|
||||
if (row.used_at) return false;
|
||||
if (new Date(row.expires_at).getTime() < Date.now()) return false;
|
||||
db.prepare(`UPDATE supporter_auth_codes SET used_at = ? WHERE id = ?`).run(nowIso(), row.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function createSupporterSession(supporterId) {
|
||||
const token = generateSessionToken();
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const now = new Date();
|
||||
db.prepare(
|
||||
`INSERT INTO supporter_sessions (token_hash, supporter_id, created_at, last_active_at, expires_at) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(tokenHash, supporterId, now.toISOString(), now.toISOString(), new Date(now.getTime() + SESSION_MAX_AGE_MS).toISOString());
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function resolveSupporterSession(token) {
|
||||
if (!token) return null;
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const session = db.prepare(`SELECT * FROM supporter_sessions WHERE token_hash = ?`).get(tokenHash);
|
||||
if (!session) return null;
|
||||
|
||||
const now = Date.now();
|
||||
if (new Date(session.expires_at).getTime() < now || now - new Date(session.last_active_at).getTime() > INACTIVITY_MS) {
|
||||
db.prepare(`DELETE FROM supporter_sessions WHERE token_hash = ?`).run(tokenHash);
|
||||
return null;
|
||||
}
|
||||
db.prepare(`UPDATE supporter_sessions SET last_active_at = ? WHERE token_hash = ?`).run(nowIso(), tokenHash);
|
||||
|
||||
const supporter = db.prepare(`SELECT * FROM supporter_users WHERE id = ? AND status = 'active'`).get(session.supporter_id);
|
||||
return supporter || null;
|
||||
}
|
||||
|
||||
export async function endSupporterSession(token) {
|
||||
if (!token) return;
|
||||
db.prepare(`DELETE FROM supporter_sessions WHERE token_hash = ?`).run(await sha256Hex(token));
|
||||
}
|
||||
|
||||
export async function requireSupporterSession(req) {
|
||||
const auth = req.headers["authorization"] || "";
|
||||
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
|
||||
return resolveSupporterSession(token);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* =====================================================================
|
||||
lib/supporter-cycle.js — Reine Rechenlogik für den 30-Monats-Prämienzyklus.
|
||||
1:1 portiert, unverändert (keine D1-/Netzwerkabhängigkeit).
|
||||
===================================================================== */
|
||||
|
||||
export const CYCLE_LENGTH = 30;
|
||||
|
||||
export const MILESTONES = [
|
||||
{ month: 6, type: "teddy_klein", labelDe: "Kleiner Hasen-Teddy" },
|
||||
{ month: 12, type: "teddy_mittel", labelDe: "Mittelgroßer Hasen-Teddy" },
|
||||
{ month: 18, type: "teddy_gross", labelDe: "Großer Hasen-Teddy" },
|
||||
{ month: 24, type: "tasse_karte", labelDe: "Tasse & Autogrammkarte" },
|
||||
{ month: 30, type: "hoodie", labelDe: "Exklusiver Hoodie" },
|
||||
];
|
||||
|
||||
export function computeProgress(totalPaidMonths) {
|
||||
const total = Math.max(0, Math.floor(totalPaidMonths || 0));
|
||||
if (total === 0) return { totalPaidMonths: 0, completedCycles: 0, cycleNumber: 1, currentCycleMonth: 0 };
|
||||
const completedCycles = Math.floor((total - 1) / CYCLE_LENGTH);
|
||||
const currentCycleMonth = total - completedCycles * CYCLE_LENGTH;
|
||||
return { totalPaidMonths: total, completedCycles, cycleNumber: completedCycles + 1, currentCycleMonth };
|
||||
}
|
||||
|
||||
export function reachedMilestonesInCurrentCycle(currentCycleMonth) {
|
||||
return MILESTONES.filter((m) => currentCycleMonth >= m.month);
|
||||
}
|
||||
|
||||
export function nextMilestone(currentCycleMonth) {
|
||||
return MILESTONES.find((m) => currentCycleMonth < m.month) || null;
|
||||
}
|
||||
|
||||
export function allUnlockedMilestones(totalPaidMonths) {
|
||||
const { completedCycles, currentCycleMonth } = computeProgress(totalPaidMonths);
|
||||
const unlocked = [];
|
||||
for (let c = 1; c <= completedCycles; c++) {
|
||||
for (const m of MILESTONES) unlocked.push({ cycleNumber: c, ...m });
|
||||
}
|
||||
const cycleNumber = completedCycles + 1;
|
||||
for (const m of reachedMilestonesInCurrentCycle(currentCycleMonth)) {
|
||||
unlocked.push({ cycleNumber, ...m });
|
||||
}
|
||||
return unlocked;
|
||||
}
|
||||
|
||||
export function timelineForCurrentCycle(totalPaidMonths) {
|
||||
const { currentCycleMonth, cycleNumber } = computeProgress(totalPaidMonths);
|
||||
return {
|
||||
cycleNumber,
|
||||
currentCycleMonth,
|
||||
milestones: MILESTONES.map((m) => ({
|
||||
...m,
|
||||
reached: currentCycleMonth >= m.month,
|
||||
isNext: nextMilestone(currentCycleMonth)?.month === m.month,
|
||||
monthsRemaining: Math.max(0, m.month - currentCycleMonth),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/* =====================================================================
|
||||
lib/supporter-mail.js — Transaktions-E-Mails für den Supporter-Bereich
|
||||
(Resend-Adapter). 1:1 portiert, env.X → process.env.X.
|
||||
===================================================================== */
|
||||
|
||||
export class EmailNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super("E-Mail-Versand ist noch nicht eingerichtet (RESEND_API_KEY fehlt).");
|
||||
this.name = "EmailNotConfiguredError";
|
||||
this.code = "EMAIL_NOT_CONFIGURED";
|
||||
}
|
||||
}
|
||||
|
||||
async function send({ to, subject, html }) {
|
||||
if (!process.env.RESEND_API_KEY || !process.env.RESEND_FROM) throw new EmailNotConfiguredError();
|
||||
const res = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ from: process.env.RESEND_FROM, to: [to], subject, html }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`E-Mail-Versand fehlgeschlagen (${res.status}): ${text.slice(0, 300)}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const WRAP = (title, bodyHtml) => `
|
||||
<div style="font-family:Arial,sans-serif;background:#0b0d10;color:#f3f5f7;padding:32px;">
|
||||
<div style="max-width:480px;margin:0 auto;background:#171b21;border-radius:14px;padding:28px;border:1px solid #262c35;">
|
||||
<h1 style="color:#8fd9ea;font-size:20px;margin:0 0 16px;">${title}</h1>
|
||||
${bodyHtml}
|
||||
<p style="color:#9aa4b2;font-size:12px;margin-top:24px;">DogiCrew 🩵</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export async function sendVerifyEmailCode({ to, name, code }) {
|
||||
return send({
|
||||
to,
|
||||
subject: "Bestätige deine E-Mail-Adresse — DogiCrew 🩵",
|
||||
html: WRAP(
|
||||
`Willkommen, ${name}! 🩵`,
|
||||
`<p style="line-height:1.6;">Bestätige deine E-Mail-Adresse mit diesem Code:</p>
|
||||
<p style="font-size:32px;font-weight:800;letter-spacing:4px;color:#fff;">${code}</p>
|
||||
<p style="color:#9aa4b2;font-size:13px;">Der Code ist 15 Minuten gültig.</p>`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendLoginCode({ to, name, code }) {
|
||||
return send({
|
||||
to,
|
||||
subject: "Dein Login-Code — Dogfather Supporter-Bereich",
|
||||
html: WRAP(
|
||||
`Hallo ${name} 👋`,
|
||||
`<p style="line-height:1.6;">Dein Login-Code für den Supporter-Bereich:</p>
|
||||
<p style="font-size:32px;font-weight:800;letter-spacing:4px;color:#fff;">${code}</p>
|
||||
<p style="color:#9aa4b2;font-size:13px;">Der Code ist 15 Minuten gültig. Hast du das nicht angefordert, ignoriere diese E-Mail.</p>`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendMilestoneUnlocked({ to, name, premiumLabel }) {
|
||||
return send({
|
||||
to,
|
||||
subject: `Neuer Meilenstein freigeschaltet — ${premiumLabel} 🎉`,
|
||||
html: WRAP(
|
||||
`Glückwunsch, ${name}! 🩵`,
|
||||
`<p style="line-height:1.6;">Du hast einen neuen Meilenstein erreicht und kannst jetzt deine Prämie einlösen:</p>
|
||||
<p style="font-size:20px;font-weight:800;color:#fff;">${premiumLabel}</p>
|
||||
<p style="color:#9aa4b2;font-size:13px;">Logge dich in deinen Supporter-Bereich ein, um die Prämie einzulösen.</p>`
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* =====================================================================
|
||||
lib/ticketanizer.js — Bewerbungen als Ticket bei Ticketanizer eröffnen.
|
||||
1:1 portiert aus cloudflare-worker/src/lib/ticketanizer.js.
|
||||
===================================================================== */
|
||||
|
||||
const TICKETANIZER_BASIS_URL = "https://api.ticketanizer.com";
|
||||
|
||||
const PANEL_IDS = { modi: 4, kooperation: 5, scout: 6, creator: 7 };
|
||||
|
||||
const FIELD_LABELS = {
|
||||
name: "Name", email: "E-Mail", kontakt: "Kontakt (Discord/TikTok)", erfahrung: "Erfahrung",
|
||||
verfuegbarkeit: "Verfügbarkeit", warum: "Warum möchtest du dabei sein?", platform: "Plattform",
|
||||
content: "Content-Art", firma: "Firma / Projekt", idee: "Idee / Anliegen",
|
||||
};
|
||||
|
||||
function ersteNachrichtAufbauen(type, data) {
|
||||
const zeilen = Object.entries(data || {})
|
||||
.filter(([, v]) => String(v ?? "").trim() !== "")
|
||||
.map(([key, value]) => `**${FIELD_LABELS[key] || key}:** ${String(value).trim()}`);
|
||||
if (!zeilen.length) zeilen.push("_Keine weiteren Angaben übermittelt._");
|
||||
const text = zeilen.join("\n");
|
||||
return text.length > 3500 ? text.slice(0, 3500) + "…" : text;
|
||||
}
|
||||
|
||||
const warten = (ms) => new Promise((fertig) => setTimeout(fertig, ms));
|
||||
const MAX_VERSUCHE = 3;
|
||||
const STANDARD_WARTEZEITEN_MS = [2000, 5000];
|
||||
|
||||
async function ticketAnfrageSenden(apiKey, panelId, visitorName, firstMessage) {
|
||||
for (let versuch = 1; versuch <= MAX_VERSUCHE; versuch++) {
|
||||
const antwort = await fetch(`${TICKETANIZER_BASIS_URL}/v1/tickets`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": apiKey, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ panel_id: panelId, visitor_name: visitorName, first_message: firstMessage }),
|
||||
});
|
||||
if (antwort.status !== 429 || versuch === MAX_VERSUCHE) return antwort;
|
||||
const retryAfterHeader = Number(antwort.headers.get("Retry-After"));
|
||||
const wartezeit = Number.isFinite(retryAfterHeader) && retryAfterHeader > 0 ? retryAfterHeader * 1000 : STANDARD_WARTEZEITEN_MS[versuch - 1] || 5000;
|
||||
await warten(wartezeit);
|
||||
}
|
||||
}
|
||||
|
||||
export async function bewerbungAlsTicketAnlegen(type, data) {
|
||||
const apiKey = process.env.TICKETANIZER_API_KEY;
|
||||
const panelId = PANEL_IDS[type];
|
||||
if (!apiKey || !panelId) return;
|
||||
|
||||
const visitorName = String(data?.name || data?.firma || "Unbekannt").trim().slice(0, 80) || "Unbekannt";
|
||||
const firstMessage = ersteNachrichtAufbauen(type, data);
|
||||
|
||||
try {
|
||||
const antwort = await ticketAnfrageSenden(apiKey, panelId, visitorName, firstMessage);
|
||||
if (!antwort.ok) {
|
||||
const antwortText = await antwort.text().catch(() => "");
|
||||
console.error(`[ticketanizer] Fehler beim Ticket-Anlegen (${type}): HTTP ${antwort.status} — ${antwortText}`);
|
||||
}
|
||||
} catch (fehler) {
|
||||
console.error(`[ticketanizer] Ausnahme beim Ticket-Anlegen (${type}):`, fehler && fehler.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/* =====================================================================
|
||||
lib/tiktok-live.js — Automatische Live-Erkennung für TikTok. 1:1 portiert
|
||||
aus cloudflare-worker/src/lib/tiktok-live.js (das "cf"-Fetch-Sonderfeld
|
||||
von Cloudflare Workers entfällt, hat in Node keine Entsprechung/Wirkung).
|
||||
===================================================================== */
|
||||
|
||||
const LIVE_STATUS_RUNNING = 2;
|
||||
|
||||
const BROWSER_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "de-DE,de;q=0.9,en;q=0.8",
|
||||
"Cache-Control": "no-cache",
|
||||
Pragma: "no-cache",
|
||||
};
|
||||
|
||||
function extractScriptJson(html, id) {
|
||||
const marker = `<script id="${id}"`;
|
||||
const start = html.indexOf(marker);
|
||||
if (start === -1) return null;
|
||||
const open = html.indexOf(">", start);
|
||||
if (open === -1) return null;
|
||||
const end = html.indexOf("</script>", open);
|
||||
if (end === -1) return null;
|
||||
try {
|
||||
return JSON.parse(html.slice(open + 1, end));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkTikTokLive(username) {
|
||||
const handle = String(username || "").replace(/^@/, "").trim();
|
||||
if (!handle) return { ok: false, reason: "kein-benutzername" };
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`https://www.tiktok.com/@${encodeURIComponent(handle)}/live`, {
|
||||
headers: BROWSER_HEADERS,
|
||||
redirect: "follow",
|
||||
});
|
||||
} catch (err) {
|
||||
return { ok: false, reason: `netzwerkfehler: ${String(err).slice(0, 120)}` };
|
||||
}
|
||||
|
||||
if (!res.ok) return { ok: false, reason: `http-${res.status}` };
|
||||
|
||||
const html = await res.text();
|
||||
if (html.length < 5000) return { ok: false, reason: "seite-zu-klein-vermutlich-blockiert" };
|
||||
|
||||
const sigi = extractScriptJson(html, "SIGI_STATE");
|
||||
let info = sigi?.LiveRoom?.liveRoomUserInfo;
|
||||
|
||||
if (!info) {
|
||||
const universal = extractScriptJson(html, "__UNIVERSAL_DATA_FOR_REHYDRATION__");
|
||||
info = universal?.__DEFAULT_SCOPE__?.["webapp.live-detail"]?.liveRoomUserInfo;
|
||||
}
|
||||
if (!info) return { ok: false, reason: "struktur-nicht-gefunden" };
|
||||
|
||||
const status = info.liveRoom?.status;
|
||||
if (typeof status !== "number") return { ok: false, reason: "kein-status-feld" };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
live: status === LIVE_STATUS_RUNNING,
|
||||
rawStatus: status,
|
||||
title: info.liveRoom?.title || null,
|
||||
roomId: info.user?.roomId ? String(info.user.roomId) : null,
|
||||
followers: info.stats?.followerCount ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "dogfather-universe-internal-server",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"description": "Node.js/Express-Server als Ersatz für den 'dogfather-universe-postfach' Cloudflare Worker: Bewerbungs-Postfach, Team-Zugangsverwaltung mit Rollen/Rechten, und das DogiCrew-Supporter-Abo (PayPal). 1:1 portiert aus cloudflare-worker/.",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"node-cron": "^3.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/* =====================================================================
|
||||
routes/applications.js — Bewerbungen (Postfach), mit vollem Rollen-/
|
||||
Rechte-Modell statt einem pauschalen Owner/Helfer-Zugriff. Status,
|
||||
Zuweisung, Notizen und Antwortentwürfe pro Bewerbung. 1:1 portiert aus
|
||||
cloudflare-worker/src/routes/applications.js.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { generateId, nowIso } from "../lib/crypto.js";
|
||||
import { hasPermission } from "../lib/permissions.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { bewerbungAlsTicketAnlegen } from "../lib/ticketanizer.js";
|
||||
import { bewerbungAlsDiscordNachrichtPosten } from "../lib/discord-webhooks.js";
|
||||
|
||||
const ALLOWED_TYPES = ["creator", "scout", "kooperation", "modi"];
|
||||
|
||||
function can(session, key) {
|
||||
if (!session) return false;
|
||||
if (session.isOwner) return true;
|
||||
return hasPermission(session.permissions, session.overrides, key);
|
||||
}
|
||||
|
||||
function viewPermissionForType(type) {
|
||||
// Der Rechte-Katalog kommt fest aus dem Anforderungsdokument und kennt nur
|
||||
// zwei Bewerbungs-Sichtbereiche (Creator / "Rest"). Scout, Kooperation und
|
||||
// Modi laufen deshalb alle über APPLICATIONS_VIEW_MANAGER_SCOUT — es wird
|
||||
// bewusst KEIN neuer Permission-Key erfunden, der nicht im Dokument steht.
|
||||
return type === "creator" ? "APPLICATIONS_VIEW_CREATOR" : "APPLICATIONS_VIEW_MANAGER_SCOUT";
|
||||
}
|
||||
|
||||
export async function submitApplication(req, res) {
|
||||
const body = req.body;
|
||||
const { type, data } = body || {};
|
||||
if (!ALLOWED_TYPES.includes(type) || typeof data !== "object" || !data) {
|
||||
return json(res, { ok: false, error: "Ungültige Bewerbungsdaten." }, 400);
|
||||
}
|
||||
const values = Object.values(data).map((v) => String(v || "").trim());
|
||||
if (values.every((v) => v === "")) return json(res, { ok: false, error: "Leere Bewerbung." }, 400);
|
||||
|
||||
const id = generateId();
|
||||
db.prepare(`INSERT INTO applications (id, type, data, status, archived, created_at) VALUES (?, ?, ?, 'neu', 0, ?)`).run(
|
||||
id,
|
||||
type,
|
||||
JSON.stringify(data),
|
||||
nowIso()
|
||||
);
|
||||
|
||||
// Zusätzlich (rein informativ, blockiert die Bewerbung selbst nie):
|
||||
// 1) echtes Ticket bei Ticketanizer im passenden Bereich anlegen
|
||||
// (Web-Inbox zum Bearbeiten/Antworten)
|
||||
// 2) direkte, garantiert sichtbare Discord-Nachricht in den passenden
|
||||
// Kanal posten (Ticketanizer erzeugt bei API-Tickets keinen echten
|
||||
// Discord-Kanal, siehe discord-webhooks.js)
|
||||
// Läuft im Hintergrund weiter (kein ctx.waitUntil-Äquivalent in Express
|
||||
// nötig — der Node-Prozess bleibt am Leben), blockiert aber die Antwort
|
||||
// an die bewerbende Person nicht.
|
||||
Promise.all([bewerbungAlsTicketAnlegen(type, data), bewerbungAlsDiscordNachrichtPosten(type, data)]).catch(() => {});
|
||||
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function listApplications(req, res, session) {
|
||||
if (!session) return json(res, { ok: false, error: "Nicht angemeldet." }, 401);
|
||||
const body = req.body || {};
|
||||
const includeArchived = !!body.includeArchived;
|
||||
|
||||
const allowedTypes = ALLOWED_TYPES.filter((t) => session.isOwner || can(session, viewPermissionForType(t)));
|
||||
if (allowedTypes.length === 0) return json(res, { ok: true, applications: [] });
|
||||
if (includeArchived && !session.isOwner && !can(session, "APPLICATIONS_VIEW_ARCHIVED")) {
|
||||
return json(res, { ok: false, error: "Keine Berechtigung für archivierte Bewerbungen." }, 403);
|
||||
}
|
||||
|
||||
const placeholders = allowedTypes.map(() => "?").join(",");
|
||||
const archivedClause = includeArchived ? "" : "AND archived = 0";
|
||||
const rows = db
|
||||
.prepare(`SELECT * FROM applications WHERE type IN (${placeholders}) ${archivedClause} ORDER BY created_at DESC LIMIT 300`)
|
||||
.all(...allowedTypes);
|
||||
|
||||
const applications = rows.map((r) => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
data: session.isOwner || can(session, "APPLICATIONS_OPEN_FULL") ? JSON.parse(r.data) : null,
|
||||
status: r.status,
|
||||
assignedTo: r.assigned_to,
|
||||
archived: !!r.archived,
|
||||
createdAt: r.created_at,
|
||||
}));
|
||||
|
||||
return json(res, { ok: true, applications });
|
||||
}
|
||||
|
||||
export async function changeApplicationStatus(req, res, session) {
|
||||
if (!can(session, "APPLICATIONS_CHANGE_STATUS")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
const status = String(body.status || "").slice(0, 40);
|
||||
db.prepare(`UPDATE applications SET status = ? WHERE id = ?`).run(status, id);
|
||||
logAction(session.actor, "application.status", id, { status });
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function takeOverApplication(req, res, session) {
|
||||
if (!can(session, "APPLICATIONS_TAKE_OVER")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
const who = session.isOwner ? "owner" : session.userId;
|
||||
db.prepare(`UPDATE applications SET assigned_to = ? WHERE id = ?`).run(who, id);
|
||||
logAction(session.actor, "application.take_over", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function assignApplication(req, res, session) {
|
||||
if (!can(session, "APPLICATIONS_ASSIGN")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
const assignedTo = body.assignedTo ? String(body.assignedTo) : null;
|
||||
db.prepare(`UPDATE applications SET assigned_to = ? WHERE id = ?`).run(assignedTo, id);
|
||||
logAction(session.actor, "application.assign", id, { assignedTo });
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function archiveApplication(req, res, session) {
|
||||
if (!can(session, "APPLICATIONS_ARCHIVE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
db.prepare(`UPDATE applications SET archived = 1 WHERE id = ?`).run(id);
|
||||
logAction(session.actor, "application.archive", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function restoreApplication(req, res, session) {
|
||||
if (!can(session, "APPLICATIONS_RESTORE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
db.prepare(`UPDATE applications SET archived = 0 WHERE id = ?`).run(id);
|
||||
logAction(session.actor, "application.restore", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function deleteApplication(req, res, session) {
|
||||
if (!can(session, "APPLICATIONS_DELETE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
db.prepare(`DELETE FROM application_notes WHERE application_id = ?`).run(id);
|
||||
db.prepare(`DELETE FROM application_drafts WHERE application_id = ?`).run(id);
|
||||
db.prepare(`DELETE FROM applications WHERE id = ?`).run(id);
|
||||
logAction(session.actor, "application.delete", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function markAnswered(req, res, session) {
|
||||
if (!can(session, "APPLICATIONS_MARK_ANSWERED")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
db.prepare(`UPDATE applications SET status = 'beantwortet' WHERE id = ?`).run(id);
|
||||
logAction(session.actor, "application.mark_answered", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/* ---- Notizen ---- */
|
||||
export async function listNotes(req, res, session) {
|
||||
if (!can(session, "NOTES_READ")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const rows = db
|
||||
.prepare(`SELECT * FROM application_notes WHERE application_id = ? ORDER BY created_at ASC`)
|
||||
.all(String(body.applicationId || ""));
|
||||
return json(res, { ok: true, notes: rows });
|
||||
}
|
||||
|
||||
export async function addNote(req, res, session) {
|
||||
if (!can(session, "NOTES_WRITE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const text = String(body.text || "").slice(0, 2000);
|
||||
if (!text.trim()) return json(res, { ok: false, error: "Leere Notiz." }, 400);
|
||||
db.prepare(`INSERT INTO application_notes (application_id, author, text, created_at) VALUES (?, ?, ?, ?)`).run(
|
||||
String(body.applicationId || ""),
|
||||
session.actor,
|
||||
text,
|
||||
nowIso()
|
||||
);
|
||||
logAction(session.actor, "note.add", body.applicationId, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function deleteNote(req, res, session) {
|
||||
if (!can(session, "NOTES_DELETE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
db.prepare(`DELETE FROM application_notes WHERE id = ?`).run(Number(body.id));
|
||||
logAction(session.actor, "note.delete", String(body.id), null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/* ---- Antwortentwürfe ---- */
|
||||
export async function listDrafts(req, res, session) {
|
||||
const body = req.body || {};
|
||||
const rows = db
|
||||
.prepare(`SELECT * FROM application_drafts WHERE application_id = ? ORDER BY updated_at DESC`)
|
||||
.all(String(body.applicationId || ""));
|
||||
return json(res, { ok: true, drafts: rows });
|
||||
}
|
||||
|
||||
export async function saveDraft(req, res, session) {
|
||||
const body = req.body || {};
|
||||
const content = String(body.content || "").slice(0, 5000);
|
||||
|
||||
if (body.id) {
|
||||
const existing = db.prepare(`SELECT * FROM application_drafts WHERE id = ?`).get(Number(body.id));
|
||||
if (!existing) return json(res, { ok: false, error: "Entwurf nicht gefunden." }, 404);
|
||||
const isOwnDraft = existing.author === session.actor;
|
||||
if (!can(session, isOwnDraft ? "DRAFTS_EDIT_OWN" : "DRAFTS_EDIT_OTHERS")) {
|
||||
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
}
|
||||
db.prepare(`UPDATE application_drafts SET content = ?, updated_at = ? WHERE id = ?`).run(content, nowIso(), Number(body.id));
|
||||
logAction(session.actor, "draft.update", String(body.id), null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
if (!can(session, "DRAFTS_CREATE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
db.prepare(`INSERT INTO application_drafts (application_id, author, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`).run(
|
||||
String(body.applicationId || ""),
|
||||
session.actor,
|
||||
content,
|
||||
nowIso(),
|
||||
nowIso()
|
||||
);
|
||||
logAction(session.actor, "draft.create", body.applicationId, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* =====================================================================
|
||||
routes/audit.js — Aktivitätsprotokoll ansehen. 1:1 portiert aus
|
||||
cloudflare-worker/src/routes/audit.js.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { hasPermission } from "../lib/permissions.js";
|
||||
import { json } from "../lib/http.js";
|
||||
|
||||
export async function listAuditLog(req, res, session) {
|
||||
if (!session) return json(res, { ok: false, error: "Nicht angemeldet." }, 401);
|
||||
if (!session.isOwner && !hasPermission(session.permissions, session.overrides, "SETTINGS_VIEW_ACTIVITY_LOG")) {
|
||||
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
}
|
||||
const body = req.body || {};
|
||||
const limit = Math.min(Number(body.limit) || 100, 500);
|
||||
const rows = db
|
||||
.prepare(`SELECT id, actor, action, target, detail, created_at FROM audit_log ORDER BY created_at DESC LIMIT ?`)
|
||||
.all(limit);
|
||||
return json(res, { ok: true, entries: rows });
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/* =====================================================================
|
||||
routes/auth.js — Login / Logout (interner Team-Bereich).
|
||||
1:1 portiert aus cloudflare-worker/src/routes/auth.js.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { verifyCode, createSession, endSession, isLockedOut, recordFailedAttempt, clearAttempts, clientKey } from "../lib/auth.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { nowIso, sha256Hex } from "../lib/crypto.js";
|
||||
import { json } from "../lib/http.js";
|
||||
|
||||
/**
|
||||
* Der Hauptadministrator ändert seinen eigenen Zugangscode.
|
||||
* Nur mit gültiger Owner-Session UND erneuter Eingabe des aktuellen Codes.
|
||||
* Gespeichert wird ausschließlich der SHA-256-Hash — nie der Klartext.
|
||||
*/
|
||||
export async function setOwnerCode(req, res, session) {
|
||||
if (!session || !session.isOwner) {
|
||||
return json(res, { ok: false, error: "Das darf nur der Hauptadministrator." }, 403);
|
||||
}
|
||||
|
||||
const body = req.body || {};
|
||||
const currentCode = String(body.currentCode || "");
|
||||
const newCode = String(body.newCode || "");
|
||||
|
||||
const check = await verifyCode(currentCode);
|
||||
if (!check.ok || !check.isOwner) {
|
||||
logAction("owner", "owner.code_change_failed", null, null);
|
||||
return json(res, { ok: false, error: "Der aktuelle Code stimmt nicht." }, 403);
|
||||
}
|
||||
|
||||
if (newCode.length < 10) {
|
||||
return json(res, { ok: false, error: "Der neue Code muss mindestens 10 Zeichen haben." }, 400);
|
||||
}
|
||||
if (newCode === currentCode) {
|
||||
return json(res, { ok: false, error: "Der neue Code muss sich vom alten unterscheiden." }, 400);
|
||||
}
|
||||
|
||||
const hash = await sha256Hex(newCode);
|
||||
db.prepare(
|
||||
`INSERT INTO app_settings (key, value, updated_at) VALUES ('owner_code_hash', ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
||||
).run(hash, nowIso());
|
||||
|
||||
logAction("owner", "owner.code_changed", null, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function login(req, res) {
|
||||
const body = req.body || {};
|
||||
const code = String(body.code || "");
|
||||
const key = `login:${clientKey(req)}`;
|
||||
|
||||
if (isLockedOut(key)) {
|
||||
return json(res, { ok: false, error: "Zu viele Fehlversuche. Bitte in ein paar Minuten erneut versuchen." }, 429);
|
||||
}
|
||||
|
||||
const result = await verifyCode(code);
|
||||
if (!result.ok) {
|
||||
const { count } = recordFailedAttempt(key);
|
||||
logAction("anonym", "login.failed", null, { attempt: count });
|
||||
if (result.reason === "locked") return json(res, { ok: false, error: "Dieser Zugang ist gesperrt." }, 403);
|
||||
if (result.reason === "expired") return json(res, { ok: false, error: "Dieser Zugang ist abgelaufen." }, 403);
|
||||
return json(res, { ok: false, error: "Falscher Code." }, 401);
|
||||
}
|
||||
|
||||
clearAttempts(key);
|
||||
|
||||
if (result.isOwner) {
|
||||
const token = await createSession({ isOwner: true });
|
||||
logAction("owner", "login.success", null, null);
|
||||
return json(res, { ok: true, token, isOwner: true, name: "Dogi", roleName: "Hauptadministrator" });
|
||||
}
|
||||
|
||||
const token = await createSession({ isOwner: false, userId: result.user.id });
|
||||
db.prepare(`UPDATE users SET last_login_at = ? WHERE id = ?`).run(nowIso(), result.user.id);
|
||||
|
||||
const roleRow = result.user.role_id
|
||||
? db.prepare(`SELECT name, permissions FROM roles WHERE id = ?`).get(result.user.role_id)
|
||||
: null;
|
||||
const overridesRows = db.prepare(`SELECT permission, allowed FROM user_permission_overrides WHERE user_id = ?`).all(result.user.id);
|
||||
const overrides = {};
|
||||
for (const r of overridesRows) overrides[r.permission] = !!r.allowed;
|
||||
const permissions = roleRow ? JSON.parse(roleRow.permissions || "[]") : [];
|
||||
|
||||
logAction(result.user.username, "login.success", null, null);
|
||||
|
||||
return json(res, {
|
||||
ok: true,
|
||||
token,
|
||||
isOwner: false,
|
||||
name: result.user.name,
|
||||
username: result.user.username,
|
||||
roleName: roleRow?.name || "Ohne Rolle",
|
||||
permissions,
|
||||
overrides,
|
||||
});
|
||||
}
|
||||
|
||||
export async function logout(req, res) {
|
||||
const auth = req.headers["authorization"] || "";
|
||||
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
|
||||
await endSession(token);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/* =====================================================================
|
||||
routes/roles.js — Rollen erstellen/umbenennen/verwalten. 1:1 portiert
|
||||
aus cloudflare-worker/src/routes/roles.js.
|
||||
|
||||
Die Owner-Rolle existiert absichtlich NICHT in dieser Tabelle — sie ist
|
||||
fest an POSTFACH_CODE gebunden und kann hier weder erstellt, verändert
|
||||
noch gelöscht werden (siehe Anforderungsdokument: "Meine persönliche
|
||||
Hauptadministrator-Rolle bleibt davon ausgeschlossen").
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { generateId, nowIso } from "../lib/crypto.js";
|
||||
import { isValidPermissionKey, hasPermission } from "../lib/permissions.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { json } from "../lib/http.js";
|
||||
|
||||
function canManageRoles(session) {
|
||||
if (!session) return false;
|
||||
if (session.isOwner) return true;
|
||||
return (
|
||||
hasPermission(session.permissions, session.overrides, "TEAM_CREATE_ROLE") ||
|
||||
hasPermission(session.permissions, session.overrides, "TEAM_EDIT_PERMISSIONS")
|
||||
);
|
||||
}
|
||||
|
||||
function canViewRoles(session) {
|
||||
if (!session) return false;
|
||||
if (session.isOwner) return true;
|
||||
return (
|
||||
canManageRoles(session) ||
|
||||
hasPermission(session.permissions, session.overrides, "TEAM_ASSIGN_ROLE") ||
|
||||
hasPermission(session.permissions, session.overrides, "TEAM_VIEW")
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizePermissions(list) {
|
||||
if (!Array.isArray(list)) return [];
|
||||
return [...new Set(list.filter((k) => typeof k === "string" && isValidPermissionKey(k)))];
|
||||
}
|
||||
|
||||
export async function listRoles(req, res, session) {
|
||||
if (!canViewRoles(session)) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const rows = db.prepare(`SELECT * FROM roles ORDER BY created_at ASC`).all();
|
||||
const roles = rows.map((r) => ({ ...r, permissions: JSON.parse(r.permissions || "[]") }));
|
||||
return json(res, { ok: true, roles });
|
||||
}
|
||||
|
||||
export async function createRole(req, res, session) {
|
||||
if (!canManageRoles(session)) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const name = String(body.name || "").trim().slice(0, 60);
|
||||
if (!name) return json(res, { ok: false, error: "Name fehlt." }, 400);
|
||||
const permissions = sanitizePermissions(body.permissions);
|
||||
const id = generateId();
|
||||
|
||||
db.prepare(`INSERT INTO roles (id, name, permissions, created_at) VALUES (?, ?, ?, ?)`).run(id, name, JSON.stringify(permissions), nowIso());
|
||||
|
||||
logAction(session.actor, "role.create", id, { name, permissions });
|
||||
return json(res, { ok: true, role: { id, name, permissions } });
|
||||
}
|
||||
|
||||
export async function updateRole(req, res, session) {
|
||||
if (!canManageRoles(session)) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
if (!id) return json(res, { ok: false, error: "id fehlt." }, 400);
|
||||
|
||||
const existing = db.prepare(`SELECT * FROM roles WHERE id = ?`).get(id);
|
||||
if (!existing) return json(res, { ok: false, error: "Rolle nicht gefunden." }, 404);
|
||||
|
||||
const name = body.name !== undefined ? String(body.name).trim().slice(0, 60) || existing.name : existing.name;
|
||||
const permissions = body.permissions !== undefined ? sanitizePermissions(body.permissions) : JSON.parse(existing.permissions || "[]");
|
||||
|
||||
db.prepare(`UPDATE roles SET name = ?, permissions = ? WHERE id = ?`).run(name, JSON.stringify(permissions), id);
|
||||
|
||||
logAction(session.actor, "role.update", id, { name, permissions });
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function deleteRole(req, res, session) {
|
||||
if (!canManageRoles(session)) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
const body = req.body || {};
|
||||
const id = String(body.id || "");
|
||||
if (!id) return json(res, { ok: false, error: "id fehlt." }, 400);
|
||||
|
||||
const inUse = db.prepare(`SELECT COUNT(*) as n FROM users WHERE role_id = ?`).get(id);
|
||||
if (inUse && inUse.n > 0) {
|
||||
return json(res, { ok: false, error: "Rolle ist noch Personen zugewiesen — erst umziehen." }, 400);
|
||||
}
|
||||
|
||||
db.prepare(`DELETE FROM roles WHERE id = ?`).run(id);
|
||||
logAction(session.actor, "role.delete", id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/* =====================================================================
|
||||
routes/supporter-admin.js — Administrationsbereich für das Supporter-
|
||||
Abo (Dogfather_VanVan_Supporter_Abo.odt Abschnitt 27). 1:1 portiert aus
|
||||
cloudflare-worker/src/routes/supporter-admin.js.
|
||||
|
||||
Nutzt die BESTEHENDE interne Team-Session (auth.js/requireSession),
|
||||
NICHT die öffentliche Supporter-Session — nur berechtigte
|
||||
Administrator:innen (Owner oder Rolle mit SUPPORTER_*-Rechten) dürfen
|
||||
hierauf zugreifen.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { generateId, nowIso } from "../lib/crypto.js";
|
||||
import { hasPermission } from "../lib/permissions.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { computeProgress, timelineForCurrentCycle } from "../lib/supporter-cycle.js";
|
||||
|
||||
function can(session, key) {
|
||||
if (!session) return false;
|
||||
if (session.isOwner) return true;
|
||||
return hasPermission(session.permissions, session.overrides, key);
|
||||
}
|
||||
|
||||
function forbidden(res) {
|
||||
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
||||
}
|
||||
|
||||
/** Abonnentenübersicht mit Filtern (Abschnitt 27). */
|
||||
export async function listSupporters(req, res, session) {
|
||||
if (!can(session, "SUPPORTER_VIEW")) return forbidden(res);
|
||||
const body = req.body || {};
|
||||
const filter = body.filter || "alle";
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT u.id, u.display_name, u.tiktok_username, u.email, u.email_verified, u.created_at,
|
||||
s.id as sub_id, s.paypal_subscription_id, s.status as sub_status, s.started_at,
|
||||
s.next_payment_at, s.paid_period_end_at, s.cancelled_at, s.total_paid_months
|
||||
FROM supporter_users u
|
||||
LEFT JOIN supporter_subscriptions s ON s.supporter_id = u.id
|
||||
WHERE u.status = 'active'
|
||||
ORDER BY u.created_at DESC`
|
||||
)
|
||||
.all();
|
||||
|
||||
let list = rows;
|
||||
const FILTER_MAP = {
|
||||
aktiv: (r) => r.sub_status === "active",
|
||||
gekuendigt: (r) => r.sub_status === "cancelled",
|
||||
zahlung_fehlgeschlagen: (r) => r.sub_status === "suspended",
|
||||
monat_6: (r) => r.total_paid_months >= 6,
|
||||
monat_12: (r) => r.total_paid_months >= 12,
|
||||
monat_18: (r) => r.total_paid_months >= 18,
|
||||
monat_24: (r) => r.total_paid_months >= 24,
|
||||
monat_30: (r) => r.total_paid_months >= 30,
|
||||
};
|
||||
if (FILTER_MAP[filter]) list = list.filter(FILTER_MAP[filter]);
|
||||
|
||||
// Fortschritt (Zyklus/Meilenstein) rein rechnerisch aus total_paid_months
|
||||
// ergänzen (Nutzer-Wunsch 04.08.2026: "wo die Leute mit den Abos dran
|
||||
// sind ... alles einsehen können") — dieselbe Logik wie im Supporter-
|
||||
// Bereich selbst (lib/supporter-cycle.js), damit nichts auseinanderläuft.
|
||||
list = list.map((r) => ({ ...r, progress: computeProgress(r.total_paid_months || 0) }));
|
||||
|
||||
return json(res, { ok: true, supporters: list });
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersicht über ALLE Supporter hinweg (Nutzer-Wunsch 04.08.2026: "ein
|
||||
* festes Fenster, wo man drauf klicken muss um zu sehen wer was hat, wie
|
||||
* weit ist" — statt sich erst durch jeden einzelnen Supporter klicken zu
|
||||
* müssen). Liefert die komplette Prämien-Warteschlange (für den Versand)
|
||||
* und die letzten Zahlungen über alle Supporter, jeweils mit Name/E-Mail
|
||||
* dazu — bewusst EIN kombinierter Aufruf statt vieler Einzelabfragen.
|
||||
*/
|
||||
export async function getAdminOverview(req, res, session) {
|
||||
if (!can(session, "SUPPORTER_VIEW")) return forbidden(res);
|
||||
|
||||
const premiums = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as collection_name, u.display_name, u.email, u.tiktok_username
|
||||
FROM supporter_premiums p
|
||||
JOIN supporter_collections c ON c.id = p.collection_id
|
||||
JOIN supporter_users u ON u.id = p.supporter_id
|
||||
ORDER BY
|
||||
CASE p.redeem_status
|
||||
WHEN 'wird_geprueft' THEN 0
|
||||
WHEN 'wird_vorbereitet' THEN 1
|
||||
WHEN 'versandbereit' THEN 2
|
||||
WHEN 'problem' THEN 3
|
||||
WHEN 'ersatz_wird_vorbereitet' THEN 4
|
||||
WHEN 'verfuegbar' THEN 5
|
||||
WHEN 'versendet' THEN 6
|
||||
WHEN 'zugestellt' THEN 7
|
||||
WHEN 'erhalten' THEN 8
|
||||
ELSE 9
|
||||
END,
|
||||
p.unlocked_at DESC`
|
||||
)
|
||||
.all();
|
||||
|
||||
const payments = db
|
||||
.prepare(
|
||||
`SELECT pay.*, u.display_name, u.email
|
||||
FROM supporter_payments pay
|
||||
JOIN supporter_users u ON u.id = pay.supporter_id
|
||||
ORDER BY pay.paid_at DESC LIMIT 100`
|
||||
)
|
||||
.all();
|
||||
|
||||
return json(res, { ok: true, premiums, payments });
|
||||
}
|
||||
|
||||
/** Details + Prämien eines einzelnen Supporters. */
|
||||
export async function getSupporterDetail(req, res, session) {
|
||||
if (!can(session, "SUPPORTER_VIEW")) return forbidden(res);
|
||||
const body = req.body || {};
|
||||
const supporter = db.prepare(`SELECT * FROM supporter_users WHERE id = ?`).get(body.supporterId);
|
||||
if (!supporter) return json(res, { ok: false, error: "Nicht gefunden." }, 404);
|
||||
|
||||
const sub = db
|
||||
.prepare(`SELECT * FROM supporter_subscriptions WHERE supporter_id = ? ORDER BY created_at DESC LIMIT 1`)
|
||||
.get(supporter.id);
|
||||
const premiums = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as collection_name FROM supporter_premiums p
|
||||
JOIN supporter_collections c ON c.id = p.collection_id
|
||||
WHERE p.supporter_id = ? ORDER BY p.cycle_number, p.milestone_month`
|
||||
)
|
||||
.all(supporter.id);
|
||||
const payments = db.prepare(`SELECT * FROM supporter_payments WHERE supporter_id = ? ORDER BY paid_at DESC LIMIT 50`).all(supporter.id);
|
||||
|
||||
const totalPaidMonths = sub?.total_paid_months || 0;
|
||||
const progress = computeProgress(totalPaidMonths);
|
||||
const timeline = timelineForCurrentCycle(totalPaidMonths);
|
||||
|
||||
return json(res, {
|
||||
ok: true, supporter, subscription: sub, premiums, payments,
|
||||
progress, timeline,
|
||||
});
|
||||
}
|
||||
|
||||
/** Prämie/Versand manuell bearbeiten (Abschnitt 27 "Prämienverwaltung"). */
|
||||
export async function updatePremium(req, res, session) {
|
||||
if (!can(session, "SUPPORTER_MANAGE_PREMIUMS")) return forbidden(res);
|
||||
const body = req.body || {};
|
||||
const premium = db.prepare(`SELECT * FROM supporter_premiums WHERE id = ?`).get(body.premiumId);
|
||||
if (!premium) return json(res, { ok: false, error: "Prämie nicht gefunden." }, 404);
|
||||
|
||||
const ALLOWED_STATUS = [
|
||||
"verfuegbar", "eingeloest", "wird_geprueft", "wird_vorbereitet",
|
||||
"versandbereit", "versendet", "zugestellt", "erhalten", "problem", "ersatz_wird_vorbereitet",
|
||||
];
|
||||
const nextStatus = ALLOWED_STATUS.includes(body.redeemStatus) ? body.redeemStatus : premium.redeem_status;
|
||||
const trackingNumber = body.trackingNumber !== undefined ? body.trackingNumber : premium.tracking_number;
|
||||
const shippedAt = nextStatus === "versendet" && !premium.shipped_at ? nowIso() : premium.shipped_at;
|
||||
const deliveredAt = nextStatus === "zugestellt" && !premium.delivered_at ? nowIso() : premium.delivered_at;
|
||||
|
||||
db.prepare(
|
||||
`UPDATE supporter_premiums SET redeem_status = ?, tracking_number = ?, shipped_at = ?, delivered_at = ?, admin_note = ? WHERE id = ?`
|
||||
).run(nextStatus, trackingNumber || null, shippedAt, deliveredAt, body.adminNote ?? premium.admin_note, premium.id);
|
||||
|
||||
logAction(session.actor, "supporter.premium_updated", premium.id, {
|
||||
from: premium.redeem_status, to: nextStatus,
|
||||
});
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/** Kollektionen anlegen/bearbeiten (Abschnitt 27 "Kollektionen verwalten"). */
|
||||
export async function listCollections(req, res, session) {
|
||||
if (!can(session, "SUPPORTER_VIEW")) return forbidden(res);
|
||||
const rows = db.prepare(`SELECT * FROM supporter_collections ORDER BY collection_number ASC`).all();
|
||||
return json(res, { ok: true, collections: rows });
|
||||
}
|
||||
|
||||
export async function upsertCollection(req, res, session) {
|
||||
if (!can(session, "SUPPORTER_MANAGE_COLLECTIONS")) return forbidden(res);
|
||||
const body = req.body || {};
|
||||
const num = Number(body.collectionNumber);
|
||||
if (!Number.isFinite(num) || num < 1) return json(res, { ok: false, error: "Ungültige Kollektionsnummer." }, 400);
|
||||
|
||||
const existing = db.prepare(`SELECT id FROM supporter_collections WHERE collection_number = ?`).get(num);
|
||||
const id = existing?.id || generateId();
|
||||
const fields = {
|
||||
name: body.name || `Kollektion ${String(num).padStart(2, "0")}`,
|
||||
theme: body.theme || null,
|
||||
description: body.description || null,
|
||||
cover_image: body.coverImage || null,
|
||||
small_teddy_design: body.smallTeddyDesign || null,
|
||||
medium_teddy_design: body.mediumTeddyDesign || null,
|
||||
large_teddy_design: body.largeTeddyDesign || null,
|
||||
mug_design: body.mugDesign || null,
|
||||
card_design: body.cardDesign || null,
|
||||
hoodie_design: body.hoodieDesign || null,
|
||||
available_hoodie_sizes: JSON.stringify(body.availableHoodieSizes || ["S", "M", "L", "XL", "XXL"]),
|
||||
status: body.status === "published" ? "published" : "draft",
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
`UPDATE supporter_collections SET name=?, theme=?, description=?, cover_image=?, small_teddy_design=?,
|
||||
medium_teddy_design=?, large_teddy_design=?, mug_design=?, card_design=?, hoodie_design=?,
|
||||
available_hoodie_sizes=?, status=? WHERE id=?`
|
||||
).run(
|
||||
fields.name, fields.theme, fields.description, fields.cover_image, fields.small_teddy_design,
|
||||
fields.medium_teddy_design, fields.large_teddy_design, fields.mug_design, fields.card_design,
|
||||
fields.hoodie_design, fields.available_hoodie_sizes, fields.status, id
|
||||
);
|
||||
} else {
|
||||
db.prepare(
|
||||
`INSERT INTO supporter_collections
|
||||
(id, collection_number, name, theme, description, cover_image, small_teddy_design, medium_teddy_design,
|
||||
large_teddy_design, mug_design, card_design, hoodie_design, available_hoodie_sizes, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id, num, fields.name, fields.theme, fields.description, fields.cover_image, fields.small_teddy_design,
|
||||
fields.medium_teddy_design, fields.large_teddy_design, fields.mug_design, fields.card_design,
|
||||
fields.hoodie_design, fields.available_hoodie_sizes, fields.status, nowIso()
|
||||
);
|
||||
}
|
||||
|
||||
logAction(session.actor, "supporter.collection_saved", id, { collectionNumber: num });
|
||||
return json(res, { ok: true, id });
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/* =====================================================================
|
||||
routes/supporter-paypal.js — PayPal-Abo erstellen + Webhook-Verarbeitung
|
||||
(Dogfather_VanVan_Supporter_Abo.odt Abschnitt 6-8). 1:1 portiert aus
|
||||
cloudflare-worker/src/routes/supporter-paypal.js.
|
||||
|
||||
WICHTIG: `req.rawBody` (der unveränderte Roh-Text des Request-Bodys,
|
||||
von index.js im express.json()-verify-Callback gesetzt) wird für die
|
||||
PayPal-Signaturprüfung gebraucht — req.body ist bereits geparst und
|
||||
könnte beim Neuzusammenbau minimal vom Original abweichen (Reihenfolge/
|
||||
Whitespace), was die Signatur ungültig machen würde.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { generateId, nowIso } from "../lib/crypto.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import { createSubscription, verifyWebhookSignature, cancelSubscription, PayPalNotConfiguredError } from "../lib/paypal.js";
|
||||
import { allUnlockedMilestones } from "../lib/supporter-cycle.js";
|
||||
import { sendMilestoneUnlocked } from "../lib/supporter-mail.js";
|
||||
|
||||
const REDEEM_LABELS = {
|
||||
teddy_klein: "Kleiner Hasen-Teddy",
|
||||
teddy_mittel: "Mittelgroßer Hasen-Teddy",
|
||||
teddy_gross: "Großer Hasen-Teddy",
|
||||
tasse_karte: "Tasse & Autogrammkarte",
|
||||
hoodie: "Exklusiver Hoodie",
|
||||
};
|
||||
|
||||
function paypalNotConfiguredResponse(res) {
|
||||
return json(
|
||||
res,
|
||||
{
|
||||
ok: false,
|
||||
code: "PAYPAL_NOT_CONFIGURED",
|
||||
error:
|
||||
"PayPal ist auf dieser Website noch nicht eingerichtet. Der Websitebetreiber muss zuerst " +
|
||||
"PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, PAYPAL_PLAN_ID und PAYPAL_WEBHOOK_ID setzen " +
|
||||
"(siehe cloudflare-worker/README.md, Abschnitt 'Supporter-Abo Setup').",
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
/** Startet ein PayPal-Abonnement für den eingeloggten Supporter (Abschnitt 6, Schritt 6). */
|
||||
export async function startPaypalSubscription(req, res, supporter) {
|
||||
const body = req.body || {};
|
||||
const origin = body.origin || "https://dogfather-universe.com";
|
||||
|
||||
try {
|
||||
const { paypalSubscriptionId, approveUrl } = await createSubscription({
|
||||
supporterId: supporter.id,
|
||||
returnUrl: `${origin}/supporter.html?paypal=success`,
|
||||
cancelUrl: `${origin}/abonnieren.html?paypal=cancelled`,
|
||||
});
|
||||
if (!approveUrl) return json(res, { ok: false, error: "PayPal hat keinen Bestätigungslink geliefert." }, 502);
|
||||
|
||||
const subId = generateId();
|
||||
db.prepare(
|
||||
`INSERT INTO supporter_subscriptions
|
||||
(id, supporter_id, paypal_subscription_id, paypal_plan_id, status, total_paid_months, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)`
|
||||
).run(subId, supporter.id, paypalSubscriptionId, process.env.PAYPAL_PLAN_ID, nowIso(), nowIso());
|
||||
|
||||
logAction("system", "supporter.subscription_started", supporter.id, { paypalSubscriptionId });
|
||||
return json(res, { ok: true, approveUrl });
|
||||
} catch (err) {
|
||||
if (err instanceof PayPalNotConfiguredError) return paypalNotConfiguredResponse(res);
|
||||
return json(res, { ok: false, error: "PayPal-Abo konnte nicht erstellt werden." }, 502);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gegenstück zu den PayPal Smart-Payment-Buttons im Frontend: dort erzeugt
|
||||
* das PayPal-JS-SDK die Subscription direkt im Browser (zeigt dabei
|
||||
* automatisch alle für die Käuferin/den Käufer verfügbaren Zahlarten an —
|
||||
* PayPal-Guthaben, Bankkonto, Kreditkarte als Gast, je nach Land/Konto),
|
||||
* damit landet das Geld direkt auf Dogis PayPal-Konto. Der Browser
|
||||
* übergibt uns danach nur noch die fertige `subscriptionID`, die wir hier
|
||||
* mit dem Supporter-Konto verknüpfen. Der tatsächliche Aktivierungsstatus
|
||||
* kommt weiterhin ausschließlich über den geprüften Webhook (Abschnitt 6:
|
||||
* "Eine reine Weiterleitung... darf nicht ausreichen, um das Abo als aktiv
|
||||
* zu markieren").
|
||||
*/
|
||||
export async function confirmPaypalSubscription(req, res, supporter) {
|
||||
const body = req.body || {};
|
||||
const paypalSubscriptionId = String(body.subscriptionId || "").trim();
|
||||
if (!paypalSubscriptionId) return json(res, { ok: false, error: "Keine PayPal-Subscription-ID übergeben." }, 400);
|
||||
|
||||
// Rechts-Update 04.08.2026: Nachweis der Widerrufsrecht-Zustimmung
|
||||
// (sofortiger Leistungsbeginn bei digitalen Inhalten, § 356 Abs. 5 BGB /
|
||||
// entspr. Regelung, siehe agb.html Ziffer 6). Die Checkbox im Frontend
|
||||
// (abonnieren.html) blockiert das Laden der PayPal-Buttons bereits
|
||||
// clientseitig — diese serverseitige Prüfung verhindert zusätzlich eine
|
||||
// Umgehung per direktem API-Aufruf ohne echte Zustimmung.
|
||||
const widerrufConsentAt = String(body.widerrufConsentAt || "").trim();
|
||||
const consentDatum = widerrufConsentAt ? new Date(widerrufConsentAt) : null;
|
||||
const consentGueltig = consentDatum && !Number.isNaN(consentDatum.getTime()) && consentDatum.getTime() <= Date.now();
|
||||
if (!consentGueltig) {
|
||||
return json(res, { ok: false, error: "Bitte bestätige zuerst die Checkbox zum sofortigen Leistungsbeginn." }, 400);
|
||||
}
|
||||
|
||||
const existing = db.prepare(`SELECT id FROM supporter_subscriptions WHERE paypal_subscription_id = ?`).get(paypalSubscriptionId);
|
||||
if (existing) return json(res, { ok: true }); // schon verknüpft (z.B. doppelter Klick) — kein Fehler
|
||||
|
||||
const subId = generateId();
|
||||
db.prepare(
|
||||
`INSERT INTO supporter_subscriptions
|
||||
(id, supporter_id, paypal_subscription_id, paypal_plan_id, status, total_paid_months, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)`
|
||||
).run(subId, supporter.id, paypalSubscriptionId, process.env.PAYPAL_PLAN_ID, nowIso(), nowIso());
|
||||
|
||||
logAction("system", "supporter.subscription_confirmed_client_side", supporter.id, {
|
||||
paypalSubscriptionId,
|
||||
widerrufConsentAt: consentDatum.toISOString(),
|
||||
});
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function cancelPaypalSubscription(req, res, supporter) {
|
||||
const sub = db
|
||||
.prepare(
|
||||
`SELECT * FROM supporter_subscriptions WHERE supporter_id = ? AND status IN ('active','pending','suspended') ORDER BY created_at DESC LIMIT 1`
|
||||
)
|
||||
.get(supporter.id);
|
||||
if (!sub) return json(res, { ok: false, error: "Kein aktives Abonnement gefunden." }, 404);
|
||||
|
||||
try {
|
||||
if (sub.paypal_subscription_id) await cancelSubscription(sub.paypal_subscription_id);
|
||||
} catch (err) {
|
||||
if (err instanceof PayPalNotConfiguredError) return paypalNotConfiguredResponse(res);
|
||||
return json(res, { ok: false, error: "Kündigung bei PayPal fehlgeschlagen." }, 502);
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE supporter_subscriptions SET status = 'cancelled', cancelled_at = ?, updated_at = ? WHERE id = ?`).run(
|
||||
nowIso(),
|
||||
nowIso(),
|
||||
sub.id
|
||||
);
|
||||
logAction("system", "supporter.subscription_cancelled", supporter.id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/* ---------- Webhook (öffentlich erreichbar, aber signaturgeprüft) ---------- */
|
||||
|
||||
function findSubscriptionByPaypalId(paypalSubscriptionId) {
|
||||
if (!paypalSubscriptionId) return null;
|
||||
return db.prepare(`SELECT * FROM supporter_subscriptions WHERE paypal_subscription_id = ?`).get(paypalSubscriptionId);
|
||||
}
|
||||
|
||||
function alreadyProcessed(eventId) {
|
||||
if (!eventId) return false;
|
||||
const row = db.prepare(`SELECT id FROM supporter_payments WHERE paypal_event_id = ?`).get(eventId);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/** Erfolgreiche Zahlung: Monat anrechnen, ggf. neue Prämien freischalten (Abschnitt 8, 11-13). */
|
||||
async function handleSuccessfulPayment(sub, event, resource) {
|
||||
const isSandbox = process.env.PAYPAL_ENV !== "live";
|
||||
const eventId = event.id;
|
||||
const paymentId = generateId();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO supporter_payments
|
||||
(id, supporter_id, subscription_id, paypal_transaction_id, paypal_event_id, amount, currency,
|
||||
payment_status, is_sandbox, counted_for_progress, paid_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'completed', ?, ?, ?, ?)`
|
||||
).run(
|
||||
paymentId, sub.supporter_id, sub.id, resource.id || null, eventId,
|
||||
resource.amount?.total || resource.amount?.value || null,
|
||||
resource.amount?.currency || "EUR",
|
||||
isSandbox ? 1 : 0, isSandbox ? 0 : 1, nowIso(), nowIso()
|
||||
);
|
||||
|
||||
if (isSandbox) return; // Testzahlungen zählen NIE als echter Monat (Abschnitt 8/34).
|
||||
|
||||
const oldTotal = sub.total_paid_months;
|
||||
const newTotal = oldTotal + 1;
|
||||
db.prepare(`UPDATE supporter_subscriptions SET total_paid_months = ?, updated_at = ? WHERE id = ?`).run(newTotal, nowIso(), sub.id);
|
||||
|
||||
const before = new Set(allUnlockedMilestones(oldTotal).map((m) => `${m.cycleNumber}:${m.month}`));
|
||||
const after = allUnlockedMilestones(newTotal);
|
||||
const newlyUnlocked = after.filter((m) => !before.has(`${m.cycleNumber}:${m.month}`));
|
||||
|
||||
for (const m of newlyUnlocked) {
|
||||
// Kollektion für diesen Zyklus ermitteln (per collection_number = cycleNumber),
|
||||
// fällt auf Kollektion 1 zurück, falls eine spätere Kollektion im Admin-
|
||||
// Bereich noch nicht angelegt wurde — damit niemand ins Leere freigeschaltet wird.
|
||||
const collection =
|
||||
db.prepare(`SELECT id FROM supporter_collections WHERE collection_number = ?`).get(m.cycleNumber) ||
|
||||
db.prepare(`SELECT id FROM supporter_collections WHERE collection_number = 1`).get();
|
||||
if (!collection) continue;
|
||||
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO supporter_premiums
|
||||
(id, supporter_id, collection_id, cycle_number, milestone_month, premium_type, unlocked_at, redeem_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'verfuegbar')`
|
||||
).run(generateId(), sub.supporter_id, collection.id, m.cycleNumber, m.month, m.type, nowIso());
|
||||
|
||||
const supporterRow = db.prepare(`SELECT display_name, email FROM supporter_users WHERE id = ?`).get(sub.supporter_id);
|
||||
if (supporterRow) {
|
||||
await sendMilestoneUnlocked({
|
||||
to: supporterRow.email,
|
||||
name: supporterRow.display_name,
|
||||
premiumLabel: REDEEM_LABELS[m.type] || m.type,
|
||||
}).catch(() => {}); // E-Mail-Fehler dürfen die Webhook-Verarbeitung nie blockieren.
|
||||
}
|
||||
logAction("system", "supporter.milestone_unlocked", sub.supporter_id, { cycle: m.cycleNumber, month: m.month, type: m.type });
|
||||
}
|
||||
}
|
||||
|
||||
function handleReversedPayment(sub, event, resource) {
|
||||
const eventId = event.id;
|
||||
const paymentId = generateId();
|
||||
db.prepare(
|
||||
`INSERT INTO supporter_payments
|
||||
(id, supporter_id, subscription_id, paypal_transaction_id, paypal_event_id, amount, currency,
|
||||
payment_status, is_sandbox, counted_for_progress, paid_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`
|
||||
).run(
|
||||
paymentId, sub.supporter_id, sub.id, resource.id || null, eventId,
|
||||
resource.amount?.total || resource.amount?.value || null, resource.amount?.currency || "EUR",
|
||||
event.event_type.includes("REFUND") ? "refunded" : "reversed",
|
||||
process.env.PAYPAL_ENV !== "live" ? 1 : 0, nowIso(), nowIso()
|
||||
);
|
||||
|
||||
// Zurückgebuchte/zurückerstattete Zahlung: betroffenen Monat zur manuellen
|
||||
// Prüfung markieren statt automatisch zu löschen (Abschnitt 24 — "muss
|
||||
// manuell durch einen Administrator geprüft werden").
|
||||
logAction("admin", "supporter.payment_flagged_for_review", sub.supporter_id, {
|
||||
eventType: event.event_type,
|
||||
transactionId: resource.id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handlePaypalWebhook(req, res) {
|
||||
const rawBody = req.rawBody || JSON.stringify(req.body || {});
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return json(res, { ok: false, error: "Ungültiger Webhook-Body." }, 400);
|
||||
}
|
||||
|
||||
let verified = false;
|
||||
try {
|
||||
verified = await verifyWebhookSignature(req, rawBody);
|
||||
} catch (err) {
|
||||
if (err instanceof PayPalNotConfiguredError) return paypalNotConfiguredResponse(res);
|
||||
return json(res, { ok: false, error: "Webhook-Signaturprüfung fehlgeschlagen." }, 400);
|
||||
}
|
||||
if (!verified) return json(res, { ok: false, error: "Ungültige Webhook-Signatur." }, 400);
|
||||
|
||||
if (alreadyProcessed(event.id)) return json(res, { ok: true, duplicate: true });
|
||||
|
||||
const resource = event.resource || {};
|
||||
const type = event.event_type;
|
||||
|
||||
try {
|
||||
if (type === "BILLING.SUBSCRIPTION.ACTIVATED") {
|
||||
const sub = findSubscriptionByPaypalId(resource.id);
|
||||
if (sub) {
|
||||
db.prepare(
|
||||
`UPDATE supporter_subscriptions SET status = 'active', started_at = COALESCE(started_at, ?),
|
||||
next_payment_at = ?, updated_at = ? WHERE id = ?`
|
||||
).run(nowIso(), resource.billing_info?.next_billing_time || null, nowIso(), sub.id);
|
||||
}
|
||||
} else if (type === "BILLING.SUBSCRIPTION.UPDATED") {
|
||||
const sub = findSubscriptionByPaypalId(resource.id);
|
||||
if (sub) {
|
||||
db.prepare(`UPDATE supporter_subscriptions SET next_payment_at = ?, updated_at = ? WHERE id = ?`).run(
|
||||
resource.billing_info?.next_billing_time || sub.next_payment_at,
|
||||
nowIso(),
|
||||
sub.id
|
||||
);
|
||||
}
|
||||
} else if (type === "BILLING.SUBSCRIPTION.CANCELLED") {
|
||||
const sub = findSubscriptionByPaypalId(resource.id);
|
||||
if (sub) {
|
||||
db.prepare(
|
||||
`UPDATE supporter_subscriptions SET status = 'cancelled', cancelled_at = ?,
|
||||
paid_period_end_at = COALESCE(paid_period_end_at, next_payment_at), updated_at = ? WHERE id = ?`
|
||||
).run(nowIso(), nowIso(), sub.id);
|
||||
}
|
||||
} else if (type === "BILLING.SUBSCRIPTION.SUSPENDED") {
|
||||
const sub = findSubscriptionByPaypalId(resource.id);
|
||||
if (sub) db.prepare(`UPDATE supporter_subscriptions SET status = 'suspended', updated_at = ? WHERE id = ?`).run(nowIso(), sub.id);
|
||||
} else if (type === "BILLING.SUBSCRIPTION.EXPIRED") {
|
||||
const sub = findSubscriptionByPaypalId(resource.id);
|
||||
if (sub) db.prepare(`UPDATE supporter_subscriptions SET status = 'expired', updated_at = ? WHERE id = ?`).run(nowIso(), sub.id);
|
||||
} else if (type === "PAYMENT.SALE.COMPLETED") {
|
||||
const sub = findSubscriptionByPaypalId(resource.billing_agreement_id);
|
||||
if (sub) await handleSuccessfulPayment(sub, event, resource);
|
||||
} else if (type === "PAYMENT.SALE.DENIED") {
|
||||
const sub = findSubscriptionByPaypalId(resource.billing_agreement_id);
|
||||
if (sub) logAction("system", "supporter.payment_failed", sub.supporter_id, { transactionId: resource.id });
|
||||
} else if (type === "PAYMENT.SALE.REFUNDED" || type === "PAYMENT.SALE.REVERSED") {
|
||||
const sub = findSubscriptionByPaypalId(resource.billing_agreement_id);
|
||||
if (sub) handleReversedPayment(sub, event, resource);
|
||||
}
|
||||
// Unbekannte/nicht relevante Ereignistypen werden bewusst stillschweigend bestätigt
|
||||
// (PayPal wiederholt Zustellversuche sonst unnötig).
|
||||
} catch (err) {
|
||||
// Bewusst 200 statt 500 an PayPal zurückgeben nur, wenn der Fehler NICHT
|
||||
// die Zahlungsverarbeitung selbst betrifft — hier lassen wir echte
|
||||
// Fehler durchschlagen, damit PayPal es später erneut zustellt.
|
||||
return json(res, { ok: false, error: "Webhook-Verarbeitung fehlgeschlagen." }, 500);
|
||||
}
|
||||
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/* =====================================================================
|
||||
routes/supporter.js — Öffentlicher Supporter-Bereich: Registrierung,
|
||||
E-Mail-Bestätigung, Magic-Link-Login, persönliches Dashboard, Prämien-
|
||||
Einlösung, Aboverwaltung. 1:1 portiert aus
|
||||
cloudflare-worker/src/routes/supporter.js.
|
||||
|
||||
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 { json } from "../lib/http.js";
|
||||
import { logAction } from "../lib/audit.js";
|
||||
import {
|
||||
normalizeEmail, normalizeTikTokUsername, issueAuthCode, verifyAuthCode,
|
||||
createSupporterSession, endSupporterSession,
|
||||
} from "../lib/supporter-auth.js";
|
||||
import { EmailNotConfiguredError, sendVerifyEmailCode, sendLoginCode } from "../lib/supporter-mail.js";
|
||||
import { computeProgress, timelineForCurrentCycle } from "../lib/supporter-cycle.js";
|
||||
import { verifyGoogleIdToken, GoogleAuthNotConfiguredError } from "../lib/google-auth.js";
|
||||
|
||||
function bad(res, msg, status = 400) {
|
||||
return json(res, { ok: false, error: msg }, status);
|
||||
}
|
||||
|
||||
function emailErrorResponse(res, err) {
|
||||
if (err instanceof EmailNotConfiguredError || err.code === "EMAIL_NOT_CONFIGURED") {
|
||||
return json(
|
||||
res,
|
||||
{
|
||||
ok: false,
|
||||
error:
|
||||
"Der E-Mail-Versand ist auf dieser Website noch nicht eingerichtet (RESEND_API_KEY fehlt). " +
|
||||
"Bitte zuerst in der .env konfigurieren, siehe cloudflare-worker/README.md.",
|
||||
code: "EMAIL_NOT_CONFIGURED",
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
return json(res, { ok: false, error: "E-Mail konnte nicht verschickt werden." }, 502);
|
||||
}
|
||||
|
||||
/* ---------- Registrierung & Login (öffentlich, keine Session) ---------- */
|
||||
|
||||
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);
|
||||
|
||||
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 existing = db.prepare(`SELECT id, email_verified FROM supporter_users WHERE email = ?`).get(email);
|
||||
|
||||
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);
|
||||
}
|
||||
supporterId = existing.id;
|
||||
db.prepare(`UPDATE supporter_users SET display_name = ?, tiktok_username = ? WHERE id = ?`).run(displayName, tiktokUsername, 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());
|
||||
logAction("system", "supporter.registered", supporterId, { email });
|
||||
}
|
||||
|
||||
const code = await issueAuthCode(supporterId, "verify_email");
|
||||
try {
|
||||
await sendVerifyEmailCode({ to: email, name: displayName, code });
|
||||
} catch (err) {
|
||||
return emailErrorResponse(res, err);
|
||||
}
|
||||
return json(res, { ok: true, needsVerification: true });
|
||||
}
|
||||
|
||||
export async function verifySupporterEmail(req, res) {
|
||||
const body = req.body || {};
|
||||
const email = normalizeEmail(body.email);
|
||||
const code = String(body.code || "").trim();
|
||||
if (!email || !code) return bad(res, "E-Mail und Code werden benötigt.");
|
||||
|
||||
const supporter = db.prepare(`SELECT id, display_name FROM supporter_users WHERE email = ?`).get(email);
|
||||
if (!supporter) return bad(res, "Kein Supporter-Konto mit dieser E-Mail-Adresse gefunden.", 404);
|
||||
|
||||
const valid = await verifyAuthCode(supporter.id, "verify_email", code);
|
||||
if (!valid) return bad(res, "Der Code ist ungültig oder abgelaufen.", 401);
|
||||
|
||||
db.prepare(`UPDATE supporter_users SET email_verified = 1, last_login_at = ? WHERE id = ?`).run(nowIso(), supporter.id);
|
||||
logAction("system", "supporter.email_verified", supporter.id, null);
|
||||
|
||||
const token = await createSupporterSession(supporter.id);
|
||||
return json(res, { ok: true, token });
|
||||
}
|
||||
|
||||
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'`)
|
||||
.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 });
|
||||
|
||||
const code = await issueAuthCode(supporter.id, "login");
|
||||
try {
|
||||
await sendLoginCode({ to: email, name: supporter.display_name, code });
|
||||
} catch (err) {
|
||||
return emailErrorResponse(res, err);
|
||||
}
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function verifySupporterLogin(req, res) {
|
||||
const body = req.body || {};
|
||||
const email = normalizeEmail(body.email);
|
||||
const code = String(body.code || "").trim();
|
||||
if (!email || !code) return bad(res, "E-Mail und Code werden benötigt.");
|
||||
|
||||
const supporter = db.prepare(`SELECT id FROM supporter_users WHERE email = ? AND status = 'active'`).get(email);
|
||||
if (!supporter) return bad(res, "Kein Supporter-Konto gefunden.", 404);
|
||||
|
||||
const valid = await verifyAuthCode(supporter.id, "login", code);
|
||||
if (!valid) return bad(res, "Der Code ist ungültig oder abgelaufen.", 401);
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* "Mit Google anmelden" — deckt Registrierung UND Login gleichzeitig ab:
|
||||
* Google hat die E-Mail-Adresse bereits selbst bestätigt, deshalb entfällt
|
||||
* hier der sonst nötige Bestätigungscode. Existiert schon ein Konto mit
|
||||
* dieser E-Mail (egal ob per Code oder vorher schon per Google angelegt),
|
||||
* wird es einfach eingeloggt statt doppelt erzeugt.
|
||||
*/
|
||||
export async function googleLoginSupporter(req, res) {
|
||||
const body = req.body || {};
|
||||
let result;
|
||||
try {
|
||||
result = await verifyGoogleIdToken(body.credential);
|
||||
} catch (err) {
|
||||
if (err instanceof GoogleAuthNotConfiguredError) {
|
||||
return json(
|
||||
res,
|
||||
{
|
||||
ok: false,
|
||||
code: "GOOGLE_AUTH_NOT_CONFIGURED",
|
||||
error: "Google-Login ist auf dieser Website noch nicht eingerichtet.",
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
return json(res, { ok: false, error: "Google-Anmeldung fehlgeschlagen." }, 502);
|
||||
}
|
||||
if (!result.ok) return bad(res, result.reason || "Google-Anmeldung fehlgeschlagen.", 401);
|
||||
|
||||
let supporter = db.prepare(`SELECT id FROM supporter_users WHERE email = ? AND status = 'active'`).get(result.email);
|
||||
|
||||
if (!supporter) {
|
||||
const id = generateId();
|
||||
db.prepare(
|
||||
`INSERT INTO supporter_users (id, display_name, email, email_verified, created_at, last_login_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?)`
|
||||
).run(id, result.name, result.email, nowIso(), nowIso());
|
||||
supporter = { id };
|
||||
logAction("system", "supporter.registered_via_google", supporter.id, { email: result.email });
|
||||
} else {
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentliche, NICHT geheime Konfiguration fürs Frontend (Client-IDs sind
|
||||
* per Design öffentlich — nur die zugehörigen Secrets sind es nicht).
|
||||
* Fehlt ein Wert, bleibt der jeweilige Button im Frontend einfach
|
||||
* ausgeblendet statt kaputt zu sein.
|
||||
*/
|
||||
export async function getPublicConfig(req, res) {
|
||||
return json(res, {
|
||||
ok: true,
|
||||
googleClientId: process.env.GOOGLE_CLIENT_ID || null,
|
||||
paypalClientId: process.env.PAYPAL_CLIENT_ID || null,
|
||||
paypalPlanId: process.env.PAYPAL_PLAN_ID || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function logoutSupporter(req, res) {
|
||||
const auth = req.headers["authorization"] || "";
|
||||
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
|
||||
await endSupporterSession(token);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/* ---------- Geschützter Supporter-Bereich (Session erforderlich) ---------- */
|
||||
|
||||
const REDEEM_LABELS = {
|
||||
teddy_klein: "Kleiner Hasen-Teddy",
|
||||
teddy_mittel: "Mittelgroßer Hasen-Teddy",
|
||||
teddy_gross: "Großer Hasen-Teddy",
|
||||
tasse_karte: "Tasse & Autogrammkarte",
|
||||
hoodie: "Exklusiver Hoodie",
|
||||
};
|
||||
|
||||
/** Baut die vollständige Dashboard-Antwort (Fenster 1+2+Archiv, Abschnitt 10/15). */
|
||||
function buildDashboard(supporter) {
|
||||
const sub = db
|
||||
.prepare(`SELECT * FROM supporter_subscriptions WHERE supporter_id = ? ORDER BY created_at DESC LIMIT 1`)
|
||||
.get(supporter.id);
|
||||
|
||||
const totalPaidMonths = sub?.total_paid_months || 0;
|
||||
const progress = computeProgress(totalPaidMonths);
|
||||
const timeline = timelineForCurrentCycle(totalPaidMonths);
|
||||
|
||||
const premiumsRows = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as collection_name, c.collection_number
|
||||
FROM supporter_premiums p JOIN supporter_collections c ON c.id = p.collection_id
|
||||
WHERE p.supporter_id = ? ORDER BY p.cycle_number ASC, p.milestone_month ASC`
|
||||
)
|
||||
.all(supporter.id);
|
||||
const premiums = premiumsRows.map((p) => ({ ...p, label: REDEEM_LABELS[p.premium_type] || p.premium_type }));
|
||||
|
||||
// Archiv: nach Kollektion gruppiert (Abschnitt 15 — abgeschlossene Kollektionen bleiben sichtbar).
|
||||
const byCollection = new Map();
|
||||
for (const p of premiums) {
|
||||
const key = p.collection_number;
|
||||
if (!byCollection.has(key)) byCollection.set(key, { collectionNumber: key, collectionName: p.collection_name, premiums: [] });
|
||||
byCollection.get(key).premiums.push(p);
|
||||
}
|
||||
|
||||
// Zahlungshistorie (Nutzer-Wunsch 04.08.2026: "die Kisten sollen beim
|
||||
// Klicken viel mehr Infos zeigen") — für die Detailansicht "Nächste
|
||||
// Zahlung" im Frontend. sub kann null sein (noch nie ein Abo gehabt).
|
||||
let payments = [];
|
||||
if (sub) {
|
||||
payments = db
|
||||
.prepare(
|
||||
`SELECT amount, currency, payment_status, paid_at, is_sandbox
|
||||
FROM supporter_payments WHERE supporter_id = ? ORDER BY paid_at DESC LIMIT 36`
|
||||
)
|
||||
.all(supporter.id);
|
||||
}
|
||||
|
||||
return {
|
||||
profile: {
|
||||
displayName: supporter.display_name,
|
||||
tiktokUsername: supporter.tiktok_username,
|
||||
email: supporter.email,
|
||||
hasShippingAddress: !!(supporter.ship_street && supporter.ship_zip),
|
||||
},
|
||||
subscription: sub
|
||||
? {
|
||||
status: sub.status,
|
||||
startedAt: sub.started_at,
|
||||
nextPaymentAt: sub.next_payment_at,
|
||||
paidPeriodEndAt: sub.paid_period_end_at,
|
||||
cancelledAt: sub.cancelled_at,
|
||||
monthlyPrice: "4,99 €",
|
||||
totalPaidMonths,
|
||||
}
|
||||
: { status: "none", totalPaidMonths: 0, monthlyPrice: "4,99 €" },
|
||||
progress,
|
||||
timeline,
|
||||
premiums,
|
||||
archive: [...byCollection.values()].sort((a, b) => a.collectionNumber - b.collectionNumber),
|
||||
payments,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSupporterMe(req, res, supporter) {
|
||||
return json(res, { ok: true, ...buildDashboard(supporter) });
|
||||
}
|
||||
|
||||
export async function updateSupporterProfile(req, res, supporter) {
|
||||
const body = req.body || {};
|
||||
const displayName = body.displayName !== undefined ? String(body.displayName).trim().slice(0, 60) : supporter.display_name;
|
||||
const tiktokUsername =
|
||||
body.tiktokUsername !== undefined ? normalizeTikTokUsername(body.tiktokUsername) : supporter.tiktok_username;
|
||||
if (!displayName) return bad(res, "Der Name darf nicht leer sein.");
|
||||
|
||||
db.prepare(`UPDATE supporter_users SET display_name = ?, tiktok_username = ? WHERE id = ?`).run(displayName, tiktokUsername, supporter.id);
|
||||
logAction("system", "supporter.profile_updated", supporter.id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function requestSupporterEmailChange(req, res, supporter) {
|
||||
const body = req.body || {};
|
||||
const newEmail = normalizeEmail(body.newEmail);
|
||||
if (!newEmail || !newEmail.includes("@")) return bad(res, "Bitte gib eine gültige neue E-Mail-Adresse an.");
|
||||
|
||||
const clash = db.prepare(`SELECT id FROM supporter_users WHERE email = ? AND id != ?`).get(newEmail, supporter.id);
|
||||
if (clash) return bad(res, "Diese E-Mail-Adresse wird bereits verwendet.", 409);
|
||||
|
||||
db.prepare(`UPDATE supporter_users SET pending_email = ? WHERE id = ?`).run(newEmail, supporter.id);
|
||||
const code = await issueAuthCode(supporter.id, "change_email");
|
||||
try {
|
||||
await sendVerifyEmailCode({ to: newEmail, name: supporter.display_name, code });
|
||||
} catch (err) {
|
||||
return emailErrorResponse(res, err);
|
||||
}
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function confirmSupporterEmailChange(req, res, supporter) {
|
||||
const body = req.body || {};
|
||||
const code = String(body.code || "").trim();
|
||||
if (!supporter.pending_email) return bad(res, "Es liegt keine E-Mail-Änderung vor.");
|
||||
const valid = await verifyAuthCode(supporter.id, "change_email", code);
|
||||
if (!valid) return bad(res, "Der Code ist ungültig oder abgelaufen.", 401);
|
||||
|
||||
db.prepare(`UPDATE supporter_users SET email = pending_email, pending_email = NULL WHERE id = ?`).run(supporter.id);
|
||||
logAction("system", "supporter.email_changed", supporter.id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
/** Prämie einlösen (Abschnitt 17) — Versandadresse erst hier, nicht bei der Registrierung. */
|
||||
export async function redeemPremium(req, res, supporter) {
|
||||
const body = req.body || {};
|
||||
const premium = db.prepare(`SELECT * FROM supporter_premiums WHERE id = ? AND supporter_id = ?`).get(body.premiumId, supporter.id);
|
||||
if (!premium) return bad(res, "Prämie nicht gefunden.", 404);
|
||||
if (premium.redeem_status !== "verfuegbar") return bad(res, "Diese Prämie wurde bereits eingelöst oder ist gesperrt.", 409);
|
||||
|
||||
const useSaved = !!body.useSavedAddress && supporter.ship_street;
|
||||
const addr = useSaved
|
||||
? {
|
||||
name: supporter.ship_name, street: supporter.ship_street, zip: supporter.ship_zip,
|
||||
city: supporter.ship_city, country: supporter.ship_country,
|
||||
}
|
||||
: {
|
||||
name: String(body.shipName || "").trim(),
|
||||
street: String(body.shipStreet || "").trim(),
|
||||
zip: String(body.shipZip || "").trim(),
|
||||
city: String(body.shipCity || "").trim(),
|
||||
country: String(body.shipCountry || "").trim(),
|
||||
};
|
||||
if (!addr.name || !addr.street || !addr.zip || !addr.city || !addr.country) {
|
||||
return bad(res, "Bitte fülle alle Adressfelder aus.");
|
||||
}
|
||||
|
||||
if (premium.premium_type === "hoodie" && !body.hoodieSize) {
|
||||
return bad(res, "Bitte wähle eine Hoodie-Größe.");
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE supporter_premiums SET redeem_status = 'eingeloest', redeemed_at = ?,
|
||||
ship_name = ?, ship_street = ?, ship_zip = ?, ship_city = ?, ship_country = ?,
|
||||
hoodie_size = ?, hoodie_variant = ?
|
||||
WHERE id = ?`
|
||||
).run(nowIso(), addr.name, addr.street, addr.zip, addr.city, addr.country, body.hoodieSize || null, body.hoodieVariant || null, premium.id);
|
||||
|
||||
if (body.saveAddress) {
|
||||
db.prepare(
|
||||
`UPDATE supporter_users SET ship_name=?, ship_street=?, ship_zip=?, ship_city=?, ship_country=?, ship_save_address=1 WHERE id=?`
|
||||
).run(addr.name, addr.street, addr.zip, addr.city, addr.country, supporter.id);
|
||||
}
|
||||
|
||||
logAction("system", "supporter.premium_redeemed", supporter.id, { premiumId: premium.id, type: premium.premium_type });
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
export async function deleteSupporterAccount(req, res, supporter) {
|
||||
// Datenschutz (Abschnitt 30): Konto löschbar. Vertrags-/Zahlungsdaten
|
||||
// bleiben aus gesetzlichen Aufbewahrungspflichten bestehen (nur an den
|
||||
// Nutzerdatensatz selbst wird nicht mehr referenziert) — deshalb hier
|
||||
// bewusst "soft delete" statt hartem DELETE der Zahlungshistorie.
|
||||
db.prepare(`UPDATE supporter_users SET status = 'deleted', email = email || '.geloescht.' || id WHERE id = ?`).run(supporter.id);
|
||||
logAction("system", "supporter.account_deleted", supporter.id, null);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||