Tier-Familien-Seiten verfeinert, Foto-Karussell, Partnercode auf Sale-Ausschluss

Mehrere Feinschliff-Punkte auf den Tier-Familien-Seiten (z.B. "Hase"):
- Abstand zwischen der Story-Box und der Kartenreihe verkleinert (vorher
  durch zwei separate Section-Umrandungen unnötig groß) — beide Bereiche
  teilen sich jetzt eine gemeinsame Section statt zwei gestapelte.
- Platzhalter-Karten (Charaktere ohne Foto) bekommen jetzt exakt die
  gleiche Bildhöhe wie eine Karte mit echtem Foto (vorher griff nur die
  allgemeine Mindesthöhe, wirkte kleiner/anders proportioniert).
- Neues optionales Feld `bannerName` an der Unterkategorie: überschreibt
  NUR die große Bannerüberschrift (Breadcrumb/Chips bleiben unverändert)
  — für "Hase" jetzt auf "HasiDog" gesetzt, den bekanntesten Charakter
  der Familie.
- Unterkategorie-Seiten ganz ohne Produkte/Charaktere (z.B. Maschen-
  markierer, Holzknöpfe) zeigen jetzt schon 3 vorbereitete Platzhalter-
  Karten im selben Kartenstil statt nur einer nackten Textzeile — die
  Seite "sieht schon fertig aus", auch bevor echte Produkte da sind.
  Nebenbei einen Bug behoben: bei Unterkategorien ohne Charaktere, aber
  MIT echten Produkten, erschien vorher zusätzlich fälschlich der
  "noch keine Produkte"-Hinweis über der echten Produktreihe.

Neu: Mini-Bildkarussell direkt auf jeder Produktkarte (Shop-Grid,
Charakter-Kacheln, "Weitere Modelle", Sale-Seite, Startseite — überall
wo ProductCard verwendet wird). Sobald ein Produkt mehrere Fotos hat
(Admin-Bereich erlaubt das bereits über das Foto-Listenfeld), erscheinen
dezente ‹/›-Pfeile plus Punkte-Indikator direkt auf der Karte, ganz ohne
erst die Produktseite öffnen zu müssen.

Neu: Partner-Rabattcodes können im Admin jetzt gezielt auf "gilt NICHT
für Artikel, die schon im Sale sind" gestellt werden (giltAufSale:
false) — der Rabatt berechnet sich dann nur noch auf den regulär
bepreisten Teil des Warenkorbs, damit sich der Partner-Rabatt nicht
zusätzlich auf einen bereits reduzierten Preis obendrauf summiert.
Standardverhalten (Feld leer/true) bleibt wie bisher unverändert.

Alle Änderungen lokal mit Test-Daten durchgespielt (mehrere Testfotos,
Testprodukt im Sale + Partnercode mit/ohne giltAufSale, leere
Unterkategorie-Seite) und vor dem Commit wieder zurückgesetzt.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
qcigano
2026-08-02 23:41:27 +02:00
co-authored by Claude Sonnet 5
parent 6fc64e0972
commit e48700f982
13 changed files with 290 additions and 93 deletions
@@ -35,6 +35,9 @@ const characterEntries = characters.map((char) => ({
// Produkte ohne (bekannte) Charakter-Zuordnung landen als "weitere Modelle" darunter.
const zugeordneteSlugs = new Set(characterEntries.map((e) => e.product?.slug).filter(Boolean));
const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
const hatCharaktere = characterEntries.length > 0;
const hatWeitere = weitere.length > 0;
const komplettLeer = !hatCharaktere && !hatWeitere;
---
<Layout title={`${sub.name[lang]} ${category.name[lang]}`} description={`${sub.name[lang]} bi Van's DIY & Bastelbedarf.`} lang={lang} path={`/shop/${category.slug}/${sub.slug}/`}>
<div class="container">
@@ -44,18 +47,16 @@ const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
<section class="page-banner" style="background-image:url('/img/banner-shop.webp'); --banner-ratio: 1600 / 729;">
<div class="page-banner-content">
<span class="eyebrow" aria-hidden="true">{category.icon} {category.name[lang]}</span>
<h1>{sub.name[lang]}</h1>
<h1>{sub.bannerName ? sub.bannerName[lang] : sub.name[lang]}</h1>
</div>
</section>
</div>
{characterEntries.length > 0 ? (
<>
{/* Die persönlichen Charaktergeschichten stehen als eigene, volle Breite Boxen ÜBER der
Kartenreihe — getrennt von den "Shop"-Karten darunter, die nur Foto/Preis/Infos zeigen. */}
{characterEntries.some(({ product }) => product) && (
<section class="section-tight">
<div class="container character-stories">
{hatCharaktere && (
<section class="section-tight">
<div class="container">
{characterEntries.some(({ product }) => product) && (
<div class="character-stories">
{characterEntries.filter(({ product }) => product).map(({ char }) => (
<div class="card character-story">
{char.vorstellung && <span class="eyebrow">{char.vorstellung[lang].split("\n")[0]}</span>}
@@ -67,12 +68,9 @@ const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
</div>
))}
</div>
</section>
)}
)}
{/* Kartenreihe wie im Shop-Grid — nur Foto, Preis und Kaufinfos, keine Geschichten. */}
<section class="section-tight">
<div class="container grid grid-3">
<div class="grid grid-3">
{characterEntries.map(({ char, product }) =>
product ? (
<ProductCard product={product} lang={lang} />
@@ -87,20 +85,32 @@ const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
)
)}
</div>
</section>
</>
) : (
<section class="section-tight">
<div class="container">
<p class="small">{t.shop.subcategoryEmpty}</p>
</div>
</section>
)}
{weitere.length > 0 && (
{komplettLeer && (
<section class="section-tight">
<div class="container">
<h2 class="text-center small more-variants-label">{t.shop.moreVariants}</h2>
<div class="grid grid-3">
{[0, 1, 2].map(() => (
<div class="card character-card">
<div class="product-media">
<div class="img-placeholder" role="img" aria-label={sub.name[lang]}></div>
</div>
<h3>{sub.name[lang]}</h3>
<p class="character-card-desc">{t.shop.subcategoryEmpty}</p>
</div>
))}
</div>
</div>
</section>
)}
{hatWeitere && (
<section class="section-tight">
<div class="container">
{hatCharaktere && <h2 class="text-center small more-variants-label">{t.shop.moreVariants}</h2>}
<div class="grid grid-3">{weitere.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</div>
</section>
@@ -35,6 +35,9 @@ const characterEntries = characters.map((char) => ({
// Produkte ohne (bekannte) Charakter-Zuordnung landen als "weitere Modelle" darunter.
const zugeordneteSlugs = new Set(characterEntries.map((e) => e.product?.slug).filter(Boolean));
const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
const hatCharaktere = characterEntries.length > 0;
const hatWeitere = weitere.length > 0;
const komplettLeer = !hatCharaktere && !hatWeitere;
---
<Layout title={`${sub.name[lang]} ${category.name[lang]}`} description={`${sub.name[lang]} at Van's DIY & Bastelbedarf.`} lang={lang} path={`/shop/${category.slug}/${sub.slug}/`}>
<div class="container">
@@ -44,18 +47,16 @@ const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
<section class="page-banner" style="background-image:url('/img/banner-shop.webp'); --banner-ratio: 1600 / 729;">
<div class="page-banner-content">
<span class="eyebrow" aria-hidden="true">{category.icon} {category.name[lang]}</span>
<h1>{sub.name[lang]}</h1>
<h1>{sub.bannerName ? sub.bannerName[lang] : sub.name[lang]}</h1>
</div>
</section>
</div>
{characterEntries.length > 0 ? (
<>
{/* Die persönlichen Charaktergeschichten stehen als eigene, volle Breite Boxen ÜBER der
Kartenreihe — getrennt von den "Shop"-Karten darunter, die nur Foto/Preis/Infos zeigen. */}
{characterEntries.some(({ product }) => product) && (
<section class="section-tight">
<div class="container character-stories">
{hatCharaktere && (
<section class="section-tight">
<div class="container">
{characterEntries.some(({ product }) => product) && (
<div class="character-stories">
{characterEntries.filter(({ product }) => product).map(({ char }) => (
<div class="card character-story">
{char.vorstellung && <span class="eyebrow">{char.vorstellung[lang].split("\n")[0]}</span>}
@@ -67,12 +68,9 @@ const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
</div>
))}
</div>
</section>
)}
)}
{/* Kartenreihe wie im Shop-Grid — nur Foto, Preis und Kaufinfos, keine Geschichten. */}
<section class="section-tight">
<div class="container grid grid-3">
<div class="grid grid-3">
{characterEntries.map(({ char, product }) =>
product ? (
<ProductCard product={product} lang={lang} />
@@ -87,20 +85,32 @@ const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
)
)}
</div>
</section>
</>
) : (
<section class="section-tight">
<div class="container">
<p class="small">{t.shop.subcategoryEmpty}</p>
</div>
</section>
)}
{weitere.length > 0 && (
{komplettLeer && (
<section class="section-tight">
<div class="container">
<h2 class="text-center small more-variants-label">{t.shop.moreVariants}</h2>
<div class="grid grid-3">
{[0, 1, 2].map(() => (
<div class="card character-card">
<div class="product-media">
<div class="img-placeholder" role="img" aria-label={sub.name[lang]}></div>
</div>
<h3>{sub.name[lang]}</h3>
<p class="character-card-desc">{t.shop.subcategoryEmpty}</p>
</div>
))}
</div>
</div>
</section>
)}
{hatWeitere && (
<section class="section-tight">
<div class="container">
{hatCharaktere && <h2 class="text-center small more-variants-label">{t.shop.moreVariants}</h2>}
<div class="grid grid-3">{weitere.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</div>
</section>
@@ -35,6 +35,9 @@ const characterEntries = characters.map((char) => ({
// Produkte ohne (bekannte) Charakter-Zuordnung landen als "weitere Modelle" darunter.
const zugeordneteSlugs = new Set(characterEntries.map((e) => e.product?.slug).filter(Boolean));
const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
const hatCharaktere = characterEntries.length > 0;
const hatWeitere = weitere.length > 0;
const komplettLeer = !hatCharaktere && !hatWeitere;
---
<Layout title={`${sub.name[lang]} ${category.name[lang]}`} description={`${sub.name[lang]} chez Van's DIY & Bastelbedarf.`} lang={lang} path={`/shop/${category.slug}/${sub.slug}/`}>
<div class="container">
@@ -44,18 +47,16 @@ const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
<section class="page-banner" style="background-image:url('/img/banner-shop.webp'); --banner-ratio: 1600 / 729;">
<div class="page-banner-content">
<span class="eyebrow" aria-hidden="true">{category.icon} {category.name[lang]}</span>
<h1>{sub.name[lang]}</h1>
<h1>{sub.bannerName ? sub.bannerName[lang] : sub.name[lang]}</h1>
</div>
</section>
</div>
{characterEntries.length > 0 ? (
<>
{/* Die persönlichen Charaktergeschichten stehen als eigene, volle Breite Boxen ÜBER der
Kartenreihe — getrennt von den "Shop"-Karten darunter, die nur Foto/Preis/Infos zeigen. */}
{characterEntries.some(({ product }) => product) && (
<section class="section-tight">
<div class="container character-stories">
{hatCharaktere && (
<section class="section-tight">
<div class="container">
{characterEntries.some(({ product }) => product) && (
<div class="character-stories">
{characterEntries.filter(({ product }) => product).map(({ char }) => (
<div class="card character-story">
{char.vorstellung && <span class="eyebrow">{char.vorstellung[lang].split("\n")[0]}</span>}
@@ -67,12 +68,9 @@ const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
</div>
))}
</div>
</section>
)}
)}
{/* Kartenreihe wie im Shop-Grid — nur Foto, Preis und Kaufinfos, keine Geschichten. */}
<section class="section-tight">
<div class="container grid grid-3">
<div class="grid grid-3">
{characterEntries.map(({ char, product }) =>
product ? (
<ProductCard product={product} lang={lang} />
@@ -87,20 +85,32 @@ const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
)
)}
</div>
</section>
</>
) : (
<section class="section-tight">
<div class="container">
<p class="small">{t.shop.subcategoryEmpty}</p>
</div>
</section>
)}
{weitere.length > 0 && (
{komplettLeer && (
<section class="section-tight">
<div class="container">
<h2 class="text-center small more-variants-label">{t.shop.moreVariants}</h2>
<div class="grid grid-3">
{[0, 1, 2].map(() => (
<div class="card character-card">
<div class="product-media">
<div class="img-placeholder" role="img" aria-label={sub.name[lang]}></div>
</div>
<h3>{sub.name[lang]}</h3>
<p class="character-card-desc">{t.shop.subcategoryEmpty}</p>
</div>
))}
</div>
</div>
</section>
)}
{hatWeitere && (
<section class="section-tight">
<div class="container">
{hatCharaktere && <h2 class="text-center small more-variants-label">{t.shop.moreVariants}</h2>}
<div class="grid grid-3">{weitere.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</div>
</section>
@@ -35,6 +35,13 @@ const characterEntries = characters.map((char) => ({
// Produkte ohne (bekannte) Charakter-Zuordnung landen als "weitere Modelle" darunter.
const zugeordneteSlugs = new Set(characterEntries.map((e) => e.product?.slug).filter(Boolean));
const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
const hatCharaktere = characterEntries.length > 0;
const hatWeitere = weitere.length > 0;
// Damit "schon vorbereitet aussieht", auch wenn VanVan hier noch gar keine Produkte/Charaktere
// eingetragen hat: statt einer nackten Text-Zeile zeigen wir schon die spätere Karten-Optik,
// nur eben mit Platzhaltern. Sobald echte Produkte/Charaktere dazukommen, verschwinden diese
// automatisch (siehe hatCharaktere/hatWeitere oben).
const komplettLeer = !hatCharaktere && !hatWeitere;
---
<Layout title={`${sub.name[lang]} ${category.name[lang]}`} description={`${sub.name[lang]} bei Van's DIY & Bastelbedarf.`} lang={lang} path={`/shop/${category.slug}/${sub.slug}/`}>
<div class="container">
@@ -44,18 +51,18 @@ const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
<section class="page-banner" style="background-image:url('/img/banner-shop.webp'); --banner-ratio: 1600 / 729;">
<div class="page-banner-content">
<span class="eyebrow" aria-hidden="true">{category.icon} {category.name[lang]}</span>
<h1>{sub.name[lang]}</h1>
<h1>{sub.bannerName ? sub.bannerName[lang] : sub.name[lang]}</h1>
</div>
</section>
</div>
{characterEntries.length > 0 ? (
<>
{/* Die persönlichen Charaktergeschichten stehen als eigene, volle Breite Boxen ÜBER der
Kartenreihe — getrennt von den "Shop"-Karten darunter, die nur Foto/Preis/Infos zeigen. */}
{characterEntries.some(({ product }) => product) && (
<section class="section-tight">
<div class="container character-stories">
{hatCharaktere && (
<section class="section-tight">
<div class="container">
{/* Die persönlichen Charaktergeschichten stehen als eigene, volle Breite Boxen ÜBER der
Kartenreihe — getrennt von den "Shop"-Karten darunter, die nur Foto/Preis/Infos zeigen. */}
{characterEntries.some(({ product }) => product) && (
<div class="character-stories">
{characterEntries.filter(({ product }) => product).map(({ char }) => (
<div class="card character-story">
{char.vorstellung && <span class="eyebrow">{char.vorstellung[lang].split("\n")[0]}</span>}
@@ -67,12 +74,10 @@ const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
</div>
))}
</div>
</section>
)}
)}
{/* Kartenreihe wie im Shop-Grid — nur Foto, Preis und Kaufinfos, keine Geschichten. */}
<section class="section-tight">
<div class="container grid grid-3">
{/* Kartenreihe wie im Shop-Grid — nur Foto, Preis und Kaufinfos, keine Geschichten. */}
<div class="grid grid-3">
{characterEntries.map(({ char, product }) =>
product ? (
<ProductCard product={product} lang={lang} />
@@ -87,20 +92,32 @@ const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
)
)}
</div>
</section>
</>
) : (
<section class="section-tight">
<div class="container">
<p class="small">{t.shop.subcategoryEmpty}</p>
</div>
</section>
)}
{weitere.length > 0 && (
{komplettLeer && (
<section class="section-tight">
<div class="container">
<h2 class="text-center small more-variants-label">{t.shop.moreVariants}</h2>
<div class="grid grid-3">
{[0, 1, 2].map(() => (
<div class="card character-card">
<div class="product-media">
<div class="img-placeholder" role="img" aria-label={sub.name[lang]}></div>
</div>
<h3>{sub.name[lang]}</h3>
<p class="character-card-desc">{t.shop.subcategoryEmpty}</p>
</div>
))}
</div>
</div>
</section>
)}
{hatWeitere && (
<section class="section-tight">
<div class="container">
{hatCharaktere && <h2 class="text-center small more-variants-label">{t.shop.moreVariants}</h2>}
<div class="grid grid-3">{weitere.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</div>
</section>