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:
qcigano
2026-08-03 18:55:44 +02:00
co-authored by Claude Sonnet 5
parent 6b1920dfb3
commit ad07584906
9 changed files with 502 additions and 161 deletions
+2
View File
@@ -6,3 +6,5 @@ dist/
!.env.example
.DS_Store
*.log
.dev.vars
.wrangler/
+1 -4
View File
@@ -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
+172
View File
@@ -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);
}
+173
View File
@@ -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 &amp; 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>
+106
View File
@@ -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());
-69
View File
@@ -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>
-59
View File
@@ -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>
-29
View File
@@ -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>
+48
View File
@@ -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; } }