Eigene Zugangscode-Schranke statt Cloudflare Access — löst VanVans Login-Problem
Ersetzt die (fehlerhaft auf die GANZE Domain wirkende) Cloudflare-Zero- Trust-Access-Sperre durch eine eigene, schlanke Lösung nach dem bewährten Vorbild von "Dogfather Universe": eine Pages-Functions-Middleware (functions/_middleware.js) prüft bei jeder Anfrage ein HMAC-signiertes Session-Cookie; ohne gültiges Cookie geht's zu public/gate.html (im Van's- Design), wo ein Zugangscode eingegeben wird. Zwei getrennte Codes für qciga und VanVan, 90 Tage gültig, danach neu eingeben. /admin/* ist komplett ausgenommen — läuft weiter über das echte GitHub-OAuth-Login, das für VanVan schon funktioniert. Bonus: die drei separaten Vorschau-Seiten (demo-normal/demo-konto/demo-abo) sind jetzt überflüssig und entfernt. Stattdessen gibt's ein schwebendes Umschalter-Widget (Layout.astro), das NUR sichtbar ist, wenn das Gate-Cookie vorhanden ist (also nur für uns beide) — auf jeder beliebigen Seite kann per Knopfdruck zwischen "Normaler Besucher", "Angemeldete Kundin" und "Abonnentin" gewechselt werden, ohne die Seite verlassen zu müssen. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
6b1920dfb3
commit
ad07584906
@@ -6,3 +6,5 @@ dist/
|
||||
!.env.example
|
||||
.DS_Store
|
||||
*.log
|
||||
.dev.vars
|
||||
.wrangler/
|
||||
|
||||
+1
-4
@@ -8,10 +8,7 @@ const SITE_URL = 'https://www.vans-diy-bastelbedarf.de';
|
||||
|
||||
export default defineConfig({
|
||||
site: SITE_URL,
|
||||
// /demo-*/ sind interne Vorschau-Seiten (simulierte Konto-Zustände für VanVan und mich zum
|
||||
// Anschauen) — sollen nie in der Sitemap/Suchmaschinen auftauchen (siehe zusätzlich noindex-Meta
|
||||
// in den Seiten selbst).
|
||||
integrations: [sitemap({ filter: (page) => !page.includes('/demo-abo/') && !page.includes('/demo-konto/') && !page.includes('/demo-normal/') })],
|
||||
integrations: [sitemap()],
|
||||
vite: {
|
||||
server: {
|
||||
// Erlaubt Zugriff über den temporären cloudflared-Quick-Tunnel (nur für lokale
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/* =====================================================================
|
||||
functions/_middleware.js — Zugangs-Schranke vor der ganzen Website.
|
||||
|
||||
Van's DIY & Bastelbedarf ist technisch schon live (Cloudflare Pages),
|
||||
soll aber noch nicht öffentlich einsehbar sein — nur qcigano und VanVan
|
||||
sollen jederzeit von überall reinkommen können, ohne dass es eine
|
||||
Enterprise-Lösung wie Cloudflare Access/Zero Trust braucht (die kostet ab
|
||||
einer gewissen Nutzerzahl und bräuchte einen separaten Login-Flow, der
|
||||
VanVan genau die Probleme gemacht hat, die zu diesem Umbau geführt haben).
|
||||
Gleiches Prinzip wie beim Dogfather-Universe-Projekt, nur als Cloudflare
|
||||
PAGES FUNCTIONS Middleware statt Workers-Static-Assets (Pages hat kein
|
||||
env.ASSETS.fetch() — hier übernimmt context.next() dieselbe Rolle: die
|
||||
eigentliche Seite normal weiter ausliefern).
|
||||
|
||||
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
|
||||
"vandiy_gate_session" (90 Tage) wird gesetzt + ein zweites, NICHT-
|
||||
HttpOnly Cookie "vandiy_gate_role" (qciga/vanvan) — rein zur Anzeige des
|
||||
Vorschau-Umschalters im Frontend (siehe Layout.astro), kein
|
||||
Sicherheits-Token.
|
||||
- Gültiges Session-Cookie -> normale Auslieferung über context.next().
|
||||
- /admin/* läuft IMMER unangetastet durch diese Schranke durch — der
|
||||
Adminbereich hat sein eigenes, echtes Login (GitHub-OAuth über den
|
||||
cms-auth-Worker) und funktioniert für VanVan bereits einwandfrei; eine
|
||||
zusätzliche Schranke davor würde nur doppelt verwirren.
|
||||
|
||||
Die Signatur (HMAC-SHA256 über Web Crypto, in jeder Workers/Pages-Runtime
|
||||
eingebaut) verhindert, dass jemand sich einfach selbst ein
|
||||
"vandiy_gate_session=irgendwas"-Cookie setzt.
|
||||
===================================================================== */
|
||||
|
||||
const COOKIE_NAME = "vandiy_gate_session";
|
||||
const ROLE_COOKIE_NAME = "vandiy_gate_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;
|
||||
}
|
||||
|
||||
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();
|
||||
const next = typeof body.next === "string" && body.next.startsWith("/") ? body.next : "/";
|
||||
|
||||
let role = null;
|
||||
if (env.SITE_ACCESS_CODE_QCIGA && code === env.SITE_ACCESS_CODE_QCIGA) role = "qciga";
|
||||
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 async function onRequest(context) {
|
||||
const { request, next, env } = context;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// /admin/* NIE anfassen — eigenes, echtes GitHub-OAuth-Login, funktioniert für VanVan bereits.
|
||||
if (url.pathname.startsWith("/admin")) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Fail-open-Absicherung: fehlen die Zugangs-Secrets (z.B. noch nicht gesetzt), lieber die
|
||||
// Schranke NICHT scharf schalten, als sich selbst aus der eigenen Seite auszusperren.
|
||||
if (!env.SITE_ACCESS_SECRET || (!env.SITE_ACCESS_CODE_QCIGA && !env.SITE_ACCESS_CODE_VANVAN)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
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 next();
|
||||
}
|
||||
|
||||
// gate.html selbst muss ohne Session ladbar sein, sonst kommt niemand überhaupt bis zur
|
||||
// Eingabemaske. Ist komplett selbst-gestylt (inline CSS, keine externen Assets) — deshalb
|
||||
// reicht diese eine Ausnahme, es müssen keine CSS-/Bild-Pfade extra freigegeben werden.
|
||||
// WICHTIG: Cloudflare Pages leitet "/gate.html" standardmäßig automatisch auf die
|
||||
// "saubere" URL "/gate" um (html_handling) — beide Schreibweisen müssen hier durchgelassen
|
||||
// werden, sonst entsteht eine Endlosschleife (gate.html -> Redirect auf /gate -> Middleware
|
||||
// kennt /gate nicht -> Redirect zurück auf gate.html -> ...).
|
||||
if (url.pathname === "/gate.html" || url.pathname === "/gate") {
|
||||
return next();
|
||||
}
|
||||
|
||||
const nextParam = url.pathname + url.search;
|
||||
return Response.redirect(`${url.origin}/gate?next=${encodeURIComponent(nextParam)}`, 302);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Zugang — Van's DIY & Bastelbedarf</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<style>
|
||||
:root {
|
||||
--c-black: #0a0910;
|
||||
--c-anthracite: #1b1a22;
|
||||
--c-anthracite-light: #252333;
|
||||
--c-text: #f4f1f7;
|
||||
--c-text-muted: #b7aec4;
|
||||
--c-accent: #5fd3ec;
|
||||
--c-accent-hover: #3ca9c2;
|
||||
--c-purple-glow: #9b7fd6;
|
||||
--radius-l: 22px;
|
||||
--radius-m: 16px;
|
||||
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background: var(--c-black);
|
||||
background-image: radial-gradient(60% 50% at 15% 10%, color-mix(in srgb, var(--c-purple-glow) 20%, transparent), transparent 70%),
|
||||
radial-gradient(50% 45% at 85% 90%, color-mix(in srgb, var(--c-accent) 14%, transparent), transparent 70%);
|
||||
color: var(--c-text);
|
||||
font-family: "Inter", "Manrope", system-ui, sans-serif;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.gate-box {
|
||||
position: relative;
|
||||
max-width: 430px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 2.4rem 2rem;
|
||||
border-radius: var(--radius-l);
|
||||
border: 1.5px solid transparent;
|
||||
background:
|
||||
linear-gradient(165deg, var(--c-anthracite-light), var(--c-anthracite)) padding-box,
|
||||
linear-gradient(120deg, #9b7fd6, var(--c-accent), #e3b23c, #9b7fd6) border-box;
|
||||
background-size: 100% 100%, 300% 100%;
|
||||
animation: gate-border-shift 9s ease-in-out infinite;
|
||||
box-shadow: 0 20px 50px -20px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
@keyframes gate-border-shift {
|
||||
0%, 100% { background-position: 0 0, 0% 50%; }
|
||||
50% { background-position: 0 0, 100% 50%; }
|
||||
}
|
||||
.gate-eyebrow {
|
||||
display: inline-block;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #e3b23c;
|
||||
background: color-mix(in srgb, #e3b23c 16%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #e3b23c 40%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.gate-box h1 {
|
||||
font-family: "Fraunces", "Playfair Display", Georgia, serif;
|
||||
font-size: 1.7rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.6rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.gate-box p {
|
||||
margin: 0 0 1.4rem;
|
||||
color: var(--c-text-muted);
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
.gate-box input {
|
||||
width: 100%;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 0.9rem;
|
||||
background: rgba(244, 241, 247, 0.05);
|
||||
border: 1.5px solid rgba(244, 241, 247, 0.16);
|
||||
color: var(--c-text);
|
||||
font-family: inherit;
|
||||
}
|
||||
.gate-box input:focus {
|
||||
outline: none;
|
||||
border-color: var(--c-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-accent) 25%, transparent);
|
||||
}
|
||||
.gate-box button {
|
||||
width: 100%;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: linear-gradient(120deg, var(--c-accent), var(--c-accent-hover));
|
||||
color: var(--c-black);
|
||||
font-weight: 800;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s var(--ease), box-shadow 0.2s var(--ease);
|
||||
}
|
||||
.gate-box button:hover { transform: translateY(-2px); box-shadow: 0 10px 24px -10px color-mix(in srgb, var(--c-accent) 60%, transparent); }
|
||||
.gate-fehler {
|
||||
color: #ff9a9a;
|
||||
font-size: 0.88rem;
|
||||
margin: 0.9rem 0 0;
|
||||
display: none;
|
||||
}
|
||||
.gate-fehler.zeige { display: block; }
|
||||
.gate-logo {
|
||||
font-family: "Fraunces", "Playfair Display", Georgia, serif;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: var(--c-text-muted);
|
||||
margin: 1.6rem 0 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="gate-box">
|
||||
<span class="gate-eyebrow">🔒 Noch nicht öffentlich</span>
|
||||
<h1>Van's DIY & Bastelbedarf</h1>
|
||||
<p>Diese Seite ist gerade im Aufbau und noch nicht für die Öffentlichkeit freigegeben.<br />Zugangscode eingeben, um weiterzukommen.</p>
|
||||
<form id="gate-form">
|
||||
<input type="text" id="gate-code" placeholder="Zugangscode" autocomplete="off" autocapitalize="off" spellcheck="false" autofocus />
|
||||
<button type="submit">Weiter</button>
|
||||
</form>
|
||||
<p class="gate-fehler" id="gate-fehler"></p>
|
||||
<p class="gate-logo">🧶 Handgemacht mit Liebe</p>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const next = params.get("next") || "/";
|
||||
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 || "/";
|
||||
} catch {
|
||||
fehlerEl.textContent = "Verbindung fehlgeschlagen. Bitte nochmal versuchen.";
|
||||
fehlerEl.classList.add("zeige");
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -179,10 +179,116 @@ const p = (href: string) => `${localePrefix(lang)}${href}`;
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Vorschau-Umschalter: nur sichtbar, wenn im Browser ein gültiges Gate-Zugangscookie liegt
|
||||
(siehe functions/_middleware.js + public/gate.html) — also nur für uns beide (qciga/vanvan),
|
||||
niemals für echte Besucher:innen. Ersetzt die früheren separaten demo-Vorschauseiten: EINE
|
||||
schwebende Box, auf JEDER Seite nutzbar, simuliert per Knopfdruck sofort den gewählten
|
||||
Konto-Zustand (localStorage) und lädt die aktuelle Seite neu. */}
|
||||
<div id="vandiy-preview-switcher" style="display:none;">
|
||||
<button type="button" id="vandiy-preview-toggle" aria-expanded="false" aria-label="Vorschau-Ansicht wählen">🎭</button>
|
||||
<div id="vandiy-preview-panel">
|
||||
<p class="small" id="vandiy-preview-current"></p>
|
||||
<button type="button" data-preview="normal">👤 Normaler Besucher</button>
|
||||
<button type="button" data-preview="konto">🙋 Angemeldete Kundin</button>
|
||||
<button type="button" data-preview="abo">🎫 Abonnentin</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { cartCount } from "../scripts/cart";
|
||||
import { getAccount, logout, anzeigeName } from "../scripts/account";
|
||||
|
||||
// Vorschau-Umschalter: Sichtbarkeit hängt allein vom (nicht-httpOnly) Rollen-Cookie ab, das
|
||||
// die Gate-Middleware nach erfolgreicher Zugangscode-Eingabe setzt.
|
||||
(function initPreviewSwitcher() {
|
||||
const match = document.cookie.match(/(?:^|;\s*)vandiy_gate_role=([^;]+)/);
|
||||
const role = match ? decodeURIComponent(match[1]) : null;
|
||||
if (!role) return;
|
||||
|
||||
const widget = document.getElementById("vandiy-preview-switcher");
|
||||
const toggle = document.getElementById("vandiy-preview-toggle");
|
||||
const panel = document.getElementById("vandiy-preview-panel");
|
||||
const currentLabel = document.getElementById("vandiy-preview-current");
|
||||
if (!widget || !toggle || !panel) return;
|
||||
widget.style.display = "block";
|
||||
|
||||
const roleLabel = role === "qciga" ? "qciga" : "VanVan";
|
||||
function beschreibeAktuell() {
|
||||
try {
|
||||
const raw = localStorage.getItem("vandiy_account_v1");
|
||||
const a = raw ? JSON.parse(raw) : null;
|
||||
if (!a || !a.loggedIn) return "Aktuell: normaler Besucher";
|
||||
if (a.abo && a.abo.stufe) return "Aktuell: Abonnentin (" + a.abo.stufe + ")";
|
||||
return "Aktuell: angemeldete Kundin";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
if (currentLabel) currentLabel.textContent = `${beschreibeAktuell()} · als ${roleLabel} angemeldet`;
|
||||
|
||||
toggle.addEventListener("click", () => {
|
||||
const open = panel.classList.toggle("open");
|
||||
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
});
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!widget.contains(e.target as Node)) panel.classList.remove("open");
|
||||
});
|
||||
|
||||
const heute = new Date().toISOString().slice(0, 10);
|
||||
|
||||
function leeresKonto(zusatz: Record<string, unknown>) {
|
||||
return {
|
||||
loggedIn: true,
|
||||
name: "Vorschau",
|
||||
username: roleLabel,
|
||||
email: "[email protected]",
|
||||
photo: "",
|
||||
bewerteteBestellungen: [],
|
||||
treueBestellungen: 0,
|
||||
treuebonusAktiv: false,
|
||||
treuebonusArtikelSlug: null,
|
||||
abo: {
|
||||
stufe: null, startDatum: null, bezahltBisDatum: null, bezahlteMonate: 0,
|
||||
gekuendigt: false, gekuendigtAm: null, kleinTeddyFreigeschaltetGesamt: 0,
|
||||
grossTeddyFreigeschaltetGesamt: 0, kleinTeddyEingeloest: 0, grossTeddyEingeloest: 0,
|
||||
historie: [],
|
||||
},
|
||||
benachrichtigungen: [],
|
||||
...zusatz,
|
||||
};
|
||||
}
|
||||
|
||||
const PRESETS: Record<string, () => void> = {
|
||||
normal: () => localStorage.removeItem("vandiy_account_v1"),
|
||||
konto: () => localStorage.setItem("vandiy_account_v1", JSON.stringify(leeresKonto({ treueBestellungen: 4 }))),
|
||||
abo: () => localStorage.setItem("vandiy_account_v1", JSON.stringify(leeresKonto({
|
||||
treueBestellungen: 6,
|
||||
abo: {
|
||||
stufe: "gross", startDatum: "2025-06-03", bezahltBisDatum: "2026-09-03", bezahlteMonate: 14,
|
||||
gekuendigt: false, gekuendigtAm: null, kleinTeddyFreigeschaltetGesamt: 2,
|
||||
grossTeddyFreigeschaltetGesamt: 1, kleinTeddyEingeloest: 1, grossTeddyEingeloest: 1,
|
||||
historie: [],
|
||||
},
|
||||
benachrichtigungen: [
|
||||
{ key: "abo_teddy_klein_frei", datum: heute, gelesen: false },
|
||||
{ key: "abo_zahlung_erfolgreich", datum: heute, gelesen: true },
|
||||
{ key: "abo_teddy_gross_eingeloest", datum: heute, gelesen: true },
|
||||
{ key: "abo_abgeschlossen", datum: "2025-06-03", gelesen: true },
|
||||
],
|
||||
}))),
|
||||
};
|
||||
|
||||
panel.querySelectorAll<HTMLButtonElement>("[data-preview]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const preset = btn.dataset.preview;
|
||||
if (preset && PRESETS[preset]) {
|
||||
PRESETS[preset]();
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
function updateCount() {
|
||||
const el = document.getElementById("cart-count");
|
||||
if (el) el.textContent = String(cartCount());
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import type { Locale } from "../../i18n/config";
|
||||
|
||||
const lang: Locale = "de";
|
||||
|
||||
// Rein internes Vorschau-Konto (nicht verlinkt, nicht in der Sitemap, noindex) — zeigt, wie das
|
||||
// Kundenkonto MIT aktivem Abo aussieht: Großes Abo, 14 durchgehend bezahlte Monate, ein noch
|
||||
// verfügbarer kleiner Teddy, ein bereits eingelöster großer Teddy, ein paar realistische
|
||||
// Benachrichtigungen und laufender Treuebonus-Fortschritt (nur mit aktivem Abo überhaupt aktiv,
|
||||
// siehe scripts/account.ts). Wird per JS ins localStorage DIESES Browsers geschrieben, sobald
|
||||
// die Seite aufgerufen wird — ändert nichts an echten Kundendaten, jede Person, die den Link
|
||||
// öffnet (auch VanVan), sieht die Vorschau in ihrem eigenen Browser.
|
||||
const demoAccount = {
|
||||
loggedIn: true,
|
||||
name: "Vorschau Kundin",
|
||||
username: "Vorschau",
|
||||
email: "[email protected]",
|
||||
photo: "",
|
||||
bewerteteBestellungen: [],
|
||||
treueBestellungen: 6,
|
||||
treuebonusAktiv: false,
|
||||
treuebonusArtikelSlug: null,
|
||||
abo: {
|
||||
stufe: "gross",
|
||||
startDatum: "2025-06-03",
|
||||
bezahltBisDatum: "2026-09-03",
|
||||
bezahlteMonate: 14,
|
||||
gekuendigt: false,
|
||||
gekuendigtAm: null,
|
||||
kleinTeddyFreigeschaltetGesamt: 2,
|
||||
grossTeddyFreigeschaltetGesamt: 1,
|
||||
kleinTeddyEingeloest: 1,
|
||||
grossTeddyEingeloest: 1,
|
||||
historie: [],
|
||||
},
|
||||
benachrichtigungen: [
|
||||
{ key: "abo_teddy_klein_frei", datum: "2026-07-03", gelesen: false },
|
||||
{ key: "abo_zahlung_erfolgreich", datum: "2026-07-03", gelesen: true },
|
||||
{ key: "abo_zahlung_erfolgreich", datum: "2026-06-03", gelesen: true },
|
||||
{ key: "abo_teddy_gross_eingeloest", datum: "2026-06-01", gelesen: true },
|
||||
{ key: "abo_gewechselt", stufe: "gross", datum: "2026-01-15", gelesen: true },
|
||||
{ key: "abo_abgeschlossen", datum: "2025-06-03", gelesen: true },
|
||||
],
|
||||
};
|
||||
---
|
||||
<Layout
|
||||
title="Vorschau: Konto mit aktivem Abo"
|
||||
description="Interne Vorschau-Seite — zeigt das Kundenkonto mit einem simulierten aktiven Abo."
|
||||
lang={lang}
|
||||
path="/demo-abo/"
|
||||
noindex={true}
|
||||
>
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<p class="todo-note">
|
||||
Interne Vorschau für VanVan — zeigt das Kundenkonto so, wie es mit einem aktiven "Großes Abo"
|
||||
aussieht (Prämien, Benachrichtigungen, Treuebonus). Wird nur in DEINEM Browser simuliert,
|
||||
ändert nichts an echten Kundendaten. Du wirst gleich automatisch weitergeleitet …
|
||||
</p>
|
||||
<p><a href="/konto/angemeldet/#abo" id="demo-weiter-link">Falls die Weiterleitung nicht klappt: hier klicken →</a></p>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
|
||||
<script define:vars={{ demoAccount }}>
|
||||
localStorage.setItem("vandiy_account_v1", JSON.stringify(demoAccount));
|
||||
window.location.href = "/konto/angemeldet/#abo";
|
||||
</script>
|
||||
@@ -1,59 +0,0 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import type { Locale } from "../../i18n/config";
|
||||
|
||||
const lang: Locale = "de";
|
||||
|
||||
// Rein internes Vorschau-Konto — zeigt das Kundenkonto einer ganz normalen, angemeldeten Person
|
||||
// OHNE Abo (Treuebonus dadurch bewusst im gesperrten Zustand, siehe scripts/account.ts). Wird
|
||||
// per JS ins localStorage DIESES Browsers geschrieben, ändert nichts an echten Kundendaten.
|
||||
const demoAccount = {
|
||||
loggedIn: true,
|
||||
name: "Vorschau Kundin",
|
||||
username: "Vorschau",
|
||||
email: "[email protected]",
|
||||
photo: "",
|
||||
bewerteteBestellungen: [],
|
||||
treueBestellungen: 4,
|
||||
treuebonusAktiv: false,
|
||||
treuebonusArtikelSlug: null,
|
||||
abo: {
|
||||
stufe: null,
|
||||
startDatum: null,
|
||||
bezahltBisDatum: null,
|
||||
bezahlteMonate: 0,
|
||||
gekuendigt: false,
|
||||
gekuendigtAm: null,
|
||||
kleinTeddyFreigeschaltetGesamt: 0,
|
||||
grossTeddyFreigeschaltetGesamt: 0,
|
||||
kleinTeddyEingeloest: 0,
|
||||
grossTeddyEingeloest: 0,
|
||||
historie: [],
|
||||
},
|
||||
benachrichtigungen: [],
|
||||
};
|
||||
---
|
||||
<Layout
|
||||
title="Vorschau: angemeldetes Konto (ohne Abo)"
|
||||
description="Interne Vorschau-Seite — zeigt das Kundenkonto einer angemeldeten Person ohne Abo."
|
||||
lang={lang}
|
||||
path="/demo-konto/"
|
||||
noindex={true}
|
||||
>
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<p class="todo-note">
|
||||
Interne Vorschau für VanVan — zeigt das Kundenkonto einer ganz normalen angemeldeten
|
||||
Person OHNE Abo (Treuebonus ist dadurch bewusst gesperrt zu sehen — genau das Verhalten,
|
||||
das Kund:innen ohne Abo anspornen soll). Wird nur in DEINEM Browser simuliert, ändert
|
||||
nichts an echten Kundendaten. Du wirst gleich automatisch weitergeleitet …
|
||||
</p>
|
||||
<p><a href="/konto/angemeldet/" id="demo-weiter-link">Falls die Weiterleitung nicht klappt: hier klicken →</a></p>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
|
||||
<script define:vars={{ demoAccount }}>
|
||||
localStorage.setItem("vandiy_account_v1", JSON.stringify(demoAccount));
|
||||
window.location.href = "/konto/angemeldet/";
|
||||
</script>
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import type { Locale } from "../../i18n/config";
|
||||
|
||||
const lang: Locale = "de";
|
||||
---
|
||||
<Layout
|
||||
title="Vorschau: normale Ansicht (nicht angemeldet)"
|
||||
description="Interne Vorschau-Seite — zeigt den Shop so, wie ihn nicht angemeldete Besucher:innen sehen."
|
||||
lang={lang}
|
||||
path="/demo-normal/"
|
||||
noindex={true}
|
||||
>
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<p class="todo-note">
|
||||
Interne Vorschau für VanVan — zeigt den Shop so, wie ihn ein ganz normaler, NICHT
|
||||
angemeldeter Besucher sieht (räumt dafür ein eventuell simuliertes Konto in diesem Browser
|
||||
wieder auf). Du wirst gleich automatisch weitergeleitet …
|
||||
</p>
|
||||
<p><a href="/" id="demo-weiter-link">Falls die Weiterleitung nicht klappt: hier klicken →</a></p>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
localStorage.removeItem("vandiy_account_v1");
|
||||
window.location.href = "/";
|
||||
</script>
|
||||
@@ -2632,3 +2632,51 @@ a:focus-visible, button:focus-visible {
|
||||
.abo-notif-body { display: flex; flex-direction: column; gap: 0.15rem; }
|
||||
.abo-notif-date { color: var(--c-text-muted); }
|
||||
@media (prefers-reduced-motion: reduce) { .abo-plan-card, .abo-plan-card.is-current-plan, .abo-reward-ready { animation: none; transition: none; } }
|
||||
|
||||
/* Vorschau-Umschalter (nur für qciga/vanvan sichtbar, siehe Layout.astro) — schwebt rechts unten
|
||||
über jeder Seite, damit man ohne Seitenwechsel zwischen normalem Besucher/angemeldeter Kundin/
|
||||
Abonnentin hin- und herschalten kann. */
|
||||
#vandiy-preview-switcher { position: fixed; right: 1.2rem; bottom: 1.2rem; z-index: 200; }
|
||||
#vandiy-preview-toggle {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid transparent;
|
||||
background:
|
||||
linear-gradient(155deg, var(--c-anthracite-light), var(--c-anthracite)) padding-box,
|
||||
linear-gradient(120deg, #9b7fd6, var(--c-accent), #e3b23c) border-box;
|
||||
font-size: 1.4rem;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 10px 26px -10px rgba(0, 0, 0, 0.7);
|
||||
transition: transform 0.2s var(--ease);
|
||||
}
|
||||
#vandiy-preview-toggle:hover { transform: scale(1.08); }
|
||||
#vandiy-preview-panel {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 0.7rem);
|
||||
min-width: 240px;
|
||||
padding: 0.8rem;
|
||||
border-radius: var(--radius-m);
|
||||
background: var(--c-anthracite-light);
|
||||
border: 1px solid rgba(244, 241, 247, 0.14);
|
||||
box-shadow: var(--shadow-card);
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
#vandiy-preview-panel.open { display: flex; }
|
||||
#vandiy-preview-current { margin: 0 0 0.3rem; color: var(--c-text-muted); }
|
||||
#vandiy-preview-panel button[data-preview] {
|
||||
text-align: left;
|
||||
padding: 0.55em 0.7em;
|
||||
border-radius: var(--radius-s);
|
||||
border: 1px solid rgba(244, 241, 247, 0.12);
|
||||
background: rgba(244, 241, 247, 0.04);
|
||||
color: var(--c-text);
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s var(--ease);
|
||||
}
|
||||
#vandiy-preview-panel button[data-preview]:hover { background: color-mix(in srgb, var(--c-accent) 14%, transparent); }
|
||||
@media (max-width: 500px) { #vandiy-preview-switcher { right: 0.7rem; bottom: 0.7rem; } }
|
||||
|
||||
Reference in New Issue
Block a user