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:
1 parent
0b2196460a
commit
ac432e261b
6 files changed
+57
-6
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, type FormEvent, type HTMLAttributes } from "react";
|
import { useEffect, useRef, useState, type FormEvent, type HTMLAttributes } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||||
import { TrustBadges } from "@/components/trust-badges";
|
import { TrustBadges } from "@/components/trust-badges";
|
||||||
@@ -41,6 +41,17 @@ export function CheckoutForm({
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isGift, setIsGift] = useState(false);
|
const [isGift, setIsGift] = useState(false);
|
||||||
|
const errorRef = useRef<HTMLParagraphElement>(null);
|
||||||
|
|
||||||
|
// Fokus aktiv auf die Fehlermeldung verschieben statt sie nur farblich
|
||||||
|
// einzublenden — sonst bleibt der Fokus auf dem "Bestellen"-Button stehen
|
||||||
|
// und Screenreader-Nutzer:innen erfahren beim fehlgeschlagenen, geldwerten
|
||||||
|
// Checkout-Absenden nichts vom Fehler (role="alert" allein wird von
|
||||||
|
// manchen Screenreadern bei neu eingefuegten Elementen unzuverlaessig
|
||||||
|
// vorgelesen, Fokusverschiebung ist der robustere Mechanismus).
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) errorRef.current?.focus();
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
// Live sichtbar statt erst nach der Weiterleitung zu Stripe — reagiert auf
|
// Live sichtbar statt erst nach der Weiterleitung zu Stripe — reagiert auf
|
||||||
// die Versand/Abholung-Auswahl, damit die Gesamtsumme immer stimmt.
|
// die Versand/Abholung-Auswahl, damit die Gesamtsumme immer stimmt.
|
||||||
@@ -234,7 +245,11 @@ export function CheckoutForm({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? <p className="text-sm text-red-700">{error}</p> : null}
|
{error ? (
|
||||||
|
<p ref={errorRef} role="alert" tabIndex={-1} className="text-sm text-red-700 outline-none">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Button type="submit" disabled={submitting} className="w-full sm:w-auto">
|
<Button type="submit" disabled={submitting} className="w-full sm:w-auto">
|
||||||
{submitting ? t.checkoutPage.submitting : t.checkoutPage.placeOrder}
|
{submitting ? t.checkoutPage.submitting : t.checkoutPage.placeOrder}
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ export default async function ProductDetailPage(
|
|||||||
sku: product.batch?.code ?? product.slug,
|
sku: product.batch?.code ?? product.slug,
|
||||||
brand: { "@type": "Brand", name: company.name },
|
brand: { "@type": "Brand", name: company.name },
|
||||||
category: t.common.categories[product.category],
|
category: t.common.categories[product.category],
|
||||||
|
// Nur bei echter Produktfotografie gesetzt (siehe ProductGallery) — ohne
|
||||||
|
// hochgeladene Bilder faellt die Galerie auf reine CSS-Platzhalterkacheln
|
||||||
|
// zurueck, die als "image" im strukturierten Datensatz falsche Tatsachen
|
||||||
|
// vortaeuschen wuerden (Google Rich-Results/Merchant-Anforderung: absolute
|
||||||
|
// URL zu einem echten Produktfoto, keine Platzhaltergrafik).
|
||||||
|
...(product.images.length > 0
|
||||||
|
? { image: product.images.map((src) => `https://${siteConfig.domain}${src}`) }
|
||||||
|
: {}),
|
||||||
offers: {
|
offers: {
|
||||||
"@type": "Offer",
|
"@type": "Offer",
|
||||||
priceCurrency: product.currency,
|
priceCurrency: product.currency,
|
||||||
@@ -134,7 +142,11 @@ export default async function ProductDetailPage(
|
|||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
|
||||||
/>
|
/>
|
||||||
<Container className="grid md:grid-cols-2 gap-14">
|
<Container className="grid md:grid-cols-2 gap-14">
|
||||||
<ProductGallery images={product.images} productName={product.name} />
|
<ProductGallery
|
||||||
|
images={product.images}
|
||||||
|
productName={product.name}
|
||||||
|
description={product.tasteTags.join(", ")}
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
|
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
|
||||||
|
|||||||
@@ -78,12 +78,17 @@ export function WarenkorbContent({
|
|||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-sm text-ink/65">{item.weightGrams} g</p>
|
<p className="text-sm text-ink/65">{item.weightGrams} g</p>
|
||||||
|
{/* Siehe Kommentar im Cart-Drawer (site-chrome.tsx): persistenter
|
||||||
|
aria-live-Wrapper, damit der erst nach dem ersten Rendern
|
||||||
|
eintreffende Live-Bestand als Ansage bei Screenreadern ankommt. */}
|
||||||
|
<div aria-live="polite">
|
||||||
{itemStock !== undefined && itemStock === 0 ? (
|
{itemStock !== undefined && itemStock === 0 ? (
|
||||||
<p className="mt-1 text-xs text-red-700">{t.product.outOfStock}</p>
|
<p className="mt-1 text-xs text-red-700">{t.product.outOfStock}</p>
|
||||||
) : itemStock !== undefined && itemStock <= 5 ? (
|
) : itemStock !== undefined && itemStock <= 5 ? (
|
||||||
<p className="mt-1 text-xs text-honey-dark">{t.product.onlyLeft(itemStock)}</p>
|
<p className="mt-1 text-xs text-honey-dark">{t.product.onlyLeft(itemStock)}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-4 ml-auto">
|
<div className="flex items-center gap-4 ml-auto">
|
||||||
<QuantityStepper
|
<QuantityStepper
|
||||||
quantity={item.quantity}
|
quantity={item.quantity}
|
||||||
|
|||||||
@@ -33,9 +33,15 @@ export function ContentImage({
|
|||||||
if (!src) {
|
if (!src) {
|
||||||
return <PlaceholderImage tone={tone} className={className} label={label} />;
|
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 (
|
return (
|
||||||
<div className={`relative overflow-hidden ${className}`}>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -27,13 +27,18 @@ const PLACEHOLDER_SLIDES: Slide[] = [
|
|||||||
interface ProductGalleryProps {
|
interface ProductGalleryProps {
|
||||||
images: string[];
|
images: string[];
|
||||||
productName: 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 slides: Slide[] = images.length > 0 ? images.map((src) => ({ src })) : PLACEHOLDER_SLIDES;
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const active = slides[activeIndex] ?? slides[0];
|
const active = slides[activeIndex] ?? slides[0];
|
||||||
const touchStartX = useRef<number | null>(null);
|
const touchStartX = useRef<number | null>(null);
|
||||||
|
const mainImageAlt = description ? `${productName} – ${description}` : productName;
|
||||||
|
|
||||||
function handleTouchStart(event: TouchEvent) {
|
function handleTouchStart(event: TouchEvent) {
|
||||||
touchStartX.current = event.touches[0].clientX;
|
touchStartX.current = event.touches[0].clientX;
|
||||||
@@ -63,7 +68,7 @@ export function ProductGallery({ images, productName }: ProductGalleryProps) {
|
|||||||
>
|
>
|
||||||
{isImageSlide(active) ? (
|
{isImageSlide(active) ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element -- externe Bildquelle noch unbekannt, next/image braucht dafuer eine feste remotePatterns-Konfiguration.
|
// 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" />
|
<PlaceholderImage tone={active.tone} label={active.label} className="w-full h-full" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -470,11 +470,19 @@ export function CartDrawer() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-ink">{item.name}</p>
|
<p className="text-sm font-medium text-ink">{item.name}</p>
|
||||||
<p className="text-xs text-ink/65">{item.weightGrams} g</p>
|
<p className="text-xs text-ink/65">{item.weightGrams} g</p>
|
||||||
|
{/* 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 ? (
|
{itemStock !== undefined && itemStock === 0 ? (
|
||||||
<p className="mt-1 text-xs text-red-700">{t.product.outOfStock}</p>
|
<p className="mt-1 text-xs text-red-700">{t.product.outOfStock}</p>
|
||||||
) : itemStock !== undefined && itemStock <= 5 ? (
|
) : itemStock !== undefined && itemStock <= 5 ? (
|
||||||
<p className="mt-1 text-xs text-honey-dark">{t.product.onlyLeft(itemStock)}</p>
|
<p className="mt-1 text-xs text-honey-dark">{t.product.onlyLeft(itemStock)}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-2">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
<QuantityStepper
|
<QuantityStepper
|
||||||
quantity={item.quantity}
|
quantity={item.quantity}
|
||||||
|
|||||||
Reference in new issue
Block a user