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]>
254 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|