Files
Wabenhain/src/components/product-card.tsx
T
Nick SzaunigandClaude Opus 5 cc267ad8d4 fix: ohne JavaScript blieb der gesamte Seiteninhalt unter dem Hero unsichtbar
Hero, ScrollReveal und ProductCard starten mit opacity 0 und werden per
motion/react eingeblendet. Diese Startwerte stehen als inline-style bereits im
server-gerenderten HTML — auf wabenhain.com an 38 Elementen nachgezaehlt. Laeuft
kein JavaScript, blendet sie niemand wieder ein: der Besucher sieht das
Hero-Bild und darunter eine leere Seite. Kein Teilausfall, sondern der
komplette Inhalt.

Fuer Suchmaschinen war das unkritisch, der Text steht im HTML. Fuer Menschen
mit deaktiviertem JavaScript war es ein Totalausfall.

Die drei Bausteine tragen jetzt data-reveal, und ein noscript-Block im
Root-Layout hebt darueber die Startwerte auf. Das Attribut statt eines
Treffers auf [style*="opacity:0"] ist der Kern der Sache: Warenkorb-Schublade,
Menue und Cookie-Banner blenden ueber AnimatePresence ebenfalls mit opacity 0
ein, sollen ohne JavaScript aber verborgen BLEIBEN. Sie tragen das Attribut
nicht.

!important ist hier notwendig und nicht nachlaessig — einen inline-style
ueberschreibt sonst nichts.

Grenze der Loesung: noscript deckt "JavaScript deaktiviert" ab. Laedt das
Bundle dagegen und schlaegt erst spaeter fehl, greift es nicht; dafuer waere
ein Zeitgeber noetig, der die Animation im Normalfall stoeren wuerde.

tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 03:51:51 +02:00

254 lines
8.2 KiB
TypeScript

"use client";
import Link from "next/link";
import { useState } from "react";
import { motion } from "motion/react";
import { ContentImage } from "@/components/content-image";
import { QuantityStepper } from "@/components/quantity-stepper";
import { StarRating } from "@/components/star-rating";
import { Button } from "@/components/ui";
import { formatPrice, formatPricePerKg } from "@/lib/format";
import { useCartStore } from "@/store/cart-store";
import { useI18n } from "@/i18n/i18n-provider";
import type { ProductWithRelations } from "@/lib/data";
import type { ProductCategory } from "@/generated/prisma/client";
// Unterscheidet die Platzhalterfarbe im Shop-Grid nach Kategorie, statt
// jedes Produkt im selben Honigton zu zeigen — verbessert die Scanbarkeit
// des Rasters, bis echte Produktfotografie vorliegt.
const categoryTone: Record<ProductCategory, "forest" | "cream" | "honey"> = {
HONIG: "honey",
KERZEN: "forest",
PROPOLIS: "cream",
};
/**
* Leichtgewichtiger Produkt-Verweis fuer Herkunfts-/Chargenseiten (Charge,
* Ernte) — nur Bild, Name und Link, ohne Preis/Bestand/Warenkorb-Funktion.
* Diese Seiten geht es um Nachweis/Herkunft, nicht ums Verkaufen.
*/
export function ProductTeaserLink({
slug,
name,
image,
}: {
slug: string;
name: string;
image?: string | null;
}) {
const { t } = useI18n();
return (
<Link
href={`/honig/${slug}`}
className="group flex items-center gap-4 border border-ink/10 p-4 transition-colors hover:border-forest"
>
<ContentImage src={image ?? null} alt={name} tone="honey" className="w-16 h-16 flex-shrink-0" sizes="64px" />
<span>
<span className="block text-forest">{name}</span>
<span className="block mt-1 text-sm text-ink/70 group-hover:text-forest transition-colors">
{t.product.viewProduct}
</span>
</span>
</Link>
);
}
export function TasteScale({
label,
value,
tone = "default",
}: {
label: string;
value: number;
/** "inverted" fuer Abschnitte auf dunklem Grund (z.B. bg-forest) — sonst waeren Label und leere Skalenpunkte auf dunklem Hintergrund kaum lesbar. */
tone?: "default" | "inverted";
}) {
return (
<div className="flex items-center gap-3 text-sm">
<span className={`w-16 ${tone === "inverted" ? "text-cream/70" : "text-ink/70"}`}>
{label}
</span>
<div className="flex gap-1">
{Array.from({ length: 5 }, (_, i) => (
<span
key={i}
className={`h-1.5 w-5 rounded-full ${
i < value ? "bg-honey" : tone === "inverted" ? "bg-cream/15" : "bg-ink/10"
}`}
/>
))}
</div>
</div>
);
}
function toCartItem(product: ProductWithRelations) {
return {
productId: product.id,
slug: product.slug,
name: product.name,
weightGrams: product.weightGrams,
priceCents: product.priceCents,
image: product.images[0] ?? null,
};
}
export function AddToCartButton({
product,
quantity = 1,
variant,
className = "",
}: {
product: ProductWithRelations;
quantity?: number;
variant?: "primary" | "secondary" | "ghost";
className?: string;
}) {
const { t } = useI18n();
const addItem = useCartStore((s) => s.addItem);
const [justAdded, setJustAdded] = useState(false);
return (
<motion.div whileTap={{ scale: 0.97 }} className={className}>
<Button
variant={variant}
className="w-full"
onClick={() => {
addItem(toCartItem(product), quantity);
setJustAdded(true);
window.setTimeout(() => setJustAdded(false), 1500);
}}
>
{justAdded ? t.product.added : t.product.addToCart}
</Button>
</motion.div>
);
}
/**
* Wie AddToCartButton, aber mit einem Mengen-Steller davor — fuer die
* Produktdetailseite, wo Kund:innen die Menge vor dem Hinzufuegen waehlen
* koennen sollen, statt sie erst im Warenkorb nachtraeglich anzupassen.
*/
export function AddToCartWithQuantity({
product,
maxQuantity,
className = "",
}: {
product: ProductWithRelations;
maxQuantity: number;
className?: string;
}) {
const { t } = useI18n();
const addItem = useCartStore((s) => s.addItem);
const [quantity, setQuantity] = useState(1);
const [justAdded, setJustAdded] = useState(false);
return (
<div className={className}>
<div className="flex items-stretch gap-3">
<QuantityStepper quantity={quantity} onChange={setQuantity} max={maxQuantity} />
<motion.div whileTap={{ scale: 0.97 }} className="flex-1">
<Button
className="w-full h-full"
onClick={() => {
addItem(toCartItem(product), quantity);
setJustAdded(true);
window.setTimeout(() => setJustAdded(false), 1500);
}}
>
{justAdded ? t.product.added : t.product.addToCart}
</Button>
</motion.div>
</div>
</div>
);
}
export function ProductCard({
product,
reviewSummary,
}: {
product: ProductWithRelations;
/** Optional — nur die Shop-Uebersicht laedt Bewertungen gebuendelt fuer das ganze Raster. */
reviewSummary?: { average: number | null; count: number };
}) {
const { t, locale } = useI18n();
const stock = product.inventory?.quantity ?? 0;
return (
<motion.article
// Siehe scroll-reveal.tsx: Gegenstueck zum noscript-Block im Root-Layout.
data-reveal=""
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.5 }}
className="group"
>
<Link
href={`/honig/${product.slug}`}
className="relative block overflow-hidden transition-shadow duration-500 group-hover:shadow-[0_24px_48px_-20px_rgba(36,53,43,0.4)]"
>
<ContentImage
src={product.images[0] ?? null}
alt={product.name}
tone={categoryTone[product.category]}
className="aspect-[4/5] w-full transition-transform duration-500 group-hover:scale-[1.03]"
label={
product.harvest
? `${product.harvest.year} · ${product.harvest.type}`
: t.common.categories[product.category]
}
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
/>
{stock === 0 ? (
<span className="absolute top-3 left-3 bg-ink/80 text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
{t.product.outOfStock}
</span>
) : stock <= 5 ? (
<span className="absolute top-3 left-3 bg-honey text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
{t.product.onlyLeft(stock)}
</span>
) : null}
</Link>
<div className="mt-4">
<Link href={`/honig/${product.slug}`}>
<h3 className="text-xl text-forest">{product.name}</h3>
</Link>
<p className="text-sm text-ink/70 mt-1">
{product.tasteTags.join(" · ")}
</p>
<p
className={`mt-1.5 flex items-center gap-1.5 ${
reviewSummary && reviewSummary.count > 0 ? "" : "invisible"
}`}
>
<StarRating value={reviewSummary?.average ?? 0} size={12} />
<span className="text-xs text-ink/65">
{(reviewSummary?.average ?? 0).toFixed(1)} ({reviewSummary?.count ?? 0})
</span>
</p>
<div className="mt-2 flex items-center justify-between">
<span className="text-sm text-ink/65">{product.weightGrams} g</span>
<span className="text-base text-forest">
{formatPrice(product.priceCents, product.currency, locale)}
</span>
</div>
<p className="text-xs text-ink/65">
{formatPricePerKg(product.pricePerKgCents, product.currency, locale)}
</p>
{stock > 0 ? (
<AddToCartButton product={product} variant="secondary" className="mt-4" />
) : null}
<Link
href={`/honig/${product.slug}`}
className="mt-4 inline-block text-sm tracking-wide-caps text-forest border-b border-forest/40 pb-0.5 hover:border-forest"
>
{product.category === "HONIG" ? t.product.toHoney : t.product.toProduct}
</Link>
</div>
</motion.article>
);
}