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
+1
View File
@@ -312,6 +312,7 @@ collections:
- { label: "ODER: Rabatt als fester Betrag (€)", name: betrag, widget: number, value_type: float, step: 0.01, required: false } - { label: "ODER: Rabatt als fester Betrag (€)", name: betrag, widget: number, value_type: float, step: 0.01, required: false }
- { label: "Gültig bis (leer = unbegrenzt)", name: bis, widget: datetime, format: "YYYY-MM-DD", date_format: "YYYY-MM-DD", time_format: false, required: false } - { label: "Gültig bis (leer = unbegrenzt)", name: bis, widget: datetime, format: "YYYY-MM-DD", date_format: "YYYY-MM-DD", time_format: false, required: false }
- { label: "Aktiv", name: aktiv, widget: boolean, default: true, hint: "Ausschalten, um den Code vorübergehend zu sperren, ohne ihn (und die Historie) zu löschen." } - { label: "Aktiv", name: aktiv, widget: boolean, default: true, hint: "Ausschalten, um den Code vorübergehend zu sperren, ohne ihn (und die Historie) zu löschen." }
- { label: "Gilt auch für Artikel, die schon im Sale sind?", name: giltAufSale, widget: boolean, default: true, hint: "Anlassen (Ja) = der Rabatt gilt auf ALLES im Warenkorb, auch auf schon reduzierte Artikel. Ausschalten (Nein) = der Rabatt gilt NUR auf Artikel zum normalen Preis — schon reduzierte/Sale-Artikel bekommen dann keinen zusätzlichen Partner-Rabatt obendrauf." }
- { label: "Notiz zu den Konditionen (z.B. vereinbarte Provision)", name: notiz, widget: text, required: false, hint: "Rein intern für dich — wirkt sich nicht auf die Website aus." } - { label: "Notiz zu den Konditionen (z.B. vereinbarte Provision)", name: notiz, widget: text, required: false, hint: "Rein intern für dich — wirkt sich nicht auf die Website aus." }
- name: bankverbindung - name: bankverbindung
+103 -4
View File
@@ -23,9 +23,24 @@ const name = product.name[lang];
const desc = product.beschreibung[lang]; const desc = product.beschreibung[lang];
--- ---
<a href={`${localePrefix(lang)}/produkt/${product.slug}/`} class="card product-card"> <a href={`${localePrefix(lang)}/produkt/${product.slug}/`} class="card product-card">
<div class="product-media"> <div class="product-media" data-carousel>
{product.bilder && product.bilder.length > 0 ? ( {product.bilder && product.bilder.length > 0 ? (
<img class="product-photo" src={product.bilder[0]} alt={name} loading="lazy" /> <>
{product.bilder.map((bild, i) => (
<img class="product-photo carousel-img" src={bild} alt={name} loading="lazy" data-idx={i} style={i === 0 ? undefined : "display:none;"} />
))}
{product.bilder.length > 1 && (
<>
<button type="button" class="carousel-btn carousel-prev" data-dir="-1" aria-label={t.common.prevPhoto}></button>
<button type="button" class="carousel-btn carousel-next" data-dir="1" aria-label={t.common.nextPhoto}></button>
<div class="carousel-dots">
{product.bilder.map((_, i) => (
<span class={`carousel-dot ${i === 0 ? "is-active" : ""}`} data-idx={i}></span>
))}
</div>
</>
)}
</>
) : ( ) : (
<div class="img-placeholder" role="img" aria-label={`${t.common.photoSoon}: ${name}`}> <div class="img-placeholder" role="img" aria-label={`${t.common.photoSoon}: ${name}`}>
{t.common.photoSoon}<br /><span class="small">{name}</span> {t.common.photoSoon}<br /><span class="small">{name}</span>
@@ -48,11 +63,95 @@ const desc = product.beschreibung[lang];
</div> </div>
</a> </a>
<script>
// Mini-Bildergalerie direkt auf der Karte (Shop-Grid, Charakter-Kacheln, "Weitere Modelle" ...):
// sobald ein Produkt mehr als ein Foto hat, kann man hier sofort mit / durchblättern, ohne
// erst die Produktseite öffnen zu müssen. EIN einziger, delegierter Klick-Listener für alle
// Karten der Seite (Astro dedupliziert dieses <script> automatisch, egal wie oft ProductCard
// auf der Seite eingebunden wird — kein Mehrfach-Registrieren). preventDefault/stopPropagation
// ist nötig, weil die ganze Karte selbst ein <a>-Link ist — sonst würde ein Klick auf den Pfeil
// gleichzeitig zur Produktseite weiterspringen.
function karussellWechseln(media: Element, nextIdx: number) {
const imgs = [...media.querySelectorAll<HTMLElement>(".carousel-img")];
const dots = [...media.querySelectorAll<HTMLElement>(".carousel-dot")];
if (imgs.length === 0) return;
const clamped = ((nextIdx % imgs.length) + imgs.length) % imgs.length;
imgs.forEach((img, i) => { img.style.display = i === clamped ? "" : "none"; });
dots.forEach((dot, i) => dot.classList.toggle("is-active", i === clamped));
}
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
const control = target.closest<HTMLElement>(".carousel-btn, .carousel-dot");
if (!control) return;
const media = control.closest("[data-carousel]");
if (!media) return;
e.preventDefault();
e.stopPropagation();
const imgs = [...media.querySelectorAll<HTMLElement>(".carousel-img")];
const current = Math.max(0, imgs.findIndex((img) => img.style.display !== "none"));
if (control.classList.contains("carousel-dot")) {
karussellWechseln(media, Number(control.dataset.idx));
} else {
karussellWechseln(media, current + Number(control.dataset.dir));
}
});
</script>
<style> <style>
.product-card { display: flex; flex-direction: column; gap: 0.6rem; padding: 1rem; } .product-card { display: flex; flex-direction: column; gap: 0.6rem; padding: 1rem; }
.product-media { position: relative; } .product-media { position: relative; isolation: isolate; }
.product-media .img-placeholder { aspect-ratio: 1 / 1; min-height: 0; } .product-media .img-placeholder { aspect-ratio: 1 / 1; min-height: 0; }
.product-badges { position: absolute; top: 0.6rem; left: 0.6rem; display: flex; gap: 0.4rem; flex-wrap: wrap; } .product-badges { position: absolute; top: 0.6rem; left: 0.6rem; display: flex; gap: 0.4rem; flex-wrap: wrap; z-index: 2; }
/* Mini-Karussell: Pfeile links/rechts direkt auf der Karte, sobald ein Produkt mehr als ein
Foto hat. Dezent, aber immer sichtbar (nicht nur bei Hover) — sonst wären sie auf
Touch-Geräten (Handy/Tablet, kein Hover) gar nicht erreichbar. */
.carousel-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid rgba(244, 241, 247, 0.18);
background: rgba(10, 9, 16, 0.55);
color: #fff;
font-size: 1.15rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
backdrop-filter: blur(3px);
transition: background 0.2s var(--ease), border-color 0.2s var(--ease), transform 0.2s var(--ease);
}
.carousel-btn:hover { background: rgba(10, 9, 16, 0.85); border-color: var(--c-accent); }
.carousel-prev { left: 0.5rem; }
.carousel-next { right: 0.5rem; }
.carousel-prev:hover { transform: translateY(-50%) translateX(-2px); }
.carousel-next:hover { transform: translateY(-50%) translateX(2px); }
.carousel-dots {
position: absolute;
bottom: 0.55rem;
left: 0;
right: 0;
z-index: 2;
display: flex;
justify-content: center;
gap: 0.35rem;
}
.carousel-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.45);
border: 1px solid rgba(10, 9, 16, 0.4);
cursor: pointer;
transition: background 0.2s var(--ease), transform 0.2s var(--ease);
}
.carousel-dot.is-active { background: var(--c-accent); transform: scale(1.25); }
.product-card h3 { font-size: 1.05rem; margin: 0.2rem 0 0; transition: color 0.2s var(--ease); } .product-card h3 { font-size: 1.05rem; margin: 0.2rem 0 0; transition: color 0.2s var(--ease); }
.product-card:hover h3 { color: var(--c-accent); } .product-card:hover h3 { color: var(--c-accent); }
.product-card p { margin: 0; } .product-card p { margin: 0; }
+1
View File
@@ -34,6 +34,7 @@
{ {
"slug": "hase", "slug": "hase",
"name": { "de": "Hase", "en": "Bunny", "ch": "Hase", "fr": "Lapin" }, "name": { "de": "Hase", "en": "Bunny", "ch": "Hase", "fr": "Lapin" },
"bannerName": { "de": "HasiDog", "en": "HasiDog", "ch": "HasiDog", "fr": "HasiDog" },
"characters": [ "characters": [
{ {
"slug": "hasidog", "slug": "hasidog",
+5
View File
@@ -24,6 +24,11 @@ export interface Character {
export interface Subcategory { export interface Subcategory {
slug: string; slug: string;
name: LocalizedText; name: LocalizedText;
// Überschreibt NUR die große Überschrift im Banner oben auf der Unterkategorie-Seite (Breadcrumb,
// Navigations-Chips etc. zeigen weiterhin `name`) — z.B. um bei "Hase" statt des Familiennamens
// gezielt den bekanntesten Charakter ("HasiDog") ins Rampenlicht zu stellen. Optional; ohne
// Angabe verhält sich die Seite wie bisher (zeigt `name`).
bannerName?: LocalizedText;
// Ein oder mehrere Charaktere dieser Tier-Familie — jeder bekommt seine eigene Box auf der // Ein oder mehrere Charaktere dieser Tier-Familie — jeder bekommt seine eigene Box auf der
// Unterkategorie-Seite (Foto/Text abwechselnd links/rechts), verknüpft mit dem passenden // Unterkategorie-Seite (Foto/Text abwechselnd links/rechts), verknüpft mit dem passenden
// Produkt über Product.charakter (siehe products.ts). // Produkt über Product.charakter (siehe products.ts).
+6
View File
@@ -26,6 +26,12 @@ export interface Partnercode {
bis?: string; // ISO-Datum, optional bis?: string; // ISO-Datum, optional
/** Auf false setzen, um einen Code zu pausieren, ohne ihn (und die Partner-Historie) zu löschen. */ /** Auf false setzen, um einen Code zu pausieren, ohne ihn (und die Partner-Historie) zu löschen. */
aktiv?: boolean; aktiv?: boolean;
/** Ob der Code auch auf Artikel gilt, die schon im Sale sind (preisAlt gesetzt ODER aktiver
* Prozent-/Betrags-Rabatt, siehe istImSale() in rabatt.ts). Standard: true (wie bisher — gilt
* auf alles). Auf false setzen, damit der Partner-Rabatt NICHT zusätzlich auf schon
* reduzierte Artikel obendrauf kommt, sondern nur auf regulär bepreiste Artikel im Warenkorb
* wirkt. */
giltAufSale?: boolean;
} }
const partnercodes = (partnercodesRaw as { codes: Partnercode[] }).codes ?? []; const partnercodes = (partnercodesRaw as { codes: Partnercode[] }).codes ?? [];
+8
View File
@@ -41,6 +41,14 @@ export function hatAktivenRabatt(product: Product): boolean {
return effektiverPreis(product) < product.preis; return effektiverPreis(product) < product.preis;
} }
/** Ob ein Produkt gerade "im Sale" ist — zählt sowohl den klassischen, manuell gesetzten
* Sale-Preis (preisAlt) als auch einen automatisch berechneten, noch aktiven Prozent-/
* Betrags-Rabatt. Wird u.a. von Partner-Rabattcodes genutzt, die per Admin-Einstellung NUR auf
* regulär bepreiste Artikel gelten sollen (siehe Partnercode.giltAufSale in partnercodes.ts). */
export function istImSale(product: Product): boolean {
return Boolean(product.preisAlt) || hatAktivenRabatt(product);
}
/** Für die Badge-Anzeige: "-20%" oder "-5 €", je nachdem was hinterlegt ist. */ /** Für die Badge-Anzeige: "-20%" oder "-5 €", je nachdem was hinterlegt ist. */
export function rabattLabel(product: Product): string | null { export function rabattLabel(product: Product): string | null {
const r = product.rabatt; const r = product.rabatt;
+4
View File
@@ -26,6 +26,7 @@ export const ui = {
vatNote: "Gemäß §19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.", vatNote: "Gemäß §19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.",
allPricesPlusShipping: "Alle Preise zzgl. Versand.", plusShipping: "zzgl. Versand", allPricesPlusShipping: "Alle Preise zzgl. Versand.", plusShipping: "zzgl. Versand",
quantity: "Anzahl", photoSoon: "Produktfoto folgt", portraitSoon: "Porträtfoto von VanVan folgt", quantity: "Anzahl", photoSoon: "Produktfoto folgt", portraitSoon: "Porträtfoto von VanVan folgt",
prevPhoto: "Vorheriges Foto", nextPhoto: "Nächstes Foto",
handmadeBadge: "🩵 Handgemacht", handmadeBadge: "🩵 Handgemacht",
}, },
product: { product: {
@@ -212,6 +213,7 @@ export const ui = {
vatNote: "Under §19 UStG (German small-business rule) no VAT is charged or shown.", vatNote: "Under §19 UStG (German small-business rule) no VAT is charged or shown.",
allPricesPlusShipping: "All prices plus shipping.", plusShipping: "plus shipping", allPricesPlusShipping: "All prices plus shipping.", plusShipping: "plus shipping",
quantity: "Quantity", photoSoon: "Product photo coming soon", portraitSoon: "VanVan's portrait photo coming soon", quantity: "Quantity", photoSoon: "Product photo coming soon", portraitSoon: "VanVan's portrait photo coming soon",
prevPhoto: "Previous photo", nextPhoto: "Next photo",
handmadeBadge: "🩵 Handmade", handmadeBadge: "🩵 Handmade",
}, },
product: { product: {
@@ -398,6 +400,7 @@ export const ui = {
vatNote: "Gemäss §19 UStG (dütsches Rächt) wird kei Umsatzsteuer berechnet und usgwiese.", vatNote: "Gemäss §19 UStG (dütsches Rächt) wird kei Umsatzsteuer berechnet und usgwiese.",
allPricesPlusShipping: "Alli Pryse zzgl. Versand.", plusShipping: "zzgl. Versand", allPricesPlusShipping: "Alli Pryse zzgl. Versand.", plusShipping: "zzgl. Versand",
quantity: "Aazahl", photoSoon: "Produktfoto chunnt bald", portraitSoon: "Porträtfoto vo VanVan chunnt bald", quantity: "Aazahl", photoSoon: "Produktfoto chunnt bald", portraitSoon: "Porträtfoto vo VanVan chunnt bald",
prevPhoto: "Vorherigs Foto", nextPhoto: "Nächschts Foto",
handmadeBadge: "🩵 Vo Hang gmacht", handmadeBadge: "🩵 Vo Hang gmacht",
}, },
product: { product: {
@@ -584,6 +587,7 @@ export const ui = {
vatNote: "Conformément à l'§19 UStG (droit allemand), aucune TVA n'est calculée ni indiquée.", vatNote: "Conformément à l'§19 UStG (droit allemand), aucune TVA n'est calculée ni indiquée.",
allPricesPlusShipping: "Tous les prix hors frais de port.", plusShipping: "hors frais de port", allPricesPlusShipping: "Tous les prix hors frais de port.", plusShipping: "hors frais de port",
quantity: "Quantité", photoSoon: "Photo du produit à venir", portraitSoon: "Photo de VanVan à venir", quantity: "Quantité", photoSoon: "Photo du produit à venir", portraitSoon: "Photo de VanVan à venir",
prevPhoto: "Photo précédente", nextPhoto: "Photo suivante",
handmadeBadge: "🩵 Fait main", handmadeBadge: "🩵 Fait main",
}, },
product: { product: {
@@ -35,6 +35,9 @@ const characterEntries = characters.map((char) => ({
// Produkte ohne (bekannte) Charakter-Zuordnung landen als "weitere Modelle" darunter. // Produkte ohne (bekannte) Charakter-Zuordnung landen als "weitere Modelle" darunter.
const zugeordneteSlugs = new Set(characterEntries.map((e) => e.product?.slug).filter(Boolean)); const zugeordneteSlugs = new Set(characterEntries.map((e) => e.product?.slug).filter(Boolean));
const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug)); 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}/`}> <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"> <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;"> <section class="page-banner" style="background-image:url('/img/banner-shop.webp'); --banner-ratio: 1600 / 729;">
<div class="page-banner-content"> <div class="page-banner-content">
<span class="eyebrow" aria-hidden="true">{category.icon} {category.name[lang]}</span> <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> </div>
</section> </section>
</div> </div>
{characterEntries.length > 0 ? ( {hatCharaktere && (
<> <section class="section-tight">
{/* Die persönlichen Charaktergeschichten stehen als eigene, volle Breite Boxen ÜBER der <div class="container">
Kartenreihe — getrennt von den "Shop"-Karten darunter, die nur Foto/Preis/Infos zeigen. */} {characterEntries.some(({ product }) => product) && (
{characterEntries.some(({ product }) => product) && ( <div class="character-stories">
<section class="section-tight">
<div class="container character-stories">
{characterEntries.filter(({ product }) => product).map(({ char }) => ( {characterEntries.filter(({ product }) => product).map(({ char }) => (
<div class="card character-story"> <div class="card character-story">
{char.vorstellung && <span class="eyebrow">{char.vorstellung[lang].split("\n")[0]}</span>} {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>
))} ))}
</div> </div>
</section> )}
)}
{/* Kartenreihe wie im Shop-Grid — nur Foto, Preis und Kaufinfos, keine Geschichten. */} <div class="grid grid-3">
<section class="section-tight">
<div class="container grid grid-3">
{characterEntries.map(({ char, product }) => {characterEntries.map(({ char, product }) =>
product ? ( product ? (
<ProductCard product={product} lang={lang} /> <ProductCard product={product} lang={lang} />
@@ -87,20 +85,32 @@ const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
) )
)} )}
</div> </div>
</section>
</>
) : (
<section class="section-tight">
<div class="container">
<p class="small">{t.shop.subcategoryEmpty}</p>
</div> </div>
</section> </section>
)} )}
{weitere.length > 0 && ( {komplettLeer && (
<section class="section-tight"> <section class="section-tight">
<div class="container"> <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 class="grid grid-3">{weitere.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</div> </div>
</section> </section>
@@ -35,6 +35,9 @@ const characterEntries = characters.map((char) => ({
// Produkte ohne (bekannte) Charakter-Zuordnung landen als "weitere Modelle" darunter. // Produkte ohne (bekannte) Charakter-Zuordnung landen als "weitere Modelle" darunter.
const zugeordneteSlugs = new Set(characterEntries.map((e) => e.product?.slug).filter(Boolean)); const zugeordneteSlugs = new Set(characterEntries.map((e) => e.product?.slug).filter(Boolean));
const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug)); 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}/`}> <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"> <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;"> <section class="page-banner" style="background-image:url('/img/banner-shop.webp'); --banner-ratio: 1600 / 729;">
<div class="page-banner-content"> <div class="page-banner-content">
<span class="eyebrow" aria-hidden="true">{category.icon} {category.name[lang]}</span> <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> </div>
</section> </section>
</div> </div>
{characterEntries.length > 0 ? ( {hatCharaktere && (
<> <section class="section-tight">
{/* Die persönlichen Charaktergeschichten stehen als eigene, volle Breite Boxen ÜBER der <div class="container">
Kartenreihe — getrennt von den "Shop"-Karten darunter, die nur Foto/Preis/Infos zeigen. */} {characterEntries.some(({ product }) => product) && (
{characterEntries.some(({ product }) => product) && ( <div class="character-stories">
<section class="section-tight">
<div class="container character-stories">
{characterEntries.filter(({ product }) => product).map(({ char }) => ( {characterEntries.filter(({ product }) => product).map(({ char }) => (
<div class="card character-story"> <div class="card character-story">
{char.vorstellung && <span class="eyebrow">{char.vorstellung[lang].split("\n")[0]}</span>} {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>
))} ))}
</div> </div>
</section> )}
)}
{/* Kartenreihe wie im Shop-Grid — nur Foto, Preis und Kaufinfos, keine Geschichten. */} <div class="grid grid-3">
<section class="section-tight">
<div class="container grid grid-3">
{characterEntries.map(({ char, product }) => {characterEntries.map(({ char, product }) =>
product ? ( product ? (
<ProductCard product={product} lang={lang} /> <ProductCard product={product} lang={lang} />
@@ -87,20 +85,32 @@ const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
) )
)} )}
</div> </div>
</section>
</>
) : (
<section class="section-tight">
<div class="container">
<p class="small">{t.shop.subcategoryEmpty}</p>
</div> </div>
</section> </section>
)} )}
{weitere.length > 0 && ( {komplettLeer && (
<section class="section-tight"> <section class="section-tight">
<div class="container"> <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 class="grid grid-3">{weitere.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</div> </div>
</section> </section>
@@ -35,6 +35,9 @@ const characterEntries = characters.map((char) => ({
// Produkte ohne (bekannte) Charakter-Zuordnung landen als "weitere Modelle" darunter. // Produkte ohne (bekannte) Charakter-Zuordnung landen als "weitere Modelle" darunter.
const zugeordneteSlugs = new Set(characterEntries.map((e) => e.product?.slug).filter(Boolean)); const zugeordneteSlugs = new Set(characterEntries.map((e) => e.product?.slug).filter(Boolean));
const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug)); 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}/`}> <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"> <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;"> <section class="page-banner" style="background-image:url('/img/banner-shop.webp'); --banner-ratio: 1600 / 729;">
<div class="page-banner-content"> <div class="page-banner-content">
<span class="eyebrow" aria-hidden="true">{category.icon} {category.name[lang]}</span> <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> </div>
</section> </section>
</div> </div>
{characterEntries.length > 0 ? ( {hatCharaktere && (
<> <section class="section-tight">
{/* Die persönlichen Charaktergeschichten stehen als eigene, volle Breite Boxen ÜBER der <div class="container">
Kartenreihe — getrennt von den "Shop"-Karten darunter, die nur Foto/Preis/Infos zeigen. */} {characterEntries.some(({ product }) => product) && (
{characterEntries.some(({ product }) => product) && ( <div class="character-stories">
<section class="section-tight">
<div class="container character-stories">
{characterEntries.filter(({ product }) => product).map(({ char }) => ( {characterEntries.filter(({ product }) => product).map(({ char }) => (
<div class="card character-story"> <div class="card character-story">
{char.vorstellung && <span class="eyebrow">{char.vorstellung[lang].split("\n")[0]}</span>} {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>
))} ))}
</div> </div>
</section> )}
)}
{/* Kartenreihe wie im Shop-Grid — nur Foto, Preis und Kaufinfos, keine Geschichten. */} <div class="grid grid-3">
<section class="section-tight">
<div class="container grid grid-3">
{characterEntries.map(({ char, product }) => {characterEntries.map(({ char, product }) =>
product ? ( product ? (
<ProductCard product={product} lang={lang} /> <ProductCard product={product} lang={lang} />
@@ -87,20 +85,32 @@ const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
) )
)} )}
</div> </div>
</section>
</>
) : (
<section class="section-tight">
<div class="container">
<p class="small">{t.shop.subcategoryEmpty}</p>
</div> </div>
</section> </section>
)} )}
{weitere.length > 0 && ( {komplettLeer && (
<section class="section-tight"> <section class="section-tight">
<div class="container"> <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 class="grid grid-3">{weitere.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</div> </div>
</section> </section>
@@ -35,6 +35,13 @@ const characterEntries = characters.map((char) => ({
// Produkte ohne (bekannte) Charakter-Zuordnung landen als "weitere Modelle" darunter. // Produkte ohne (bekannte) Charakter-Zuordnung landen als "weitere Modelle" darunter.
const zugeordneteSlugs = new Set(characterEntries.map((e) => e.product?.slug).filter(Boolean)); const zugeordneteSlugs = new Set(characterEntries.map((e) => e.product?.slug).filter(Boolean));
const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug)); 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}/`}> <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"> <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;"> <section class="page-banner" style="background-image:url('/img/banner-shop.webp'); --banner-ratio: 1600 / 729;">
<div class="page-banner-content"> <div class="page-banner-content">
<span class="eyebrow" aria-hidden="true">{category.icon} {category.name[lang]}</span> <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> </div>
</section> </section>
</div> </div>
{characterEntries.length > 0 ? ( {hatCharaktere && (
<> <section class="section-tight">
{/* Die persönlichen Charaktergeschichten stehen als eigene, volle Breite Boxen ÜBER der <div class="container">
Kartenreihe — getrennt von den "Shop"-Karten darunter, die nur Foto/Preis/Infos zeigen. */} {/* Die persönlichen Charaktergeschichten stehen als eigene, volle Breite Boxen ÜBER der
{characterEntries.some(({ product }) => product) && ( Kartenreihe — getrennt von den "Shop"-Karten darunter, die nur Foto/Preis/Infos zeigen. */}
<section class="section-tight"> {characterEntries.some(({ product }) => product) && (
<div class="container character-stories"> <div class="character-stories">
{characterEntries.filter(({ product }) => product).map(({ char }) => ( {characterEntries.filter(({ product }) => product).map(({ char }) => (
<div class="card character-story"> <div class="card character-story">
{char.vorstellung && <span class="eyebrow">{char.vorstellung[lang].split("\n")[0]}</span>} {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>
))} ))}
</div> </div>
</section> )}
)}
{/* Kartenreihe wie im Shop-Grid — nur Foto, Preis und Kaufinfos, keine Geschichten. */} {/* Kartenreihe wie im Shop-Grid — nur Foto, Preis und Kaufinfos, keine Geschichten. */}
<section class="section-tight"> <div class="grid grid-3">
<div class="container grid grid-3">
{characterEntries.map(({ char, product }) => {characterEntries.map(({ char, product }) =>
product ? ( product ? (
<ProductCard product={product} lang={lang} /> <ProductCard product={product} lang={lang} />
@@ -87,20 +92,32 @@ const weitere = alleProdukte.filter((p) => !zugeordneteSlugs.has(p.slug));
) )
)} )}
</div> </div>
</section>
</>
) : (
<section class="section-tight">
<div class="container">
<p class="small">{t.shop.subcategoryEmpty}</p>
</div> </div>
</section> </section>
)} )}
{weitere.length > 0 && ( {komplettLeer && (
<section class="section-tight"> <section class="section-tight">
<div class="container"> <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 class="grid grid-3">{weitere.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</div> </div>
</section> </section>
+25 -4
View File
@@ -2,7 +2,7 @@
// TODO Phase 2: durch echten Cloudflare-Worker-Warenkorb/Checkout ersetzen. // TODO Phase 2: durch echten Cloudflare-Worker-Warenkorb/Checkout ersetzen.
import { getProduct } from "../data/products"; import { getProduct } from "../data/products";
import { effektiverZeilenpreis } from "../data/rabatt"; import { effektiverZeilenpreis, istImSale } from "../data/rabatt";
import { findeGutschein, gutscheinRabatt, type Gutschein } from "../data/gutscheine"; import { findeGutschein, gutscheinRabatt, type Gutschein } from "../data/gutscheine";
import { findePartnercode, partnercodeRabatt, type Partnercode } from "../data/partnercodes"; import { findePartnercode, partnercodeRabatt, type Partnercode } from "../data/partnercodes";
@@ -159,10 +159,31 @@ export function appliedPartnerCode(): Partnercode | undefined {
return findePartnercode(code); return findePartnercode(code);
} }
/** Rabatt-Betrag in Euro, den der eingelöste Partnercode auf die gegebene Zwischensumme gibt. */ /** Warenwert, auf den der aktuell eingelöste Partnercode überhaupt angewendet werden darf. Ist
export function partnerCodeDiscount(subtotal: number): number { * `giltAufSale` am Code auf false gesetzt, zählen NUR Zeilen mit regulärem Preis (kein
* preisAlt, kein aktiver Prozent-/Betrags-Rabatt) — schon reduzierte Artikel bleiben dann außen
* vor, damit sich der Partner-Rabatt nicht zusätzlich auf einen bereits reduzierten Preis
* draufsummiert. Ohne Angabe (Standard) zählt wie bisher der komplette Warenwert. */
export function partnerCodeEligibleSubtotal(): number {
const p = appliedPartnerCode(); const p = appliedPartnerCode();
return p ? partnercodeRabatt(p, subtotal) : 0; if (!p) return 0;
return getCart()
.filter(istAusgewaehlt)
.reduce((sum, i) => {
const produkt = getProduct(i.slug);
if (!produkt) return sum + i.menge * i.preis;
if (p.giltAufSale === false && istImSale(produkt)) return sum;
return sum + effektiverZeilenpreis(produkt, i.menge);
}, 0);
}
/** Rabatt-Betrag in Euro, den der eingelöste Partnercode gibt — berechnet auf Basis von
* partnerCodeEligibleSubtotal(), NICHT auf der kompletten übergebenen Zwischensumme (siehe dort
* für den Hintergrund zu `giltAufSale`). Der Parameter bleibt der Vollständigkeit halber
* bestehen (identische Signatur wie couponDiscount), wird aber nicht mehr direkt verwendet. */
export function partnerCodeDiscount(_subtotal?: number): number {
const p = appliedPartnerCode();
return p ? partnercodeRabatt(p, partnerCodeEligibleSubtotal()) : 0;
} }
/** Gutschein und Partnercode können gleichzeitig eingelöst sein (zwei unabhängige Codes) — die /** Gutschein und Partnercode können gleichzeitig eingelöst sein (zwei unabhängige Codes) — die
+6 -1
View File
@@ -807,7 +807,7 @@ a:focus-visible, button:focus-visible {
/* Persönliche Charaktergeschichten (Hallo-ich-bin-Texte) auf den Tier-Familien-Seiten — eigene, /* Persönliche Charaktergeschichten (Hallo-ich-bin-Texte) auf den Tier-Familien-Seiten — eigene,
volle Breite Karten übereinander gestapelt, GETRENNT von den Shop-Karten darunter (die zeigen volle Breite Karten übereinander gestapelt, GETRENNT von den Shop-Karten darunter (die zeigen
nur noch Foto/Preis/Kaufinfos, keine Geschichten). */ nur noch Foto/Preis/Kaufinfos, keine Geschichten). */
.character-stories { display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2.2rem; } .character-stories { display: flex; flex-direction: column; gap: 1rem; margin-bottom: 0.6rem; }
/* Die Story-Karte bekommt einen eigenen, "besonderen" Auftritt statt der schlichten Basis-Karte: /* Die Story-Karte bekommt einen eigenen, "besonderen" Auftritt statt der schlichten Basis-Karte:
ein großes, dezentes Anführungszeichen als Wasserzeichen (reine Formatierung, kein neuer Text), ein großes, dezentes Anführungszeichen als Wasserzeichen (reine Formatierung, kein neuer Text),
@@ -878,6 +878,11 @@ a:focus-visible, button:focus-visible {
echten Produktkarten (ProductCard.astro), damit die Reihe einheitlich aussieht. */ echten Produktkarten (ProductCard.astro), damit die Reihe einheitlich aussieht. */
.character-card { display: flex; flex-direction: column; gap: 0.6rem; padding: 1rem; } .character-card { display: flex; flex-direction: column; gap: 0.6rem; padding: 1rem; }
.character-card .product-media { position: relative; } .character-card .product-media { position: relative; }
/* Gleiche Bildhöhe wie ein echtes Produktfoto (siehe ProductCard.astro .product-media
.img-placeholder) — vorher griff hier nur die allgemeine .img-placeholder-Mindesthöhe
(160px), wodurch die Platzhalter-Karten kleiner/anders proportioniert wirkten als die
Karte mit echtem Foto daneben. */
.character-card .product-media .img-placeholder { aspect-ratio: 1 / 1; min-height: 0; }
.character-card h3 { font-size: 1.15rem; margin: 0.1rem 0 0; } .character-card h3 { font-size: 1.15rem; margin: 0.1rem 0 0; }
.character-card-desc { margin: 0; color: var(--c-text-muted); font-size: 0.92rem; } .character-card-desc { margin: 0; color: var(--c-text-muted); font-size: 0.92rem; }