diff --git a/assets/img/marke/quelle-vandiy.jpg b/assets/img/marke/quelle-vandiy.jpg
new file mode 100644
index 00000000..92db821a
Binary files /dev/null and b/assets/img/marke/quelle-vandiy.jpg differ
diff --git a/assets/img/partner-vandiy.webp b/assets/img/partner-vandiy.webp
new file mode 100644
index 00000000..cd1e2d55
Binary files /dev/null and b/assets/img/partner-vandiy.webp differ
diff --git a/assets/js/i18n-rabattcodes.js b/assets/js/i18n-rabattcodes.js
index bf304c6d..5110bb7b 100644
--- a/assets/js/i18n-rabattcodes.js
+++ b/assets/js/i18n-rabattcodes.js
@@ -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 "&" hin.
+ data-i18n-attr="alt:schluessel" -> el.setAttribute(...)
+ Dort gehört ein blankes "&" hin;
+ "&" 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: "10 % Rabatt auf deinen Einkauf",
+ "de-CH": "10 % Rabatt uf din Iikauf",
+ en: "10% off your order",
+ fr: "10 % de remise sur ta commande",
+ pt: "10 % 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",
diff --git a/pruef-rabattcodes.mjs b/pruef-rabattcodes.mjs
new file mode 100644
index 00000000..ebe1e038
--- /dev/null
+++ b/pruef-rabattcodes.mjs
@@ -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, "&" 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.");
diff --git a/rabattcodes.html b/rabattcodes.html
index 560cdba4..1cc054da 100644
--- a/rabattcodes.html
+++ b/rabattcodes.html
@@ -25,50 +25,11 @@
@@ -103,25 +401,54 @@
-
-
🏷️
-
Codes folgen in Kürze
-
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.
Weitere Codes folgen, sobald die nächsten Kooperationen stehen.
🔒
Nur für Dogfather-Supporter
-
Rabattcodes sind ein exklusiver Vorteil der DogiCrew 🩵. Werde jetzt Teil davon, um sie freizuschalten, sobald die ersten Kooperationen live sind.
+
Rabattcodes sind ein exklusiver Vorteil der DogiCrew 🩵. Der erste Partnercode ist schon drin — werde jetzt Teil davon, um ihn freizuschalten.
Jetzt Supporter werden 👑← Zurück zu allen Links
@@ -132,10 +459,27 @@
-
+
diff --git a/sitemap.xml b/sitemap.xml
index 5ef3fb1d..87c80f2b 100644
--- a/sitemap.xml
+++ b/sitemap.xml
@@ -15,7 +15,7 @@
https://dogfather-universe.com/medien-casper.html2026-08-01https://dogfather-universe.com/links.html2026-07-30https://dogfather-universe.com/abonnieren.html2026-08-03
- https://dogfather-universe.com/rabattcodes.html2026-08-03
+ https://dogfather-universe.com/rabattcodes.html2026-09-08https://dogfather-universe.com/manager.html2026-07-30https://dogfather-universe.com/werte.html2026-07-30https://dogfather-universe.com/team-scouts.html2026-07-30
diff --git a/tools/partner-siegel-freistellen.mjs b/tools/partner-siegel-freistellen.mjs
new file mode 100644
index 00000000..75a152f0
--- /dev/null
+++ b/tools/partner-siegel-freistellen.mjs
@@ -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 (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.`);