Der erste Partnercode steht: DOGI10 auf einer gepraegten Muenze

Die Rabattcodeseite war ein Platzhalter -- "Codes folgen in Kuerze" und
darunter eine Vorschaukachel mit dem erfundenen Code "DOGFATHER". Jetzt
steht der erste echte drauf: DOGI10 fuer Van's DIY & Bastelbedarf,
verlinkt auf vans-diy-bastelbedarf.com.

DIE KONDITIONEN SIND ABGELESEN, NICHT GERATEN.

Aus "DOGI10" auf zehn Prozent zu schliessen, waere naheliegend gewesen,
haette zufaellig gestimmt und waere trotzdem falsch gewesen. Nachgesehen
im Shop selbst (src/content/partnercodes.json):

  { "code": "DOGI10", "prozent": 10, "bis": "2026-09-30",
    "aktiv": true, "giltAufSale": false }

Zwei der drei Angaben stehen im Namen NICHT drin: dass der Code am
30.09.2026 auslaeuft und dass er auf bereits reduzierte Artikel nicht
gilt. Beides steht jetzt sichtbar auf der Karte. Eine falsche Zusage auf
einer Verkaufsseite kostet VanVan die Diskussion an der Kasse.

Gegengeprueft, dass die Datei auch wirklich gilt und kein Ueberbleibsel
ist: Sie wird an zwei Stellen ausgewertet, src/scripts/cart.ts fuer den
Warenkorb und server/lib/preis-berechnen.js fuer den Endpreis, beide mit
derselben Regel (aktiv !== false UND jetzt <= bis 23:59:59).

DAS ABLAUFDATUM IST EINE ZEITBOMBE, ALSO BEKOMMT ES EINEN ZUENDER.

Ein fest eingetippter Satz "gueltig bis 30.09.2026" stimmt, bis der
Kalender ihn ueberholt -- ab dem 01.10. verspraeche die Seite etwas, das
der Shop schon ablehnt. Genau dieselbe Falle hat am 06.09. den
Oeffnungstest im Shop umgeworfen. Das Datum steht deshalb nicht nur im
Text, sondern einmal als Zahl im Skript: Ist es vorbei, schaltet die
Karte selbsttaetig auf "abgelaufen", streicht den Code durch und sperrt
den Kopierknopf, statt weiter zu werben.

DAS LOGO: AUS EINEM SIEGEL WIRD EINE MUENZE.

Filipe hat das Logo als Bildschirmfoto aus TikTok geliefert, rundes
Siegel auf schwarzem Grund. Ungestellt waere daraus auf der dunklen
Karte ein sichtbarer schwarzer Kasten geworden -- derselbe Fehler wie
bei der Workspace-Marke, deren Zahlen damals tadellos aussahen.

Freigestellt wird per Flutfuellung vom Bildrand (tools/partner-siegel-
freistellen.mjs, 384 px WebP, 37 KB). Eine Kreismaske waere hier sogar
ausrechenbar gewesen -- Mitte 539,5/526,5, Radius 495 -- und haette
genau fuer dieses eine Bild funktioniert. Die Fuellung MISST die Form,
statt sie vorauszusetzen: Der naechste Partner ist ein Eintrag in
AUFTRAEGE und sonst nichts. Die Quelle liegt mit im Repo, sonst laesst
sich das Werkzeug genau einmal ausfuehren und ist danach Dekoration.

"Extrem speziell" fuehrt hier NICHT ueber mehr Farbe -- das Siegel ist
schwarz-weiss, jede Einfaerbung lackierte eine fremde Marke um. Es
fuehrt ueber mehr Material: sechs CSS-Lagen, kein zweites Bild.

  Aura     weicher Lichthof, atmet in 9 s
  Raendel  die geriffelte Muenzkante, 72 Zaehne. Sie steht STILL --
           eine sich drehende Riffelung flimmert bei 148 px, und das
           waere genau die grelle Optik, die die Hausregel ausschliesst
  Glanz    stattdessen wandert EIN Lichtpunkt in 22 s um die Kante.
           Das ist die Bewegung, die eine Muenze im Licht macht
  Schliff  Praegekante nach innen, oben Licht, unten Schatten
  Ablage   elliptischer Schatten, damit die Muenze auf der Karte LIEGT

Bei prefers-reduced-motion steht alles davon still.

NEBENBEFUND, DER SONST NIEMANDEM AUFGEFALLEN WAERE: Der Text auf der
Sperrkachel endete auf "sobald die ersten Kooperationen live sind".
Seit heute IST die erste live -- der Satz haette jemanden dafuer zahlen
lassen, auf etwas zu warten, das schon hinter der Sperre liegt.
Ebenfalls nachgemessen statt vermutet: die Warengruppen im
Beschreibungssatz sind die echten Kategorien des Shops.

pruef-rabattcodes EXIT=0 (42 Pruefungen), darunter beide Richtungen der
Supporter-Sperre, die Bildpunkte des ausgelieferten Siegels (Ecken
durchsichtig, Mitte deckend, 69 % Flaeche), das Kopieren gegen die echte
Zwischenablage samt Gegenprobe davor, die Ablaufschaltung mit gestellter
Uhr an beiden Seiten des Stichtags und alle fuenf Sprachen.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-08 18:48:11 +02:00
co-authored by Claude Opus 5
parent d694e34fcb
commit 8cdc31bd46
7 changed files with 1157 additions and 77 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+100 -23
View File
@@ -1,6 +1,20 @@
/* =====================================================================
i18n-rabattcodes.js — Übersetzungs-Wörterbuch NUR für rabattcodes.html
Alle 5 Sprachen vollständig gepflegt (de, de-CH, en, fr, pt).
⚠️ ZWEI EINSETZWEGE, ZWEI SCHREIBWEISEN FÜR "&" (siehe main.js,
applyTranslations):
data-i18n="schluessel" -> el.innerHTML = text
Dort gehört "&amp;" hin.
data-i18n-attr="alt:schluessel" -> el.setAttribute(...)
Dort gehört ein blankes "&" hin;
"&amp;" stünde wörtlich im
Vorlesetext.
Betroffen ist hier genau ein Eintrag: rc_logo_alt (Attribut, blankes
"&"). Der Markenname selbst steht bewusst NICHT im Wörterbuch — "Van's
DIY & Bastelbedarf" heißt in jeder Sprache gleich.
===================================================================== */
window.I18N_PAGE = {
@@ -23,39 +37,102 @@ window.I18N_PAGE = {
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",
},
/* Stand 08.09.2026 angepasst: Der Satz endete vorher auf "sobald die
ersten Kooperationen live sind". Seit heute IST die erste live —
der alte Text hätte einen Supporter dafür bezahlen lassen, auf etwas
zu warten, das schon hinter der Sperre liegt. */
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.",
de: "Rabattcodes sind ein exklusiver Vorteil der DogiCrew 🩵. Der erste Partnercode ist schon drin — werde jetzt Teil davon, um ihn freizuschalten.",
"de-CH": "Rabattcodes sind e exklusive Vorteil vo dr DogiCrew 🩵. De erst Partnercode isch scho dinn — wird jetzt Teil devo, zum en freizschalte.",
en: "Discount codes are an exclusive perk of the DogiCrew 🩵. The first partner code is already in there — join now to unlock it.",
fr: "Les codes de réduction sont un avantage exclusif de la DogiCrew 🩵. Le premier code partenaire est déjà là — rejoins-la maintenant pour le débloquer.",
pt: "Os códigos de desconto são um benefício exclusivo da DogiCrew 🩵. O primeiro código de parceiro já lá está — junta-te agora para o desbloquear.",
},
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",
/* ---- Erster Partner: Van's DIY & Bastelbedarf (DOGI10) ---------- */
rc_band: {
de: "Partner Nr. 1", "de-CH": "Partner Nr. 1",
en: "Partner no. 1", fr: "Partenaire n° 1", pt: "Parceiro n.º 1",
},
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.",
/* Attribut-Eintrag: blankes "&", siehe Kopf dieser Datei. */
rc_logo_alt: {
de: "Logo von Van's DIY & Bastelbedarf",
"de-CH": "Logo vo Van's DIY & Bastelbedarf",
en: "Logo of Van's DIY & Bastelbedarf",
fr: "Logo de Van's DIY & Bastelbedarf",
pt: "Logótipo de Van's DIY & Bastelbedarf",
},
rc_beispiel_marke: {
de: "Partner-Marke", "de-CH": "Partner-Marke",
en: "Partner brand", fr: "Marque partenaire", pt: "Marca parceira",
/* Die aufgezählten Warengruppen sind die echten Kategorien des Shops
(src/content/categories/), nicht das, was man bei "Bastelbedarf"
vermuten würde: Bastelzubehör, Häkelzubehör, DIY Häkelwerke, DIY
Plushies, DIY Modeschmuck & Accessoires, Mystery-Pakete. */
rc_beschreibung: {
de: "Häkelzubehör, Bastelbedarf, DIY-Plushies und Modeschmuck — VanVans eigener Shop.",
"de-CH": "Häkelzubehör, Bastelbedarf, DIY-Plushies und Modeschmuck — VanVans eigene Shop.",
en: "Crochet supplies, craft materials, DIY plushies and handmade jewellery — VanVan's own shop.",
fr: "Accessoires de crochet, matériel de bricolage, peluches DIY et bijoux — la boutique de VanVan.",
pt: "Acessórios de crochet, material de artesanato, plushies DIY e bijutaria — a loja da VanVan.",
},
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_code_kopf: {
de: "Dein Code", "de-CH": "Din Code",
en: "Your code", fr: "Ton code", pt: "O teu código",
},
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_code_fuss: {
de: "Tippen zum Kopieren", "de-CH": "Tippe zum Kopiere",
en: "Tap to copy", fr: "Touche pour copier", pt: "Toca para copiar",
},
rc_code_kopiert: {
de: "Code kopiert ✓", "de-CH": "Code kopiert ✓",
en: "Code copied ✓", fr: "Code copié ✓", pt: "Código copiado ✓",
},
rc_code_fehler: {
de: "Kopieren ging nicht — bitte von Hand abtippen.",
"de-CH": "Kopiere het nöd klappt — bitte vo Hand abtippe.",
en: "Copying didn't work — please type it in by hand.",
fr: "La copie n'a pas fonctionné — saisis-le à la main.",
pt: "A cópia não funcionou — escreve-o à mão.",
},
/* Die drei Konditionen stehen 1:1 so im Shop (partnercodes.json,
Eintrag DOGI10) — nicht aus dem Codenamen geschlossen. */
rc_fakt_rabatt: {
de: "<b>10 %</b> Rabatt auf deinen Einkauf",
"de-CH": "<b>10 %</b> Rabatt uf din Iikauf",
en: "<b>10%</b> off your order",
fr: "<b>10 %</b> de remise sur ta commande",
pt: "<b>10 %</b> de desconto na tua compra",
},
rc_fakt_bis: {
de: "Gültig bis 30.09.2026", "de-CH": "Gültig bis 30.09.2026",
en: "Valid until 30 September 2026", fr: "Valable jusqu'au 30.09.2026", pt: "Válido até 30.09.2026",
},
rc_fakt_sale: {
de: "Nicht auf bereits reduzierte Artikel",
"de-CH": "Nöd uf scho reduzierti Artikel",
en: "Not valid on items that are already reduced",
fr: "Non valable sur les articles déjà réduits",
pt: "Não válido em artigos já reduzidos",
},
rc_abgelaufen: {
de: "Dieser Code ist ausgelaufen. Dogi klärt gerade die Verlängerung — schau bald wieder rein.",
"de-CH": "De Code isch abglaufe. Dogi klärt grad d Verlängerig — lueg bald wieder verbi.",
en: "This code has expired. Dogi is sorting out an extension — check back soon.",
fr: "Ce code a expiré. Dogi s'occupe d'une prolongation — repasse bientôt.",
pt: "Este código expirou. O Dogi está a tratar da renovação — volta em breve.",
},
rc_btn_shop: {
de: "Zum Shop von Van’s DIY ↗", "de-CH": "Zum Shop vo Van’s DIY ↗",
en: "Go to the Van’s DIY shop ↗", fr: "Voir la boutique Van’s DIY ↗", pt: "Ir à loja Van’s DIY ↗",
},
rc_mehr: {
de: "Weitere Codes folgen, sobald die nächsten Kooperationen stehen.",
"de-CH": "Wyteri Codes chömed, sobald die nächste Kooperatione stönd.",
en: "More codes will follow as soon as the next collaborations are in place.",
fr: "D'autres codes suivront dès que les prochaines collaborations seront conclues.",
pt: "Mais códigos seguem assim que as próximas parcerias estiverem fechadas.",
},
rc_btn_zurueck: {
de: "← Zurück zu allen Links", "de-CH": "← Zrugg zu alle Links",
+358
View File
@@ -0,0 +1,358 @@
/* Prüft die Rabattcode-Seite mit dem ersten echten Partnercode (DOGI10).
WAS HIER SCHIEFGEHEN KANN, UND WARUM MAN ES NICHT SIEHT
Die Seite hat zwei Zustände, und man bekommt immer nur einen davon zu
sehen: Ohne Supporter-Abo die Sperre, mit Abo die Codekarte. Wer im
Browser nachschaut, prüft also die Hälfte. Beide Richtungen stehen
deshalb hier drin — auch die Gegenprobe, dass die Karte OHNE Abo
wirklich verschwunden ist und nicht bloss weiter unten steht.
Dazu vier Dinge, die im Bild richtig aussehen und trotzdem falsch
sein können:
* Das Siegel. Ein nicht freigestelltes Logo ist auf einer dunklen
Karte kaum zu erkennen — der schwarze Kasten geht im Hintergrund
unter. Genau daran ist die Workspace-Marke schon einmal
vorbeigerutscht ("60,8 % deckend" klang tadellos). Hier werden
deshalb die Bildpunkte der AUSGELIEFERTEN Datei gemessen: Ecken
durchsichtig, Mitte deckend.
* Der Code. "DOGI10" ist kurz genug, um ihn beim Abtippen zu
verdrehen, und lang genug, dass es niemandem auffällt. Verglichen
wird deshalb zeichengenau, nicht "enthält".
* Das Ablaufdatum. Es steht an zwei Stellen — sichtbar im Text und
als Zahl im Skript. Laufen die auseinander, verspricht die Seite
etwas, das sie selbst schon anders bewertet. Die Prüfung liest
beide und vergleicht sie.
* Die Ablaufschaltung selbst. Sie wird erst am 01.10.2026 zum
ersten Mal gebraucht — also genau dann, wenn niemand mehr
hinschaut. Sie wird hier mit gestellter Uhr in beide Richtungen
gefahren: am letzten gültigen Abend muss der Code GELTEN, am
Morgen danach nicht mehr. Eine Prüfung, die nur bestätigen kann,
bestätigt nichts.
Läuft rein lokal gegen die Dateien im Arbeitsverzeichnis, rund 20
Sekunden. Kein Zugriff auf den Server, keine Bestellung, keine Daten.
Aufrufen mit: node pruef-rabattcodes.mjs
*/
import { chromium } from "playwright";
import { createServer } from "node:http";
import { readFileSync, existsSync, statSync } from "node:fs";
import { join, extname } from "node:path";
const W = process.cwd();
const T = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript",
".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml",
".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json" };
/* Auf isFile() statt auf "endet nicht mit /" prüfen: main.js fragt auch
Adressen ohne Schrägstrich am Ende ab, die im Arbeitsverzeichnis ein
ORDNER sind. readFileSync bricht darauf mit EISDIR ab -- und weil das
im Serverrückruf passiert, stirbt der ganze Prüflauf statt einer
einzelnen Anfrage. */
const srv = createServer((q, r) => {
const p = join(W, decodeURIComponent(q.url.split("?")[0]));
if (existsSync(p) && statSync(p).isFile()) {
r.writeHead(200, { "content-type": T[extname(p)] || "text/plain" });
return r.end(readFileSync(p));
}
r.writeHead(404).end("");
});
await new Promise((r) => srv.listen(4331, r));
const URL = "http://127.0.0.1:4331/rabattcodes.html";
/* Erwartungswerte. Sie stammen aus dem Shop, nicht aus dem Codenamen:
/home/vandiy/vans-diy-bastelbedarf/src/content/partnercodes.json
{ "code": "DOGI10", "prozent": 10, "bis": "2026-09-30",
"aktiv": true, "giltAufSale": false }
Ändert VanVan die Konditionen, muss hier UND auf der Seite nachgezogen
werden — dass beides zusammen auffällt, ist der Zweck dieser Zeilen. */
const CODE = "DOGI10";
const SHOP = "https://vans-diy-bastelbedarf.com/";
const BIS = "2026-09-30";
const BIS_TEXT = "30.09.2026";
const ergebnisse = [];
function pruefe(name, ok, zusatz) {
ergebnisse.push({ name, ok });
console.log((ok ? " ok " : " FEHL ") + name + (zusatz !== undefined ? " -> " + zusatz : ""));
}
const browser = await chromium.launch();
/* Eine Seite im gewünschten Zustand öffnen. `abo` schaltet die Sperre,
`zeit` stellt die Uhr (nur Date, die Zeitgeber laufen weiter — sonst
stünde main.js still). */
async function seiteOeffnen({ abo = true, zeit = null, sprache = "de", viewport, reducedMotion } = {}) {
const ctx = await browser.newContext({
viewport: viewport || { width: 1280, height: 1000 },
permissions: ["clipboard-read", "clipboard-write"],
reducedMotion,
});
const p = await ctx.newPage();
if (zeit) await p.clock.setFixedTime(zeit);
await p.addInitScript(([a, s]) => {
if (a) localStorage.setItem("supporterSubStatus", "active");
else localStorage.removeItem("supporterSubStatus");
localStorage.setItem("dogi-sprache", s);
}, [abo, sprache]);
await p.goto(URL, { waitUntil: "domcontentloaded" });
await p.waitForTimeout(700);
return { ctx, p };
}
/* ===================================================================
1 — Die Sperre, in BEIDE Richtungen
=================================================================== */
console.log("\n=== Supporter-Sperre ===");
for (const [abo, sichtbar] of [[true, "Karte"], [false, "Sperre"]]) {
const { ctx, p } = await seiteOeffnen({ abo });
const z = await p.evaluate(() => ({
karte: !document.getElementById("rc-inhalt").hidden,
sperre: !document.getElementById("rc-gesperrt").hidden,
codeSichtbar: !!document.getElementById("rc-code")?.checkVisibility?.(),
}));
const lage = abo ? "Mit Abo" : "Ohne Abo";
pruefe(`${lage}: ${sichtbar} steht da`,
z.karte === abo && z.sperre === !abo, `Karte=${z.karte} Sperre=${z.sperre}`);
/* Gegenprobe: "hidden" ist ein Attribut, kein Beweis. Ohne Abo darf
der Code auch tatsächlich nicht auf dem Schirm sein. */
pruefe(`${lage}: Code ist ${abo ? "sichtbar" : "unsichtbar"}`,
z.codeSichtbar === abo, `sichtbar=${z.codeSichtbar}`);
await ctx.close();
}
/* ===================================================================
2 — Das Siegel: geladen, gross genug, wirklich freigestellt
=================================================================== */
console.log("\n=== Partnersiegel ===");
{
const { ctx, p } = await seiteOeffnen({});
await p.waitForFunction(() => {
const i = document.querySelector(".rc-medaillon img");
return i && i.complete && i.naturalWidth > 0;
}, null, { timeout: 8000 }).catch(() => {});
const b = await p.evaluate(() => {
const i = document.querySelector(".rc-medaillon img");
if (!i) return { fehler: "kein Bild" };
const r = i.getBoundingClientRect();
const K = 64;
const c = document.createElement("canvas");
c.width = K; c.height = K;
const s = c.getContext("2d", { willReadFrequently: true });
s.drawImage(i, 0, 0, K, K);
const d = s.getImageData(0, 0, K, K).data;
const alpha = (x, y) => d[(y * K + x) * 4 + 3];
let deckend = 0;
for (let k = 3; k < d.length; k += 4) if (d[k] > 200) deckend++;
return {
quelle: (i.currentSrc || i.src).split("/").pop(),
natur: i.naturalWidth, breite: Math.round(r.width),
ecken: [alpha(0, 0), alpha(K - 1, 0), alpha(0, K - 1), alpha(K - 1, K - 1)],
mitte: alpha(K >> 1, K >> 1),
anteil: deckend / (K * K),
};
});
pruefe("Siegel geladen (naturalWidth 384)", b.natur === 384, `${b.quelle} / ${b.natur}px`);
pruefe("Siegel wird gross genug angezeigt (>= 110 px)", b.breite >= 110, `${b.breite} px`);
/* DAS ist die Prüfung, die den schwarzen Kasten gefunden hätte. */
pruefe("Ecken des Siegels sind durchsichtig", b.ecken.every((a) => a <= 20), b.ecken.join("/"));
pruefe("Mitte des Siegels ist deckend", b.mitte >= 250, String(b.mitte));
/* Und die Anzahl gehört in die Bedingung, nicht in den Meldetext: ein
leeres Bild hätte alle drei Prüfungen oben bestanden. Ein Kreis im
Quadrat deckt pi/4, hier mit 6 % Rand rund 70 %. */
pruefe("Es ist auch wirklich ein rundes Siegel drauf (60-80 % Fläche)",
b.anteil > 0.6 && b.anteil < 0.8, `${(b.anteil * 100).toFixed(1)} %`);
await ctx.close();
}
/* ===================================================================
3 — Code, Shoplink, Konditionen
=================================================================== */
console.log("\n=== Code und Konditionen ===");
{
const { ctx, p } = await seiteOeffnen({});
const z = await p.evaluate(() => {
const knopf = document.getElementById("rc-code");
const link = document.querySelector('.rc-partner a.btn-primary');
return {
wert: document.querySelector(".rc-code-wert")?.textContent.trim(),
daten: knopf?.getAttribute("data-code"),
href: link?.getAttribute("href"),
ziel: link?.getAttribute("target"),
rel: link?.getAttribute("rel"),
fakten: [...document.querySelectorAll(".rc-fakten li")].map((l) => l.textContent.trim()),
skript: [...document.querySelectorAll("script:not([src])")].map((s) => s.textContent).join("\n"),
};
});
pruefe(`Angezeigter Code lautet genau „${CODE}“`, z.wert === CODE, z.wert);
/* Kopiert wird data-code, angezeigt wird der Text. Beide müssen
dasselbe sagen — sonst kopiert man etwas anderes, als man liest. */
pruefe("Angezeigter Code und kopierter Code sind identisch", z.daten === z.wert, `${z.daten} / ${z.wert}`);
pruefe(`Shoplink zeigt genau auf ${SHOP}`, z.href === SHOP, z.href);
pruefe("Shoplink öffnet in neuem Tab und ohne Rückreferenz",
z.ziel === "_blank" && (z.rel || "").includes("noopener"), `${z.ziel} / ${z.rel}`);
pruefe("Drei Konditionen stehen da", z.fakten.length === 3, z.fakten.length + "");
pruefe("Rabatthöhe genannt (10 %)", z.fakten.some((f) => /10\s?%/.test(f)), z.fakten[0]);
pruefe(`Ablaufdatum genannt (${BIS_TEXT})`, z.fakten.some((f) => f.includes(BIS_TEXT)), z.fakten[1]);
pruefe("Sale-Ausnahme genannt", z.fakten.some((f) => /reduziert/i.test(f)), z.fakten[2]);
/* Der Text sagt ein Datum, das Skript rechnet mit einem. Zwei Stellen,
eine Wahrheit — hier wird sie verglichen. */
const imSkript = (z.skript.match(/var ABLAUF = "([\d-]+)"/) || [])[1];
pruefe(`Skript rechnet mit demselben Datum wie der Text`,
imSkript === BIS, `Skript ${imSkript}, Text ${BIS_TEXT}`);
await ctx.close();
}
/* ===================================================================
4 — Kopieren, mit Gegenprobe
=================================================================== */
console.log("\n=== Kopieren ===");
{
const { ctx, p } = await seiteOeffnen({});
/* Vorher: die Erfolgsmeldung darf NICHT stehen. Ohne diese Zeile
würde eine dauerhaft sichtbare Meldung als Erfolg durchgehen. */
const vorher = await p.evaluate(() => !document.getElementById("rc-echo-ok").hidden);
pruefe("Vor dem Klick steht keine Erfolgsmeldung", vorher === false, String(vorher));
await p.click("#rc-code");
await p.waitForTimeout(400);
const nachher = await p.evaluate(async () => ({
meldung: !document.getElementById("rc-echo-ok").hidden,
fehler: !document.getElementById("rc-echo-fehler").hidden,
marke: document.getElementById("rc-code").classList.contains("ist-kopiert"),
zwischenablage: await navigator.clipboard.readText(),
}));
pruefe(`In der Zwischenablage steht „${CODE}“`, nachher.zwischenablage === CODE, nachher.zwischenablage);
pruefe("Erfolgsmeldung erscheint", nachher.meldung === true && nachher.fehler === false,
`ok=${nachher.meldung} fehler=${nachher.fehler}`);
pruefe("Knopf zeigt den kopierten Zustand", nachher.marke === true, String(nachher.marke));
await ctx.close();
}
/* ===================================================================
5 — Ablauf, in beide Richtungen (gestellte Uhr)
=================================================================== */
console.log("\n=== Ablaufschaltung ===");
for (const [wann, zeit, sollAbgelaufen] of [
["am letzten gültigen Abend", new Date(`${BIS}T21:30:00`), false],
["am Morgen danach", new Date("2026-10-01T09:00:00"), true],
]) {
const { ctx, p } = await seiteOeffnen({ zeit });
const z = await p.evaluate(() => ({
markiert: document.getElementById("rc-partner").classList.contains("ist-abgelaufen"),
hinweis: !document.getElementById("rc-abgelaufen").hidden,
gesperrt: document.getElementById("rc-code").disabled,
}));
pruefe(`${wann}: Code gilt ${sollAbgelaufen ? "nicht mehr" : "noch"}`,
z.markiert === sollAbgelaufen && z.hinweis === sollAbgelaufen && z.gesperrt === sollAbgelaufen,
`markiert=${z.markiert} hinweis=${z.hinweis} gesperrt=${z.gesperrt}`);
await ctx.close();
}
/* ===================================================================
6 — Alle fünf Sprachen
=================================================================== */
console.log("\n=== Sprachen ===");
{
const erwartet = {
de: ["Dein Code", "Tippen zum Kopieren"],
"de-CH": ["Din Code", "Tippe zum Kopiere"],
en: ["Your code", "Tap to copy"],
fr: ["Ton code", "Touche pour copier"],
pt: ["O teu código", "Toca para copiar"],
};
for (const [sp, [kopf, fuss]] of Object.entries(erwartet)) {
const { ctx, p } = await seiteOeffnen({ sprache: sp });
const z = await p.evaluate(() => ({
kopf: document.querySelector(".rc-code-kopf")?.textContent.trim(),
fuss: document.querySelector(".rc-code-fuss")?.textContent.trim(),
/* Ein nicht übersetzter Schlüssel erscheint wörtlich als "rc_..." —
das ist der sichtbare Rest, nach dem gesucht wird. */
reste: document.getElementById("rc-inhalt").innerText.match(/rc_[a-z_]+/g) || [],
entitaeten: document.querySelector(".rc-medaillon img")?.getAttribute("alt") || "",
}));
pruefe(`[${sp}] Beschriftungen übersetzt`, z.kopf === kopf && z.fuss === fuss, `${z.kopf} / ${z.fuss}`);
pruefe(`[${sp}] keine unübersetzten Schlüssel sichtbar`, z.reste.length === 0, z.reste.join(","));
/* Die Falle aus pruef-texte.mjs, hier für ein Attribut: Wörterbuch
-> setAttribute kodiert NICHT, "&amp;" stünde wörtlich im Alt-Text
und würde so vorgelesen. */
pruefe(`[${sp}] Alt-Text ohne Kodierungsrest`, !/&(amp|lt|gt|quot|#39);/.test(z.entitaeten), z.entitaeten);
await ctx.close();
}
}
/* ===================================================================
7 — Handy, Bewegungsarmut, Bildschirmfotos
=================================================================== */
console.log("\n=== Handy und Bewegungsarmut ===");
{
const { ctx, p } = await seiteOeffnen({ viewport: { width: 390, height: 844 } });
const z = await p.evaluate(() => {
const k = document.getElementById("rc-partner").getBoundingClientRect();
return {
quer: document.documentElement.scrollWidth - document.documentElement.clientWidth,
kartenbreite: Math.round(k.width),
knopfhoehe: Math.round(document.getElementById("rc-code").getBoundingClientRect().height),
};
});
pruefe("Handy 390 px: nichts läuft seitlich über", z.quer <= 0, `${z.quer} px Überhang`);
pruefe("Handy 390 px: Kopierknopf ist gross genug (>= 44 px)", z.knopfhoehe >= 44, `${z.knopfhoehe} px`);
await p.screenshot({ path: "pruef-rabattcodes-handy.png", fullPage: true });
await ctx.close();
}
{
const { ctx, p } = await seiteOeffnen({ reducedMotion: "reduce" });
const laufen = await p.evaluate(() => {
const teile = [".rc-medaillon", ".rc-glanz", ".rc-schliff", ".rc-code-wert"];
return teile.filter((s) => {
const el = document.querySelector(s);
return el && getComputedStyle(el).animationName !== "none";
});
});
pruefe("Bei „weniger Bewegung“ steht alles still", laufen.length === 0, laufen.join(","));
await ctx.close();
}
{
const { ctx, p } = await seiteOeffnen({});
await p.waitForTimeout(500);
await p.locator("#rc-partner").screenshot({ path: "pruef-rabattcodes-karte.png" });
await ctx.close();
}
{
const { ctx, p } = await seiteOeffnen({ abo: false });
await p.screenshot({ path: "pruef-rabattcodes-gesperrt.png", fullPage: true });
await ctx.close();
}
await browser.close();
srv.close();
/* ===================================================================
Ergebnis — mit der ANZAHL in der Bedingung.
Ein grüner Lauf mit weniger Prüfungen als vorher ist kein grüner Lauf,
sondern ein stiller Aussetzer (Erkenntnis 28.08.2026). Die Zahl unten
wird beim Erweitern mit angehoben.
=================================================================== */
const MINDESTENS = 34;
const fehl = ergebnisse.filter((e) => !e.ok);
console.log(`\n${ergebnisse.length} Prüfungen, ${fehl.length} fehlgeschlagen.`);
console.log("Bildschirmfotos: pruef-rabattcodes-karte.png, -handy.png, -gesperrt.png");
if (ergebnisse.length < MINDESTENS) {
console.error(`FEHLER: nur ${ergebnisse.length} von mindestens ${MINDESTENS} Prüfungen gelaufen — `
+ `es ist etwas stillschweigend ausgefallen.`);
process.exit(1);
}
if (fehl.length) {
console.error("\nFehlgeschlagen:\n" + fehl.map((e) => " - " + e.name).join("\n"));
process.exit(1);
}
console.log("Alles in Ordnung.");
+436 -50
View File
@@ -25,50 +25,11 @@
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<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
@@ -85,6 +46,343 @@
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);
}
/* =====================================================================
DER ERSTE ECHTE PARTNERCODE (08.09.2026)
Bis heute war das hier eine Platzhalterkachel ("Codes folgen in
Kürze") mit einem erfundenen Beispielcode. Jetzt steht der erste
echte drauf: DOGI10 für Van's DIY & Bastelbedarf.
⚠️ DIE KONDITIONEN SIND ABGELESEN, NICHT GERATEN.
Quelle der Wahrheit ist der Shop, nicht diese Seite:
/home/vandiy/vans-diy-bastelbedarf/src/content/partnercodes.json
{ "code": "DOGI10", "prozent": 10, "bis": "2026-09-30",
"aktiv": true, "giltAufSale": false }
Ausgewertet wird sie an zwei Stellen mit derselben Regel
(aktiv !== false UND jetzt <= bis 23:59:59): src/scripts/cart.ts für
den Warenkorb, server/lib/preis-berechnen.js für den Endpreis.
Der naheliegende Weg wäre gewesen, "–10 %" einfach aus dem Namen
"DOGI10" zu schließen. Das hätte hier zufällig gestimmt und wäre
trotzdem falsch gewesen: Dass der Code NICHT auf bereits reduzierte
Artikel gilt (giltAufSale: false) und dass er am 30.09.2026 ausläuft,
steht im Namen nicht drin. Eine falsche Zusage auf einer
Verkaufsseite kostet VanVan die Diskussion an der Kasse.
Abgeschrieben ist abgeschrieben: Wer die Konditionen ändert, ändert
sie im Shop UND hier — die Zeile unten rechnet nichts aus, sie sagt
nur an. Damit das nicht schleichend auseinanderläuft, kennt das
Skript am Seitenende das Ablaufdatum und schaltet die Karte von
selbst auf "abgelaufen", statt weiter etwas zu versprechen.
===================================================================== */
.rc-partner {
max-width: 620px;
margin: 0 auto;
text-align: center;
padding: 2.4rem 1.6rem 2rem;
border-color: rgba(94, 234, 167, .3);
}
/* Eckband wie bei den Feature-Kacheln auf links.html — hier aber ohne
Rotation und in Silber statt Bunt, damit es dem Siegel nicht die
Aufmerksamkeit wegnimmt. */
.rc-band {
position: absolute;
top: 1rem; right: 1rem;
font-size: .64rem;
font-weight: 800;
letter-spacing: .09em;
text-transform: uppercase;
padding: .36rem .72rem;
border-radius: 999px;
background: rgba(8, 10, 14, .6);
border: 1px solid rgba(255, 255, 255, .24);
color: #dfe6ef;
}
/* ---- Das Medaillon --------------------------------------------------
Warum für EIN Logo so viel Aufwand: Es ist das einzige Bild auf der
Seite und das Gesicht des Partners. Der Weg zu "besonders" führt hier
NICHT über mehr Farbe — das Siegel ist schwarz-weiß, jede Einfärbung
würde eine fremde Marke umlackieren. Er führt über mehr MATERIAL: aus
dem runden Siegel wird eine geprägte Münze, aus sechs Lagen:
1. Aura weicher Lichthof dahinter, atmet sehr langsam
2. Rändel die geriffelte Münzkante (repeating-conic-gradient),
72 Zähne. Sie steht STILL — eine sich drehende
Riffelung flimmert bei 148 px, und genau diese grelle
Optik soll hier nicht sein (Grundregel: augenschonend)
3. Glanz stattdessen wandert EIN Lichtpunkt in 22 s einmal um
die Kante. Das ist die Bewegung, die eine Münze im
Licht macht, und sie ist ruhig genug fürs Auge
4. Siegel das freigestellte Logo (partner-vandiy.webp)
5. Schliff Prägekante nach innen (oben Licht, unten Schatten) und
ein langsamer Lichtstreif über die Fläche
6. Ablage elliptischer Schatten darunter, damit die Münze auf der
Karte LIEGT statt zu schweben
Alles davon ist reines CSS: kein zweites Bild, kein Skript, keine
Abhängigkeit. Und alles davon steht still, sobald der Rechner
"weniger Bewegung" meldet (Block ganz unten). */
.rc-medaillon {
position: relative;
width: clamp(116px, 34vw, 148px);
aspect-ratio: 1 / 1;
margin: .2rem auto 1.5rem;
border-radius: 50%;
transition: transform .4s cubic-bezier(.22, .9, .3, 1);
}
.rc-medaillon > * {
position: absolute;
inset: 0;
border-radius: 50%;
}
/* 1 — Aura. Silber (die Farbe des Siegels) mit einem Hauch Mint (die
Farbe, in der diese Seite über Codes spricht). */
.rc-medaillon::before {
content: "";
position: absolute;
inset: -28%;
z-index: 0;
border-radius: 50%;
background: radial-gradient(circle at 50% 45%,
rgba(226, 234, 244, .30), rgba(94, 234, 167, .11) 45%, transparent 70%);
filter: blur(12px);
animation: rc-aura-atmen 9s ease-in-out infinite;
pointer-events: none;
}
/* 6 — Ablageschatten. Steht bewusst VOR der Aura im Quelltext, damit
die Reihenfolge im Kopf der Lage entspricht, nicht der Nummer. */
.rc-medaillon::after {
content: "";
position: absolute;
left: 14%; right: 14%;
bottom: -8%;
height: 13%;
z-index: 0;
border-radius: 50%;
background: radial-gradient(ellipse at center, rgba(0, 0, 0, .6), transparent 72%);
filter: blur(4px);
pointer-events: none;
}
/* 2 — Die geriffelte Münzkante. Der Ring entsteht aus zwei Teilen:
border-radius beschneidet den Kegelverlauf auf den Kreis, die Maske
stanzt die Mitte heraus. Das Siegel (Lage 4) deckt anschließend
alles bis zu seinem eigenen Rand ab — sichtbar bleibt genau der
Streifen, der außen übersteht. */
.rc-raendel {
inset: -10%;
z-index: 1;
background: repeating-conic-gradient(from 0deg,
rgba(228, 234, 243, .92) 0deg 2.5deg,
rgba(70, 80, 94, .8) 2.5deg 5deg);
-webkit-mask: radial-gradient(closest-side, transparent 0 82%, #000 86%);
mask: radial-gradient(closest-side, transparent 0 82%, #000 86%);
opacity: .45;
}
/* 3 — Der wandernde Lichtpunkt auf derselben Kante. */
.rc-glanz {
inset: -10%;
z-index: 2;
background: conic-gradient(from 0deg,
rgba(255, 255, 255, 0) 0deg,
rgba(255, 255, 255, 0) 248deg,
rgba(255, 255, 255, .72) 322deg,
rgba(255, 255, 255, 0) 360deg);
-webkit-mask: radial-gradient(closest-side, transparent 0 82%, #000 86%);
mask: radial-gradient(closest-side, transparent 0 82%, #000 86%);
animation: rc-glanz-dreht 22s linear infinite;
pointer-events: none;
}
/* 4 — Das Siegel selbst. */
.rc-medaillon img {
z-index: 3;
width: 100%;
height: 100%;
object-fit: contain;
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, .55));
}
/* 5 — Prägekante und Lichtstreif. Der Streifen benutzt dieselbe
Bewegung wie die Marken-Kacheln auf links.html (nav-cta-shimmer aus
main.css), nur deutlich langsamer. */
.rc-schliff {
z-index: 4;
pointer-events: none;
background: linear-gradient(118deg, transparent 36%, rgba(255, 255, 255, .28) 49%, transparent 62%);
background-size: 260% 100%;
box-shadow:
inset 0 2px 4px rgba(255, 255, 255, .3),
inset 0 -8px 16px rgba(0, 0, 0, .4),
inset 0 0 0 1px rgba(255, 255, 255, .1);
animation: nav-cta-shimmer 6.5s linear infinite;
}
/* Beim Zeigen kippt die Münze leicht ins Licht. Ausgelöst wird das an
der ganzen KARTE, nicht am Medaillon: Es ist kein Knopf, und ein
Effekt, der erst kommt, wenn man ein Bild genau trifft, findet
niemand. */
.rc-partner:hover .rc-medaillon {
transform: perspective(680px) rotateX(9deg) rotateY(-7deg) scale(1.045);
}
@keyframes rc-aura-atmen {
0%, 100% { opacity: .75; transform: scale(1); }
50% { opacity: 1; transform: scale(1.05); }
}
@keyframes rc-glanz-dreht { to { transform: rotate(360deg); } }
/* ---- Name, Beschreibung ------------------------------------------- */
.rc-marke {
font-size: clamp(1.35rem, 5vw, 1.7rem);
line-height: 1.2;
margin: 0;
}
.rc-marke small {
display: block;
font-size: .58em;
font-weight: 700;
letter-spacing: .05em;
color: var(--text-muted);
margin-top: .25rem;
}
.rc-beschreibung {
margin: .7rem auto 0;
max-width: 40ch;
font-size: .97rem;
}
/* ---- Der Code selbst ----------------------------------------------
Ein Knopf, kein Textfeld: Die einzige Handlung hier ist "kopieren",
und die gehört auf etwas Drückbares — mit Tastatur erreichbar, ohne
dass jemand erst markieren muss. Der gestrichelte Rand ist der
Gutschein-Look, den diese Seite vorher schon in der Vorschaukachel
hatte; er bleibt, das Erfundene daran verschwindet. */
.rc-code {
position: relative;
display: block;
width: 100%;
max-width: 340px;
margin: 1.7rem auto 0;
padding: .9rem 1.1rem 1rem;
border: 1.5px dashed rgba(94, 234, 167, .45);
border-radius: 14px;
background: rgba(52, 211, 153, .06);
color: inherit;
font-family: inherit;
cursor: pointer;
text-align: center;
transition: border-color .2s ease, background-color .2s ease, transform .12s ease;
}
.rc-code:hover {
border-color: rgba(94, 234, 167, .8);
background: rgba(52, 211, 153, .1);
}
.rc-code:active { transform: scale(.99); }
.rc-code.ist-kopiert {
border-style: solid;
border-color: rgba(94, 234, 167, .9);
background: rgba(52, 211, 153, .16);
}
.rc-code-kopf {
display: block;
font-size: .7rem;
font-weight: 800;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--text-muted);
}
.rc-code-wert {
display: block;
margin: .32rem 0 .4rem;
font-size: clamp(1.7rem, 7.5vw, 2.15rem);
font-weight: 900;
letter-spacing: .16em;
text-indent: .16em; /* gleicht den Sperrsatz hinter dem letzten Zeichen aus */
background: linear-gradient(100deg,
#7fe3b4 0%, #ffffff 26%, #d9fff1 44%, #86e9bd 62%, #ffffff 80%, #7fe3b4 100%);
background-size: 260% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: btn-silver-text-shine 5.5s linear infinite; /* aus main.css */
}
.rc-code-fuss {
display: block;
font-size: .8rem;
color: var(--text-muted);
}
/* Rückmeldung steht AUSSERHALB des Knopfes und wird nicht per Skript
beschriftet, sondern nur ein- und ausgeblendet. Sonst wäre der Text
nach einem Sprachwechsel deutsch geblieben — data-i18n greift nur an
Elementen, die im HTML stehen. Die feste Höhe verhindert, dass die
Karte beim Kopieren springt. */
.rc-code-echo {
min-height: 1.4em;
margin: .5rem 0 0;
font-size: .85rem;
font-weight: 700;
color: #8ff0c0;
}
.rc-code-echo .fehler { color: #ffc9a3; }
/* ---- Konditionen --------------------------------------------------- */
.rc-fakten {
list-style: none;
margin: 1.2rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: .4rem;
font-size: .9rem;
color: var(--text-muted);
}
.rc-fakten li { display: flex; gap: .5rem; align-items: baseline; }
.rc-fakten b { color: #8ff0c0; font-weight: 800; }
/* ---- Abgelaufen ---------------------------------------------------- */
.rc-abgelaufen {
max-width: 380px;
margin: 1.2rem auto 0;
padding: .7rem 1rem;
border-radius: 12px;
border: 1.5px solid rgba(212, 175, 55, .4);
background: rgba(212, 175, 55, .08);
color: #ffe9a8;
font-size: .88rem;
}
.rc-abgelaufen[hidden] { display: none; }
.rc-partner.ist-abgelaufen .rc-code-wert {
text-decoration: line-through;
opacity: .5;
animation: none;
}
.rc-partner.ist-abgelaufen .rc-code { cursor: not-allowed; opacity: .75; }
.rc-mehr {
max-width: 620px;
margin: 1.4rem auto 0;
text-align: center;
font-size: .9rem;
color: var(--text-muted);
}
@media (prefers-reduced-motion: reduce) {
.rc-medaillon,
.rc-medaillon::before,
.rc-glanz,
.rc-schliff,
.rc-code-wert { animation: none; transition: none; }
.rc-partner:hover .rc-medaillon { transform: none; }
}
</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');">
@@ -103,25 +401,54 @@
<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 id="rc-inhalt" hidden>
<article class="card rc-partner" id="rc-partner">
<span class="rc-band" data-i18n="rc_band">Partner Nr. 1</span>
<div class="rc-medaillon">
<span class="rc-raendel" aria-hidden="true"></span>
<span class="rc-glanz" aria-hidden="true"></span>
<img src="/assets/img/partner-vandiy.webp" width="384" height="384"
alt="Logo von Van’s DIY &amp; Bastelbedarf"
data-i18n-attr="alt:rc_logo_alt" loading="lazy" decoding="async" />
<span class="rc-schliff" aria-hidden="true"></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>
<h2 class="rc-marke">VAN’S DIY<small>&amp; Bastelbedarf</small></h2>
<p class="rc-beschreibung" data-i18n="rc_beschreibung">Häkelzubehör, Bastelbedarf, DIY-Plushies und Modeschmuck — VanVans eigener Shop.</p>
<button type="button" class="rc-code" id="rc-code" data-code="DOGI10">
<span class="rc-code-kopf" data-i18n="rc_code_kopf">Dein Code</span>
<span class="rc-code-wert">DOGI10</span>
<span class="rc-code-fuss" data-i18n="rc_code_fuss">Tippen zum Kopieren</span>
</button>
<p class="rc-code-echo" role="status" aria-live="polite">
<span id="rc-echo-ok" data-i18n="rc_code_kopiert" hidden>Code kopiert ✓</span>
<span class="fehler" id="rc-echo-fehler" data-i18n="rc_code_fehler" hidden>Kopieren ging nicht — bitte von Hand abtippen.</span>
</p>
<ul class="rc-fakten">
<li><span aria-hidden="true">💸</span><span data-i18n="rc_fakt_rabatt"><b>10 %</b> Rabatt auf deinen Einkauf</span></li>
<li><span aria-hidden="true">📅</span><span data-i18n="rc_fakt_bis">Gültig bis 30.09.2026</span></li>
<li><span aria-hidden="true">ℹ️</span><span data-i18n="rc_fakt_sale">Nicht auf bereits reduzierte Artikel</span></li>
</ul>
<p class="rc-abgelaufen" id="rc-abgelaufen" hidden data-i18n="rc_abgelaufen">Dieser Code ist ausgelaufen. Dogi klärt gerade die Verlängerung — schau bald wieder rein.</p>
<div class="btn-row" style="justify-content:center;margin-top:1.6rem;">
<a class="btn btn-primary" href="https://vans-diy-bastelbedarf.com/" target="_blank" rel="noopener" data-i18n="rc_btn_shop">Zum Shop von Van’s DIY ↗</a>
<a class="btn btn-outline" href="links.html" data-i18n="rc_btn_zurueck">← Zurück zu allen Links</a>
</div>
</article>
<p class="rc-mehr" data-i18n="rc_mehr">Weitere Codes folgen, sobald die nächsten Kooperationen stehen.</p>
</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>
<p data-i18n="rc_gesperrt_text">Rabattcodes sind ein exklusiver Vorteil der DogiCrew 🩵. Der erste Partnercode ist schon drin — werde jetzt Teil davon, um ihn freizuschalten.</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>
@@ -132,10 +459,27 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/i18n-rabattcodes.js?v=20260828e"></script>
<script src="assets/js/i18n-rabattcodes.js?v=20260908a"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script>
(function () {
/* Ablaufdatum von DOGI10 — dieselbe Angabe wie "bis" in
src/content/partnercodes.json im Shop, und dieselbe Auslegung:
einschließlich dieses Tages bis 23:59:59 (server/lib/preis-
berechnen.js, istRabattAktiv).
WARUM DAS HIER ÜBERHAUPT STEHT: Ein fest eingetipptes Datum in einem
Satz ist eine Zeitbombe — es stimmt, bis der Kalender es überholt,
und ab dann verspricht die Seite etwas, das der Shop schon ablehnt.
Deshalb steht das Datum genau EINMAL als Zahl da und nicht nur als
Text, damit die Seite selbst merken kann, wann sie stillhalten muss.
Gerechnet wird in der Zeitzone des Besuchers, der Shop rechnet in
seiner. In den wenigen Stunden Unterschied kann die Seite den Code
bereits als abgelaufen zeigen, während er noch gilt — das ist die
harmlose Richtung des Fehlers. Andersherum wäre sie es nicht. */
var ABLAUF = "2026-09-30";
document.addEventListener("DOMContentLoaded", function () {
var status = localStorage.getItem("supporterSubStatus");
var hatAbo = ["active", "pending", "suspended", "cancelled"].indexOf(status) !== -1;
@@ -143,6 +487,48 @@
var gesperrt = document.getElementById("rc-gesperrt");
if (inhalt) inhalt.hidden = !hatAbo;
if (gesperrt) gesperrt.hidden = hatAbo;
var karte = document.getElementById("rc-partner");
var knopf = document.getElementById("rc-code");
var ok = document.getElementById("rc-echo-ok");
var fehler = document.getElementById("rc-echo-fehler");
var abgelaufen = new Date() > new Date(ABLAUF + "T23:59:59");
if (abgelaufen) {
if (karte) karte.classList.add("ist-abgelaufen");
var hinweis = document.getElementById("rc-abgelaufen");
if (hinweis) hinweis.hidden = false;
if (knopf) knopf.disabled = true;
}
if (!knopf) return;
var uhr = null;
knopf.addEventListener("click", function () {
if (abgelaufen) return;
var zeigen = function (welches) {
if (ok) ok.hidden = welches !== "ok";
if (fehler) fehler.hidden = welches !== "fehler";
knopf.classList.toggle("ist-kopiert", welches === "ok");
clearTimeout(uhr);
uhr = setTimeout(function () {
if (ok) ok.hidden = true;
if (fehler) fehler.hidden = true;
knopf.classList.remove("ist-kopiert");
}, 2600);
};
/* Ohne HTTPS gibt es navigator.clipboard nicht, und auch mit kann
das Versprechen abgelehnt werden. Beide Fälle bekommen dieselbe
ehrliche Antwort statt einer stillen Nicht-Reaktion — der Code
steht ja sichtbar da und lässt sich abtippen. */
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(knopf.getAttribute("data-code") || "").then(
function () { zeigen("ok"); },
function () { zeigen("fehler"); }
);
} else {
zeigen("fehler");
}
});
});
})();
</script>
+1 -1
View File
@@ -15,7 +15,7 @@
<url><loc>https://dogfather-universe.com/medien-casper.html</loc><lastmod>2026-08-01</lastmod></url>
<url><loc>https://dogfather-universe.com/links.html</loc><lastmod>2026-07-30</lastmod></url>
<url><loc>https://dogfather-universe.com/abonnieren.html</loc><lastmod>2026-08-03</lastmod></url>
<url><loc>https://dogfather-universe.com/rabattcodes.html</loc><lastmod>2026-08-03</lastmod></url>
<url><loc>https://dogfather-universe.com/rabattcodes.html</loc><lastmod>2026-09-08</lastmod></url>
<url><loc>https://dogfather-universe.com/manager.html</loc><lastmod>2026-07-30</lastmod></url>
<url><loc>https://dogfather-universe.com/werte.html</loc><lastmod>2026-07-30</lastmod></url>
<url><loc>https://dogfather-universe.com/team-scouts.html</loc><lastmod>2026-07-30</lastmod></url>
+259
View File
@@ -0,0 +1,259 @@
/* ===================================================================
Stellt die Siegel der Rabattcode-Partner frei.
ANLASS (08.09.2026). Auf rabattcodes.html steht ab jetzt der erste
echte Partnercode (DOGI10 fuer Van's DIY & Bastelbedarf). Filipe hat
das Logo als Bildschirmfoto aus TikTok geliefert: ein rundes Siegel
auf schwarzem Grund, 1080x1047, JPEG.
WARUM DAS SO NICHT AUF DIE SEITE KANN. Der Grund ist NICHT dieselbe
Farbe wie die Karte -- rgb(1,1,1) gegen die Kartenflaeche. Ungestellt
liegt das Siegel also in einem sichtbaren schwarzen KASTEN mitten in
einer runden Fassung. Genau der Fehler, der im Workspace schon einmal
passiert ist (siehe marken-freistellen.mjs) -- dort mit einer Datei,
deren Zahlen tadellos aussahen.
WARUM EIN EIGENES WERKZEUG UND NICHT marken-freistellen.mjs. Jenes
liefert nach workspace/assets/img/ und traegt zwei Sonderdurchgaenge,
die hier beide falsch waeren: `kopfTrennen` (schneidet unter dem Kinn
ab) und `nurFarbmotiv` (behaelt nur Buntes). Das Siegel ist rund,
vollstaendig gewollt und vollstaendig GRAU -- `nurFarbmotiv` haette
ein leeres Bild ergeben, `kopfTrennen` die untere Haelfte gekappt.
Gemeinsam ist nur die Flutfuellung; die steht hier deshalb noch
einmal, dafuer ohne jede Verzweigung.
WARUM FLUTFUELLUNG UND KEINE KREISMASKE. Ein Kreis waere hier sogar
ausrechenbar (Mitte 539,5/526,5, Radius 495 -- nachgemessen). Er
waere aber an genau dieses eine Bild gebunden: Der naechste Partner
liefert sein Logo in einer anderen Groesse, vielleicht nicht mittig,
vielleicht gar nicht rund. Die Fuellung laeuft vom Bildrand nach
innen und bleibt an der ersten Kontur stehen -- sie misst die Form,
statt sie vorauszusetzen. Ein neuer Partner ist damit ein Eintrag in
AUFTRAEGE und sonst nichts.
DIE QUELLEN LIEGEN MIT IM REPO (assets/img/marke/quelle-*.jpg), nicht
nur in Filipes Bilderordner. Ein Werkzeug, dessen Eingangsdatei auf
einem fremden Rechner liegt, laesst sich genau einmal ausfuehren --
danach ist es Dekoration.
WEBP STATT PNG. Gemessen an derselben Vorlage, 384 px Kante:
PNG (RGBA) 222 KB, PNG als Graustufen+Alpha 83 KB, WebP 0.94 rund
45 KB -- bei einem Bild, das auf der Seite hoechstens 148 px breit
erscheint. WebP steckt im Repo bereits in <img> (workspace/index.html,
die Buehnenbilder) und in CSS (cryo-*.webp), ist also nichts Neues.
Aufrufen mit: node tools/partner-siegel-freistellen.mjs
=================================================================== */
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const WURZEL = join(dirname(fileURLToPath(import.meta.url)), "..");
const AUFTRAEGE = [
{
name: "Van's DIY & Bastelbedarf",
quelle: join(WURZEL, "assets", "img", "marke", "quelle-vandiy.jpg"),
ziel: join(WURZEL, "assets", "img", "partner-vandiy.webp"),
},
];
/* 384 px Kante. Das Medaillon auf rabattcodes.html misst hoechstens
148 CSS-px (Desktop) bzw. 116 (Handy) -- 384 deckt damit auch ein
Handy mit dreifacher Pixeldichte noch scharf ab, ohne dass die Datei
in die Naehe der 120-KB-Schwelle aus pruef-bildgroessen.mjs kommt. */
const KANTE = 384;
for (const a of AUFTRAEGE) {
if (!existsSync(a.quelle)) {
console.error(`Quelle fehlt: ${a.quelle}`);
process.exit(1);
}
}
const browser = await chromium.launch();
const seite = await (await browser.newContext()).newPage();
const arbeiten = async (auftrag) => await seite.evaluate(async ({ quelle, KANTE }) => {
const bild = new Image();
bild.src = quelle;
await bild.decode();
const b = bild.naturalWidth, h = bild.naturalHeight;
const leinwand = document.createElement("canvas");
leinwand.width = b; leinwand.height = h;
const stift = leinwand.getContext("2d", { willReadFrequently: true });
stift.drawImage(bild, 0, 0);
const daten = stift.getImageData(0, 0, b, h);
const p = daten.data;
/* GRUNDTON AUS DEM HAEUFIGSTEN RANDTON, nicht aus den vier Ecken.
Uebernommen aus logo-freistellen.mjs, wo die Eckenmittelung an
abgerundeten Ecken gescheitert ist. Der Weg ueber den ganzen Rand
ueberlebt Rahmen, runde Ecken und einzelne Ausreisser. */
const haufen = new Map();
const merke = (x, y) => {
const i = (y * b + x) * 4;
if (p[i + 3] < 200) return;
const s = `${p[i] >> 3},${p[i + 1] >> 3},${p[i + 2] >> 3}`;
const e = haufen.get(s) || { n: 0, r: 0, g: 0, bl: 0 };
e.n++; e.r += p[i]; e.g += p[i + 1]; e.bl += p[i + 2];
haufen.set(s, e);
};
for (let x = 0; x < b; x++) { merke(x, 0); merke(x, h - 1); }
for (let y = 0; y < h; y++) { merke(0, y); merke(b - 1, y); }
if (!haufen.size) return { fehler: "Der ganze Rand ist durchsichtig." };
const groesster = [...haufen.values()].sort((a, c) => c.n - a.n)[0];
const randPunkte = [...haufen.values()].reduce((s, e) => s + e.n, 0);
const randAnteil = groesster.n / randPunkte;
if (randAnteil < 0.5) {
return { fehler: `Der Rand ist nicht einfarbig genug `
+ `(haeufigster Ton nur ${Math.round(randAnteil * 100)} % des Randes).` };
}
const grund = [
Math.round(groesster.r / groesster.n),
Math.round(groesster.g / groesster.n),
Math.round(groesster.bl / groesster.n),
];
const abstand = (i) => Math.sqrt(
(p[i] - grund[0]) ** 2 + (p[i + 1] - grund[1]) ** 2 + (p[i + 2] - grund[2]) ** 2);
/* NAH bis hierher gilt ein Punkt als Hintergrund
WEIT ab hier gehoert er zum Motiv; dazwischen wird weich abgestuft.
Dieselben Werte wie bei den Marken: Auch diese Quelle ist ein JPEG,
und um den weissen Ring liegt ein Saum aus Kompressionsartefakten,
der weder Grund noch Motiv ist. Wer ihn stehen laesst, bekommt einen
schmutzigen Rand -- auf einer dunklen Seite faellt das als grauer
Hof um das Siegel auf. */
const NAH = 58, WEIT = 118;
const besucht = new Uint8Array(b * h);
const liste = [];
for (let x = 0; x < b; x++) { liste.push(x, x + (h - 1) * b); }
for (let y = 0; y < h; y++) { liste.push(y * b, b - 1 + y * b); }
let entfernt = 0, weich = 0;
while (liste.length) {
const feld = liste.pop();
if (besucht[feld]) continue;
besucht[feld] = 1;
const i = feld * 4;
const a = abstand(i);
if (a > WEIT) continue; // Motivkante erreicht, hier ist Schluss
if (a <= NAH) { p[i + 3] = 0; entfernt++; }
else { p[i + 3] = Math.round(255 * ((a - NAH) / (WEIT - NAH))); weich++; }
const x = feld % b, y = (feld - x) / b;
if (x > 0) liste.push(feld - 1);
if (x < b - 1) liste.push(feld + 1);
if (y > 0) liste.push(feld - b);
if (y < h - 1) liste.push(feld + b);
}
/* ---- Wo steht das Motiv? ---------------------------------------- */
let x0 = b, y0 = h, x1 = -1, y1 = -1, sichtbar = 0;
for (let y = 0; y < h; y++) for (let x = 0; x < b; x++) {
if (p[(y * b + x) * 4 + 3] > 40) {
sichtbar++;
if (x < x0) x0 = x; if (x > x1) x1 = x;
if (y < y0) y0 = y; if (y > y1) y1 = y;
}
}
if (x1 < 0) return { fehler: "Nach dem Freistellen ist nichts uebrig." };
stift.putImageData(daten, 0, 0);
/* ---- Quadratisch und zentriert ausgeben --------------------------
Das Medaillon in der Seite ist ein Kreis. Ein nicht quadratisch
ausgegebenes Bild sitzt darin aus der Mitte gerueckt -- und bei
einem runden Siegel sieht man einen Versatz von wenigen Punkten
sofort, weil zwei Kreise nebeneinander liegen. 6 % Luft, damit die
weiche Kante nicht am Bildrand abgeschnitten wird. */
const mb = x1 - x0 + 1, mh = y1 - y0 + 1;
const seiteQ = Math.round(Math.max(mb, mh) * 1.06);
const mx = x0 + mb / 2, my = y0 + mh / 2;
const ziel = document.createElement("canvas");
ziel.width = KANTE; ziel.height = KANTE;
const zs = ziel.getContext("2d");
zs.imageSmoothingQuality = "high";
zs.drawImage(leinwand, mx - seiteQ / 2, my - seiteQ / 2, seiteQ, seiteQ, 0, 0, KANTE, KANTE);
const zp = zs.getImageData(0, 0, KANTE, KANTE).data;
let deckend = 0, halb = 0;
for (let i = 3; i < zp.length; i += 4) {
if (zp[i] > 200) deckend++; else if (zp[i] > 10) halb++;
}
const eckenAlpha = [[0, 0], [KANTE - 1, 0], [0, KANTE - 1], [KANTE - 1, KANTE - 1]]
.map(([x, y]) => zp[(y * KANTE + x) * 4 + 3]);
const mitteAlpha = zp[((KANTE >> 1) * KANTE + (KANTE >> 1)) * 4 + 3];
return {
webp: ziel.toDataURL("image/webp", 0.94),
grund, randAnteil: Math.round(randAnteil * 100),
entfernt, weich, quelle: `${b}x${h}`, rahmen: `${mb}x${mh}`,
seitenverhaeltnis: mb / mh,
deckend, halb, eckenAlpha, mitteAlpha,
anteil: deckend / (KANTE * KANTE),
};
}, { quelle: "data:image/jpeg;base64," + readFileSync(auftrag.quelle).toString("base64"), KANTE });
let fehlt = 0;
for (const auftrag of AUFTRAEGE) {
console.log(`\n=== ${auftrag.name} ===`);
const e = await arbeiten(auftrag);
if (e.fehler) { console.error(` Abgebrochen: ${e.fehler}`); fehlt++; continue; }
console.log(` Quelle: ${e.quelle}, Grundton rgb(${e.grund.join(",")}) `
+ `auf ${e.randAnteil} % des Randes`);
console.log(` Freigestellt: ${e.entfernt} Punkte ganz, ${e.weich} weich`);
console.log(` Motivrahmen: ${e.rahmen} (Seitenverhaeltnis ${e.seitenverhaeltnis.toFixed(3)})`);
console.log(` Ergebnis: ${e.deckend} deckende Punkte (${(e.anteil * 100).toFixed(1)} %), `
+ `${e.halb} weiche Kante, Ecken-Alpha ${e.eckenAlpha.join("/")}, Mitte ${e.mitteAlpha}`);
/* VIER BEDINGUNGEN, und alle vier muessen halten. Die Anzahl steht in
der Bedingung, nicht nur im Meldetext -- eine Pruefung, die immer
bestaetigt, bestaetigt nichts. */
/* Ein Kreis fuellt pi/4 seines Quadrats, hier zusaetzlich geteilt
durch die 6 % Luft: 0,785 / 1,06^2 = 0,70. Der Rahmen 0,55..0,85
laesst Siegel mit Ecken oder mit Rand zu und faengt trotzdem beide
Ausrutscher: Fuellung hat das Motiv gefressen / es wurde nichts
entfernt. */
if (e.anteil < 0.55 || e.anteil > 0.85) {
console.error(` FEHLER: ${(e.anteil * 100).toFixed(1)} % Flaeche uebrig, erwartet 55-85 % `
+ `(ein Kreis mit 6 % Luft ergibt rund 70 %). Nichts gespeichert.`); fehlt++; continue;
}
/* Ecken durchsichtig -- genau daran ist die alte Workspace-Datei
gescheitert, und man sieht es einer Zahl wie "60,8 % deckend" nicht
an. */
if (e.eckenAlpha.some((a) => a > 20)) {
console.error(` FEHLER: eine Ecke ist noch deckend (${e.eckenAlpha.join("/")}) -- `
+ `der Kasten steht noch. Nichts gespeichert.`); fehlt++; continue;
}
/* ... und die Mitte NICHT. Ohne diese Gegenprobe waere ein Bild, aus
dem die Fuellung den Kern herausgeloest hat, durch die Eckenpruefung
glatt durchgelaufen. */
if (e.mitteAlpha < 250) {
console.error(` FEHLER: die Mitte ist durchsichtig (Alpha ${e.mitteAlpha}) -- die Fuellung `
+ `ist ins Motiv gelaufen. Nichts gespeichert.`); fehlt++; continue;
}
/* Rund heisst quadratischer Rahmen. Ein schiefes Verhaeltnis bedeutet,
dass die Fuellung an einer Seite weitergelaufen ist als an der
anderen -- im Medaillon saehe man das als aus der Mitte geruecktes
Siegel. */
if (Math.abs(e.seitenverhaeltnis - 1) > 0.04) {
console.error(` FEHLER: Motivrahmen ${e.rahmen} ist nicht rund `
+ `(Verhaeltnis ${e.seitenverhaeltnis.toFixed(3)}). Nichts gespeichert.`); fehlt++; continue;
}
writeFileSync(auftrag.ziel, Buffer.from(e.webp.split(",")[1], "base64"));
const kb = (readFileSync(auftrag.ziel).length / 1024).toFixed(1);
console.log(` Gespeichert: ${auftrag.ziel.replace(WURZEL, ".")} (${KANTE}x${KANTE}, ${kb} KB)`);
}
await browser.close();
if (fehlt) { console.error(`\n${fehlt} von ${AUFTRAEGE.length} nicht gespeichert.`); process.exit(1); }
console.log(`\n${AUFTRAEGE.length} Partnersiegel freigestellt.`);