fix: SEO- und Barrierefreiheits-Luecken auf Kaufentscheidungsseiten schliessen

Konkrete, verifizierte Luecken auf den Seiten, die Kund:innen von Google
aus oder per Link erreichen:

- Product-JSON-LD auf der Produktdetailseite ergaenzt um "image" (absolute
  URLs der hochgeladenen Produktfotos) — fehlte bisher trotz vorhandener
  Bilder und ist fuer Google Rich-Results/Merchant-Listing mit Sternebewertung
  + Preis im Suchergebnis erforderlich.
- Alt-Text fuer echte (hochgeladene) Bilder aussagekraeftiger: ContentImage
  uebernimmt jetzt das bereits vorhandene, bisher nur auf Platzhalterkacheln
  sichtbare "label" (Ernte-Jahr/Typ, Kategorie, Ort) mit in den Alt-Text —
  wirkt projektweit auf Produktkarten, Ernte-Uebersicht/-Detail und
  Produkt-Teaser. Produktgalerie-Hauptbild zeigt zusaetzlich die
  Geschmacksnoten im Alt-Text statt nur des Produktnamens.
- Checkout: allgemeine Fehlermeldung nach fehlgeschlagenem Absenden bekommt
  role="alert" und Fokus wird aktiv dorthin verschoben — bisher rein farblich
  markiert, Screenreader-Nutzer:innen haetten den Fehler beim geldwerten
  Bestellabschluss sonst gar nicht mitbekommen.
- Cart-Drawer und Warenkorb-Seite: Lagerbestand-Hinweise (Ausverkauft/Nur
  noch N) treffen asynchron erst nach dem ersten Rendern ein — jetzt in
  einem persistenten aria-live-Wrapper, damit der Uebergang tatsaechlich
  angesagt wird statt stumm zu bleiben.

Geprueft und bereits solide befunden (keine Aenderung noetig): Shop-Kanonical
ignoriert bewusst Such-/Filter-Query-Parameter (kein Duplicate-Content-Risiko),
Sitemap deckt Produkt- und Ernte-Detailseiten mit echtem lastmod ab,
Produktgalerie-Tastaturbedienung ueber echte <button>-Kacheln mit
aria-label/aria-current, Cart-Drawer-Fokusfalle/Fokus-Rueckgabe, Mengen-Steller-
aria-live, Farbkontrast von text-honey-dark/text-red-700 auf dem Hintergrund
(~4.9:1 bzw. ~6.1:1, beide AA-konform).

tsc/vitest/build bleiben gruen (142 Tests).
This commit is contained in:
Nick Szaunig committed 2026-09-06 02:46:06 +02:00
1 parent 0b2196460a
commit ac432e261b
6 files changed
+67 -16

No files matched your search

+7 -1
View File
@@ -33,9 +33,15 @@ export function ContentImage({
if (!src) {
return <PlaceholderImage tone={tone} className={className} label={label} />;
}
// "label" traegt bereits eine kurze inhaltliche Einordnung (Ernte-Jahr/Typ,
// Kategorie, Ort — siehe Aufrufer), die bisher nur auf der Platzhalterkachel
// sichtbar war und bei echten Fotos verworfen wurde. Fuer echte Bilder in
// den Alt-Text uebernommen: aussagekraeftiger fuer Screenreader UND
// Bildersuche als der Produkt-/Ernteame allein.
const descriptiveAlt = label ? `${alt} – ${label}` : alt;
return (
<div className={`relative overflow-hidden ${className}`}>
<Image src={src} alt={alt} fill sizes={sizes} className="object-cover" />
<Image src={src} alt={descriptiveAlt} fill sizes={sizes} className="object-cover" />
</div>
);
}
+7 -2
View File
@@ -27,13 +27,18 @@ const PLACEHOLDER_SLIDES: Slide[] = [
interface ProductGalleryProps {
images: string[];
productName: string;
/** Kurzbeschreibung (z.B. Geschmacksnoten) fuer aussagekraeftigeren Alt-Text
* auf dem Hauptbild — reiner Produktname allein ist fuer Screenreader UND
* Bildersuche weniger nuetzlich als Name + kurze inhaltliche Einordnung. */
description?: string;
}
export function ProductGallery({ images, productName }: ProductGalleryProps) {
export function ProductGallery({ images, productName, description }: ProductGalleryProps) {
const slides: Slide[] = images.length > 0 ? images.map((src) => ({ src })) : PLACEHOLDER_SLIDES;
const [activeIndex, setActiveIndex] = useState(0);
const active = slides[activeIndex] ?? slides[0];
const touchStartX = useRef<number | null>(null);
const mainImageAlt = description ? `${productName} – ${description}` : productName;
function handleTouchStart(event: TouchEvent) {
touchStartX.current = event.touches[0].clientX;
@@ -63,7 +68,7 @@ export function ProductGallery({ images, productName }: ProductGalleryProps) {
>
{isImageSlide(active) ? (
// eslint-disable-next-line @next/next/no-img-element -- externe Bildquelle noch unbekannt, next/image braucht dafuer eine feste remotePatterns-Konfiguration.
<img src={active.src} alt={productName} className="w-full h-full object-cover" draggable={false} />
<img src={active.src} alt={mainImageAlt} className="w-full h-full object-cover" draggable={false} />
) : (
<PlaceholderImage tone={active.tone} label={active.label} className="w-full h-full" />
)}
+13 -5
View File
@@ -470,11 +470,19 @@ export function CartDrawer() {
<div className="flex-1">
<p className="text-sm font-medium text-ink">{item.name}</p>
<p className="text-xs text-ink/65">{item.weightGrams} g</p>
{itemStock !== undefined && itemStock === 0 ? (
<p className="mt-1 text-xs text-red-700">{t.product.outOfStock}</p>
) : itemStock !== undefined && itemStock <= 5 ? (
<p className="mt-1 text-xs text-honey-dark">{t.product.onlyLeft(itemStock)}</p>
) : null}
{/* Live-Bestand trifft erst NACH dem ersten Rendern ein (siehe
use-cart-stock.ts) — der Wrapper bleibt beim Uebergang von
"kein Hinweis" zu "Ausverkauft"/"Nur noch N" bestehen, damit
Screenreader die neu erscheinende Warnung tatsaechlich ansagen
statt sie stumm zu uebergehen (ein erst mit dem Hinweis selbst
eingefuegtes aria-live-Element wird von mancher AT ignoriert). */}
<div aria-live="polite">
{itemStock !== undefined && itemStock === 0 ? (
<p className="mt-1 text-xs text-red-700">{t.product.outOfStock}</p>
) : itemStock !== undefined && itemStock <= 5 ? (
<p className="mt-1 text-xs text-honey-dark">{t.product.onlyLeft(itemStock)}</p>
) : null}
</div>
<div className="flex items-center gap-3 mt-2">
<QuantityStepper
quantity={item.quantity}