fix: Preis-/Datumsformat an Kunden-Locale anpassen, Produktgalerie-LCP verbessern

- format.ts: formatPrice/formatDate/formatDateTime/formatMonthYear nehmen
  jetzt ein optionales Locale-Argument (Default "de" fuer unveraenderte
  Aufrufer wie Rechnungen/Admin) und formatieren fuer "en" als en-GB statt
  hart codiert als de-DE -- englischsprachige Kundschaft sah bisher trotz
  gewaehlter Sprache "9,90 €" und "10. Mai 2026" statt "€9.90"/"10 May 2026".
  Alle kundenseitigen Aufrufer (Produktseite, Warenkorb, Checkout, Bestell-
  verfolgung, Konto-Bestellungen, Ernten-/Chargenseiten, Live-Suchvorschlaege,
  Bestell-/Versand-E-Mails) reichen jetzt die aktive Locale durch.
- en.ts: openCart/shippingWithCount/shippingLabel pluralisieren "item(s)"
  jetzt korrekt (vorher z.B. "Open cart, 1 items").
- product-gallery.tsx: Haupt- und Vorschaubilder nutzen jetzt next/image
  statt <img> (hochgeladene Produktfotos sind immer same-origin unter
  /uploads/..., brauchen also keine remotePatterns-Konfiguration wie der
  entfernte Kommentar behauptete) -- Hauptbild mit priority/responsivem
  sizes, da es auf der Produktseite das wahrscheinlichste LCP-Element ist.
- content-image.tsx: neue optionale priority-Prop; hero.tsx setzt sie fuer
  das Startseiten-Hero-Bild (ebenfalls LCP-Kandidat).
- Sticky-Mobile-CTA-Leiste und useCartStock-Polling gegengeprueft: bereits
  korrekt (pb-28-Platzreservierung bzw. Fetch nur bei Warenkorb-Aenderung
  ohne Intervall) -- keine Aenderung noetig.

Verifiziert: tsc --noEmit clean, pnpm test (142/142 gruen), pnpm build erfolgreich.
This commit is contained in:
Nick Szaunig committed 2026-09-06 02:57:57 +02:00
1 parent ac432e261b
commit de0f6ee249
20 files changed
+137 -84

No files matched your search

+3 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getActiveProducts } from "@/lib/data"; import { getActiveProducts } from "@/lib/data";
import { formatPrice } from "@/lib/format"; import { formatPrice } from "@/lib/format";
import { rateLimit, getClientIp } from "@/lib/rate-limit"; import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { getLocale } from "@/i18n/locale";
const SUGGESTION_LIMIT = 5; const SUGGESTION_LIMIT = 5;
const SUGGEST_ATTEMPTS_PER_WINDOW = 60; const SUGGEST_ATTEMPTS_PER_WINDOW = 60;
@@ -26,11 +27,12 @@ export async function GET(request: Request) {
return NextResponse.json({ suggestions: [] }); return NextResponse.json({ suggestions: [] });
} }
const locale = await getLocale();
const products = await getActiveProducts({ query, onlyAvailable: true }); const products = await getActiveProducts({ query, onlyAvailable: true });
const suggestions = products.slice(0, SUGGESTION_LIMIT).map((product) => ({ const suggestions = products.slice(0, SUGGESTION_LIMIT).map((product) => ({
slug: product.slug, slug: product.slug,
name: product.name, name: product.name,
price: formatPrice(product.priceCents, product.currency), price: formatPrice(product.priceCents, product.currency, locale),
})); }));
return NextResponse.json({ suggestions }); return NextResponse.json({ suggestions });
+7 -6
View File
@@ -16,7 +16,8 @@ const TRACK_ATTEMPTS_PER_WINDOW = 10;
const TRACK_WINDOW_MS = 15 * 60 * 1000; const TRACK_WINDOW_MS = 15 * 60 * 1000;
export default async function TrackOrderPage(props: PageProps<"/bestellung-verfolgen">) { export default async function TrackOrderPage(props: PageProps<"/bestellung-verfolgen">) {
const t = getDictionary(await getLocale()); const locale = await getLocale();
const t = getDictionary(locale);
const { order: orderNumber, email } = await props.searchParams; const { order: orderNumber, email } = await props.searchParams;
const hasQuery = typeof orderNumber === "string" && typeof email === "string" && orderNumber && email; const hasQuery = typeof orderNumber === "string" && typeof email === "string" && orderNumber && email;
@@ -80,7 +81,7 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div> <div>
<p className="text-forest">{order.orderNumber}</p> <p className="text-forest">{order.orderNumber}</p>
<p className="text-xs text-ink/65">{formatDate(order.createdAt)}</p> <p className="text-xs text-ink/65">{formatDate(order.createdAt, locale)}</p>
</div> </div>
<div className="text-right"> <div className="text-right">
<OrderStatusBadge <OrderStatusBadge
@@ -90,12 +91,12 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
<p className="mt-1.5 text-xs text-ink/65"> <p className="mt-1.5 text-xs text-ink/65">
{order.fulfillmentMethod === "VERSAND" {order.fulfillmentMethod === "VERSAND"
? order.shipment?.shippedAt ? order.shipment?.shippedAt
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt)) ? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt, locale))
: t.ordersPage.shippingPending : t.ordersPage.shippingPending
: order.pickup?.pickedUpAt : order.pickup?.pickedUpAt
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt)) ? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt, locale))
: order.pickup?.readyAt : order.pickup?.readyAt
? t.ordersPage.readySince(formatDate(order.pickup.readyAt)) ? t.ordersPage.readySince(formatDate(order.pickup.readyAt, locale))
: t.ordersPage.pickupPending} : t.ordersPage.pickupPending}
</p> </p>
</div> </div>
@@ -150,7 +151,7 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
) : null} ) : null}
<div className="mt-5 flex items-center justify-between gap-4"> <div className="mt-5 flex items-center justify-between gap-4">
<p className="text-forest">{formatPrice(order.totalCents, order.currency)}</p> <p className="text-forest">{formatPrice(order.totalCents, order.currency, locale)}</p>
{order.invoice ? ( {order.invoice ? (
<Link href={`/rechnung/${order.invoice.invoiceNumber}`} className="text-sm text-forest underline"> <Link href={`/rechnung/${order.invoice.invoiceNumber}`} className="text-sm text-forest underline">
{t.trackOrderPage.viewInvoice} {t.trackOrderPage.viewInvoice}
+5 -4
View File
@@ -27,15 +27,16 @@ export async function generateMetadata(
* in dem jemand mit dem Glas in der Hand die Herkunftsangaben nachprueft. * in dem jemand mit dem Glas in der Hand die Herkunftsangaben nachprueft.
*/ */
export default async function BatchPage(props: PageProps<"/charge/[code]">) { export default async function BatchPage(props: PageProps<"/charge/[code]">) {
const t = getDictionary(await getLocale()); const locale = await getLocale();
const t = getDictionary(locale);
const { code } = await props.params; const { code } = await props.params;
const batch = await getBatchByCode(code); const batch = await getBatchByCode(code);
if (!batch) notFound(); if (!batch) notFound();
const steps = [ const steps = [
{ label: t.chargePage.stepHarvest, value: formatDate(batch.harvest.harvestDate) }, { label: t.chargePage.stepHarvest, value: formatDate(batch.harvest.harvestDate, locale) },
{ label: t.chargePage.stepFilling, value: formatDate(batch.fillDate) }, { label: t.chargePage.stepFilling, value: formatDate(batch.fillDate, locale) },
{ label: t.chargePage.stepBestBefore, value: formatDate(batch.bestBeforeDate) }, { label: t.chargePage.stepBestBefore, value: formatDate(batch.bestBeforeDate, locale) },
]; ];
return ( return (
+6 -6
View File
@@ -31,7 +31,7 @@ export function CheckoutForm({
tiers: ShippingTierLike[]; tiers: ShippingTierLike[];
bulkDiscountTiers: BulkDiscountTierLike[]; bulkDiscountTiers: BulkDiscountTierLike[];
}) { }) {
const { t } = useI18n(); const { t, locale } = useI18n();
const items = useCartStore((s) => s.items); const items = useCartStore((s) => s.items);
const subtotal = cartTotalCents(items); const subtotal = cartTotalCents(items);
const totalQuantity = cartItemCount(items); const totalQuantity = cartItemCount(items);
@@ -267,14 +267,14 @@ export function CheckoutForm({
<span> <span>
{item.quantity} × {item.name} {item.quantity} × {item.name}
</span> </span>
<span>{formatPrice(item.priceCents * item.quantity)}</span> <span>{formatPrice(item.priceCents * item.quantity, "EUR", locale)}</span>
</li> </li>
))} ))}
</ul> </ul>
<div className="mt-6 pt-4 border-t border-ink/10 space-y-2 text-sm"> <div className="mt-6 pt-4 border-t border-ink/10 space-y-2 text-sm">
<div className="flex justify-between text-ink/70"> <div className="flex justify-between text-ink/70">
<span>{t.cart.subtotal}</span> <span>{t.cart.subtotal}</span>
<span>{formatPrice(subtotal)}</span> <span>{formatPrice(subtotal, "EUR", locale)}</span>
</div> </div>
<div className="flex justify-between text-ink/70"> <div className="flex justify-between text-ink/70">
<span> <span>
@@ -282,20 +282,20 @@ export function CheckoutForm({
? t.checkoutPage.shippingWithCount(totalQuantity) ? t.checkoutPage.shippingWithCount(totalQuantity)
: t.checkoutPage.shippingPickup} : t.checkoutPage.shippingPickup}
</span> </span>
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping)}</span> <span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping, "EUR", locale)}</span>
</div> </div>
{activeBulkDiscount ? ( {activeBulkDiscount ? (
<div className="flex justify-between text-forest"> <div className="flex justify-between text-forest">
<span> <span>
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%) {t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
</span> </span>
<span>-{formatPrice(bulkDiscountCents)}</span> <span>-{formatPrice(bulkDiscountCents, "EUR", locale)}</span>
</div> </div>
) : null} ) : null}
</div> </div>
<div className="mt-3 pt-3 border-t border-ink/10 flex justify-between text-base text-forest"> <div className="mt-3 pt-3 border-t border-ink/10 flex justify-between text-base text-forest">
<span>{t.cartPage.total}</span> <span>{t.cartPage.total}</span>
<span>{formatPrice(total)}</span> <span>{formatPrice(total, "EUR", locale)}</span>
</div> </div>
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" /> <TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
</aside> </aside>
+3 -2
View File
@@ -30,7 +30,8 @@ export async function generateMetadata(
export default async function HarvestDetailPage( export default async function HarvestDetailPage(
props: PageProps<"/ernten/[year]/[type]"> props: PageProps<"/ernten/[year]/[type]">
) { ) {
const t = getDictionary(await getLocale()); const locale = await getLocale();
const t = getDictionary(locale);
const { year, type } = await props.params; const { year, type } = await props.params;
// Next.js liefert dynamische Segmente hier unkodiert (z.B. "Fr%C3%BChtracht" // Next.js liefert dynamische Segmente hier unkodiert (z.B. "Fr%C3%BChtracht"
// statt "Frühtracht") — deshalb wird explizit dekodiert, statt sich auf // statt "Frühtracht") — deshalb wird explizit dekodiert, statt sich auf
@@ -53,7 +54,7 @@ export default async function HarvestDetailPage(
/> />
<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">
{harvest.year} · {formatDate(harvest.harvestDate)} {harvest.year} · {formatDate(harvest.harvestDate, locale)}
</p> </p>
<h1 className="text-4xl text-forest">{harvest.type}</h1> <h1 className="text-4xl text-forest">{harvest.type}</h1>
<p className="mt-6 text-ink/75 leading-relaxed">{harvest.description}</p> <p className="mt-6 text-ink/75 leading-relaxed">{harvest.description}</p>
+3 -2
View File
@@ -17,7 +17,8 @@ export const metadata: Metadata = {
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function ErntenPage() { export default async function ErntenPage() {
const t = getDictionary(await getLocale()); const locale = await getLocale();
const t = getDictionary(locale);
const harvests = await getHarvests(); const harvests = await getHarvests();
const byYear = harvests.reduce<Record<number, typeof harvests>>((acc, h) => { const byYear = harvests.reduce<Record<number, typeof harvests>>((acc, h) => {
acc[h.year] = [...(acc[h.year] ?? []), h]; acc[h.year] = [...(acc[h.year] ?? []), h];
@@ -46,7 +47,7 @@ export default async function ErntenPage() {
alt={harvest.type} alt={harvest.type}
tone="forest" tone="forest"
className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]" className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]"
label={formatMonthYear(harvest.harvestDate)} label={formatMonthYear(harvest.harvestDate, locale)}
/> />
<h3 className="mt-4 text-lg text-forest">{harvest.type}</h3> <h3 className="mt-4 text-lg text-forest">{harvest.type}</h3>
<p className="text-sm text-ink/65">{t.erntenPage.batchCount(harvest.batches.length)}</p> <p className="text-sm text-ink/65">{t.erntenPage.batchCount(harvest.batches.length)}</p>
+12 -6
View File
@@ -45,7 +45,8 @@ export async function generateMetadata(
export default async function ProductDetailPage( export default async function ProductDetailPage(
props: PageProps<"/honig/[slug]"> props: PageProps<"/honig/[slug]">
) { ) {
const t = getDictionary(await getLocale()); const locale = await getLocale();
const t = getDictionary(locale);
const { slug } = await props.params; const { slug } = await props.params;
const { error, message, notifyError, notifyMessage } = await props.searchParams; const { error, message, notifyError, notifyMessage } = await props.searchParams;
const product = await getProductBySlug(slug); const product = await getProductBySlug(slug);
@@ -167,18 +168,20 @@ export default async function ProductDetailPage(
<div className="mt-6 flex items-baseline gap-3"> <div className="mt-6 flex items-baseline gap-3">
<span className="text-2xl text-forest"> <span className="text-2xl text-forest">
{formatPrice(product.priceCents, product.currency)} {formatPrice(product.priceCents, product.currency, locale)}
</span> </span>
<span className="text-sm text-ink/65">{product.weightGrams} g</span> <span className="text-sm text-ink/65">{product.weightGrams} g</span>
</div> </div>
<p className="text-xs text-ink/65"> <p className="text-xs text-ink/65">
{formatPricePerKg(product.pricePerKgCents, product.currency)} {formatPricePerKg(product.pricePerKgCents, product.currency, locale)}
</p> </p>
<Link <Link
href="/versand-zahlung" href="/versand-zahlung"
className="mt-2 inline-block text-xs text-ink/65 underline decoration-ink/30 hover:text-forest hover:decoration-forest" className="mt-2 inline-block text-xs text-ink/65 underline decoration-ink/30 hover:text-forest hover:decoration-forest"
> >
{t.productPage.shippingHint(baseShippingCents === 0 ? t.cartPage.free : formatPrice(baseShippingCents))} {t.productPage.shippingHint(
baseShippingCents === 0 ? t.cartPage.free : formatPrice(baseShippingCents, "EUR", locale)
)}
</Link> </Link>
{isHoney ? ( {isHoney ? (
@@ -270,7 +273,10 @@ export default async function ProductDetailPage(
label={t.productPage.detailLabels.origin} label={t.productPage.detailLabels.origin}
value={`${product.harvest.location} — ${product.harvest.type} ${product.harvest.year}`} value={`${product.harvest.location} — ${product.harvest.type} ${product.harvest.year}`}
/> />
<DetailRow label={t.productPage.detailLabels.harvest} value={formatDate(product.harvest.harvestDate)} /> <DetailRow
label={t.productPage.detailLabels.harvest}
value={formatDate(product.harvest.harvestDate, locale)}
/>
<DetailRow label={t.productPage.detailLabels.batch} value={product.batch.code} /> <DetailRow label={t.productPage.detailLabels.batch} value={product.batch.code} />
<DetailRow label={t.productPage.detailLabels.storage} value={t.productPage.storageText} /> <DetailRow label={t.productPage.detailLabels.storage} value={t.productPage.storageText} />
<DetailRow label={t.productPage.detailLabels.note} value={t.productPage.infantWarningText} /> <DetailRow label={t.productPage.detailLabels.note} value={t.productPage.infantWarningText} />
@@ -311,7 +317,7 @@ export default async function ProductDetailPage(
<span className="sr-only">{t.productPage.starsOutOf5(review.rating)}</span> <span className="sr-only">{t.productPage.starsOutOf5(review.rating)}</span>
{review.comment ? <p className="mt-2 text-sm text-ink/80">{review.comment}</p> : null} {review.comment ? <p className="mt-2 text-sm text-ink/80">{review.comment}</p> : null}
<p className="mt-2 text-xs text-ink/65"> <p className="mt-2 text-xs text-ink/65">
{review.customer.firstName} · {formatDate(review.createdAt)} {review.customer.firstName} · {formatDate(review.createdAt, locale)}
</p> </p>
</li> </li>
))} ))}
+7 -6
View File
@@ -26,7 +26,8 @@ const RETURN_ELIGIBLE_STATUSES = [
]; ];
export default async function KontoBestellungenPage(props: PageProps<"/konto/bestellungen">) { export default async function KontoBestellungenPage(props: PageProps<"/konto/bestellungen">) {
const t = getDictionary(await getLocale()); const locale = await getLocale();
const t = getDictionary(locale);
const statusLabels = t.orderStatusBadge; const statusLabels = t.orderStatusBadge;
const returnStatusLabels = t.ordersPage.returnStatusLabels; const returnStatusLabels = t.ordersPage.returnStatusLabels;
const { error, message } = await props.searchParams; const { error, message } = await props.searchParams;
@@ -65,19 +66,19 @@ export default async function KontoBestellungenPage(props: PageProps<"/konto/bes
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div> <div>
<p className="text-forest">{order.orderNumber}</p> <p className="text-forest">{order.orderNumber}</p>
<p className="text-xs text-ink/65">{formatDate(order.createdAt)}</p> <p className="text-xs text-ink/65">{formatDate(order.createdAt, locale)}</p>
</div> </div>
<div className="text-right"> <div className="text-right">
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} /> <OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
<p className="mt-1.5 text-xs text-ink/65"> <p className="mt-1.5 text-xs text-ink/65">
{order.fulfillmentMethod === "VERSAND" {order.fulfillmentMethod === "VERSAND"
? order.shipment?.shippedAt ? order.shipment?.shippedAt
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt)) ? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt, locale))
: t.ordersPage.shippingPending : t.ordersPage.shippingPending
: order.pickup?.pickedUpAt : order.pickup?.pickedUpAt
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt)) ? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt, locale))
: order.pickup?.readyAt : order.pickup?.readyAt
? t.ordersPage.readySince(formatDate(order.pickup.readyAt)) ? t.ordersPage.readySince(formatDate(order.pickup.readyAt, locale))
: t.ordersPage.pickupPending} : t.ordersPage.pickupPending}
</p> </p>
</div> </div>
@@ -138,7 +139,7 @@ export default async function KontoBestellungenPage(props: PageProps<"/konto/bes
) : null} ) : null}
<div className="mt-4 flex flex-wrap items-center justify-between gap-4"> <div className="mt-4 flex flex-wrap items-center justify-between gap-4">
<p className="text-forest">{formatPrice(order.totalCents, order.currency)}</p> <p className="text-forest">{formatPrice(order.totalCents, order.currency, locale)}</p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{order.invoice ? ( {order.invoice ? (
<Link <Link
+8 -3
View File
@@ -12,15 +12,20 @@ export const metadata: Metadata = { title: "Versand und Zahlung", alternates: {
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function VersandZahlungPage() { export default async function VersandZahlungPage() {
const t = getDictionary(await getLocale()); const locale = await getLocale();
const t = getDictionary(locale);
const tiers = await getShippingTiers(); const tiers = await getShippingTiers();
const tierParagraphs = const tierParagraphs =
tiers.length > 0 tiers.length > 0
? tiers.map((tier) => ? tiers.map((tier) =>
tier.maxQuantity === null tier.maxQuantity === null
? t.versandZahlungPage.tierMinimum(tier.minQuantity, formatPrice(tier.priceCents)) ? t.versandZahlungPage.tierMinimum(tier.minQuantity, formatPrice(tier.priceCents, "EUR", locale))
: t.versandZahlungPage.tierRange(tier.minQuantity, tier.maxQuantity, formatPrice(tier.priceCents)) : t.versandZahlungPage.tierRange(
tier.minQuantity,
tier.maxQuantity,
formatPrice(tier.priceCents, "EUR", locale)
)
) )
: [t.versandZahlungPage.tierFallback]; : [t.versandZahlungPage.tierFallback];
+7 -7
View File
@@ -25,7 +25,7 @@ export function WarenkorbContent({
availableProducts: ProductWithRelations[]; availableProducts: ProductWithRelations[];
bulkDiscountTiers: BulkDiscountTierLike[]; bulkDiscountTiers: BulkDiscountTierLike[];
}) { }) {
const { t } = useI18n(); const { t, locale } = useI18n();
const items = useCartStore((s) => s.items); const items = useCartStore((s) => s.items);
const setQuantity = useCartStore((s) => s.setQuantity); const setQuantity = useCartStore((s) => s.setQuantity);
const removeItem = useCartStore((s) => s.removeItem); const removeItem = useCartStore((s) => s.removeItem);
@@ -96,7 +96,7 @@ export function WarenkorbContent({
max={itemStock ?? 10} max={itemStock ?? 10}
/> />
<span className="w-20 text-right text-ink"> <span className="w-20 text-right text-ink">
{formatPrice(item.priceCents * item.quantity)} {formatPrice(item.priceCents * item.quantity, "EUR", locale)}
</span> </span>
<button <button
onClick={() => removeItem(item.productId)} onClick={() => removeItem(item.productId)}
@@ -115,15 +115,15 @@ export function WarenkorbContent({
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
<span>{t.cart.subtotal}</span> <span>{t.cart.subtotal}</span>
<span>{formatPrice(subtotal)}</span> <span>{formatPrice(subtotal, "EUR", locale)}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span>{t.cartPage.shippingLabel(totalQuantity)}</span> <span>{t.cartPage.shippingLabel(totalQuantity)}</span>
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping)}</span> <span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping, "EUR", locale)}</span>
</div> </div>
{nextTier && nextTier.unitsUntilNext <= 2 ? ( {nextTier && nextTier.unitsUntilNext <= 2 ? (
<p className="text-xs text-ink/60"> <p className="text-xs text-ink/60">
{t.cartPage.nextTierHint(nextTier.unitsUntilNext, formatPrice(nextTier.priceCents))} {t.cartPage.nextTierHint(nextTier.unitsUntilNext, formatPrice(nextTier.priceCents, "EUR", locale))}
</p> </p>
) : null} ) : null}
{activeBulkDiscount ? ( {activeBulkDiscount ? (
@@ -131,7 +131,7 @@ export function WarenkorbContent({
<span> <span>
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%) {t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
</span> </span>
<span>-{formatPrice(bulkDiscountCents)}</span> <span>-{formatPrice(bulkDiscountCents, "EUR", locale)}</span>
</div> </div>
) : nextBulkDiscount && nextBulkDiscount.unitsUntilNext <= 2 ? ( ) : nextBulkDiscount && nextBulkDiscount.unitsUntilNext <= 2 ? (
<p className="text-xs text-ink/60"> <p className="text-xs text-ink/60">
@@ -140,7 +140,7 @@ export function WarenkorbContent({
) : null} ) : null}
<div className="flex justify-between text-base text-forest pt-3 border-t border-ink/10"> <div className="flex justify-between text-base text-forest pt-3 border-t border-ink/10">
<span>{t.cartPage.total}</span> <span>{t.cartPage.total}</span>
<span>{formatPrice(subtotal - bulkDiscountCents + shipping)}</span> <span>{formatPrice(subtotal - bulkDiscountCents + shipping, "EUR", locale)}</span>
</div> </div>
</div> </div>
<LinkButton href="/checkout" className="w-full mt-6"> <LinkButton href="/checkout" className="w-full mt-6">
+6 -1
View File
@@ -10,6 +10,10 @@ interface ContentImageProps {
tone?: "forest" | "cream" | "honey"; tone?: "forest" | "cream" | "honey";
label?: string; label?: string;
sizes?: string; sizes?: string;
/** Fuer das wahrscheinlichste LCP-Bild einer Seite (z.B. Startseiten-Hero)
* true setzen — laedt das Bild mit hoher Prioritaet ohne Lazy-Loading,
* statt es erst beim Scannen weiter unten im Dokument zu entdecken. */
priority?: boolean;
} }
/** /**
@@ -29,6 +33,7 @@ export function ContentImage({
tone = "forest", tone = "forest",
label, label,
sizes = "100vw", sizes = "100vw",
priority = false,
}: ContentImageProps) { }: ContentImageProps) {
if (!src) { if (!src) {
return <PlaceholderImage tone={tone} className={className} label={label} />; return <PlaceholderImage tone={tone} className={className} label={label} />;
@@ -41,7 +46,7 @@ export function ContentImage({
const descriptiveAlt = label ? `${alt} – ${label}` : alt; const descriptiveAlt = label ? `${alt} – ${label}` : alt;
return ( return (
<div className={`relative overflow-hidden ${className}`}> <div className={`relative overflow-hidden ${className}`}>
<Image src={src} alt={descriptiveAlt} fill sizes={sizes} className="object-cover" /> <Image src={src} alt={descriptiveAlt} fill sizes={sizes} priority={priority} className="object-cover" />
</div> </div>
); );
} }
+1
View File
@@ -51,6 +51,7 @@ export function Hero({
tone="forest" tone="forest"
className="h-[78vh] min-h-[520px] w-full" className="h-[78vh] min-h-[520px] w-full"
label="Bienenstand im Morgenlicht · Altenmünster" label="Bienenstand im Morgenlicht · Altenmünster"
priority
/> />
<div className="absolute inset-0 flex items-center bg-gradient-to-t from-forest/70 via-forest/10 to-transparent"> <div className="absolute inset-0 flex items-center bg-gradient-to-t from-forest/70 via-forest/10 to-transparent">
<Container> <Container>
+4 -3
View File
@@ -128,7 +128,8 @@ type HoneySpotlightProduct = ProductWithRelations & {
}; };
export async function ProductSpotlight({ product }: { product: HoneySpotlightProduct }) { export async function ProductSpotlight({ product }: { product: HoneySpotlightProduct }) {
const t = getDictionary(await getLocale()); const locale = await getLocale();
const t = getDictionary(locale);
return ( return (
<section className="py-24 bg-forest text-cream"> <section className="py-24 bg-forest text-cream">
<Container className="grid md:grid-cols-2 gap-14 items-center"> <Container className="grid md:grid-cols-2 gap-14 items-center">
@@ -162,7 +163,7 @@ export async function ProductSpotlight({ product }: { product: HoneySpotlightPro
<dl className="mt-8 grid grid-cols-2 gap-4 text-sm text-cream/70"> <dl className="mt-8 grid grid-cols-2 gap-4 text-sm text-cream/70">
<div> <div>
<dt className="text-cream/65">{t.home.productSpotlight.harvest}</dt> <dt className="text-cream/65">{t.home.productSpotlight.harvest}</dt>
<dd>{formatDate(product.harvest.harvestDate)}</dd> <dd>{formatDate(product.harvest.harvestDate, locale)}</dd>
</div> </div>
<div> <div>
<dt className="text-cream/65">{t.home.productSpotlight.batch}</dt> <dt className="text-cream/65">{t.home.productSpotlight.batch}</dt>
@@ -174,7 +175,7 @@ export async function ProductSpotlight({ product }: { product: HoneySpotlightPro
</div> </div>
<div> <div>
<dt className="text-cream/65">{t.home.productSpotlight.price}</dt> <dt className="text-cream/65">{t.home.productSpotlight.price}</dt>
<dd>{formatPrice(product.priceCents, product.currency)}</dd> <dd>{formatPrice(product.priceCents, product.currency, locale)}</dd>
</div> </div>
</dl> </dl>
+3 -3
View File
@@ -173,7 +173,7 @@ export function ProductCard({
/** Optional — nur die Shop-Uebersicht laedt Bewertungen gebuendelt fuer das ganze Raster. */ /** Optional — nur die Shop-Uebersicht laedt Bewertungen gebuendelt fuer das ganze Raster. */
reviewSummary?: { average: number | null; count: number }; reviewSummary?: { average: number | null; count: number };
}) { }) {
const { t } = useI18n(); const { t, locale } = useI18n();
const stock = product.inventory?.quantity ?? 0; const stock = product.inventory?.quantity ?? 0;
return ( return (
@@ -229,11 +229,11 @@ export function ProductCard({
<div className="mt-2 flex items-center justify-between"> <div className="mt-2 flex items-center justify-between">
<span className="text-sm text-ink/65">{product.weightGrams} g</span> <span className="text-sm text-ink/65">{product.weightGrams} g</span>
<span className="text-base text-forest"> <span className="text-base text-forest">
{formatPrice(product.priceCents, product.currency)} {formatPrice(product.priceCents, product.currency, locale)}
</span> </span>
</div> </div>
<p className="text-xs text-ink/65"> <p className="text-xs text-ink/65">
{formatPricePerKg(product.pricePerKgCents, product.currency)} {formatPricePerKg(product.pricePerKgCents, product.currency, locale)}
</p> </p>
{stock > 0 ? ( {stock > 0 ? (
<AddToCartButton product={product} variant="secondary" className="mt-4" /> <AddToCartButton product={product} variant="secondary" className="mt-4" />
+18 -6
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useRef, useState, type TouchEvent } from "react"; import { useRef, useState, type TouchEvent } from "react";
import Image from "next/image";
import { PlaceholderImage } from "@/components/placeholder-image"; import { PlaceholderImage } from "@/components/placeholder-image";
// Mindest-Wischstrecke in Pixeln, ab der ein Touch-Wisch als Bildwechsel // Mindest-Wischstrecke in Pixeln, ab der ein Touch-Wisch als Bildwechsel
@@ -62,13 +63,25 @@ export function ProductGallery({ images, productName, description }: ProductGall
return ( return (
<div> <div>
<div <div
className="aspect-[4/3] overflow-hidden touch-pan-y" className="relative aspect-[4/3] overflow-hidden touch-pan-y"
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
> >
{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. // Hochgeladene Produktbilder liegen immer same-origin unter
<img src={active.src} alt={mainImageAlt} className="w-full h-full object-cover" draggable={false} /> // /uploads/... (siehe uploads.ts/isValidUploadPath) — next/image
// braucht dafuer KEINEN remotePatterns-Eintrag (nur externe
// Bildquellen brauchen den), siehe auch content-image.tsx. Als
// wahrscheinlichstes LCP-Element der Produktseite mit "priority"
// geladen, statt erst beim Scannen des Bild-Tags entdeckt zu werden.
<Image
src={active.src}
alt={mainImageAlt}
fill
sizes="(min-width: 768px) 50vw, 100vw"
priority
className="object-cover"
/>
) : ( ) : (
<PlaceholderImage tone={active.tone} label={active.label} className="w-full h-full" /> <PlaceholderImage tone={active.tone} label={active.label} className="w-full h-full" />
)} )}
@@ -82,13 +95,12 @@ export function ProductGallery({ images, productName, description }: ProductGall
onClick={() => setActiveIndex(index)} onClick={() => setActiveIndex(index)}
aria-label={`${isImageSlide(slide) ? `Produktbild ${index + 1}` : slide.label} – Bild ${index + 1} von ${slides.length} anzeigen`} aria-label={`${isImageSlide(slide) ? `Produktbild ${index + 1}` : slide.label} – Bild ${index + 1} von ${slides.length} anzeigen`}
aria-current={index === activeIndex} aria-current={index === activeIndex}
className={`aspect-square overflow-hidden border-2 transition-colors ${ className={`relative aspect-square overflow-hidden border-2 transition-colors ${
index === activeIndex ? "border-forest" : "border-transparent hover:border-ink/20" index === activeIndex ? "border-forest" : "border-transparent hover:border-ink/20"
}`} }`}
> >
{isImageSlide(slide) ? ( {isImageSlide(slide) ? (
// eslint-disable-next-line @next/next/no-img-element -- s.o. <Image src={slide.src} alt="" fill sizes="120px" className="object-cover" />
<img src={slide.src} alt="" className="w-full h-full object-cover" />
) : ( ) : (
<PlaceholderImage tone={slide.tone} label={slide.label} className="w-full h-full" /> <PlaceholderImage tone={slide.tone} label={slide.label} className="w-full h-full" />
)} )}
+3 -3
View File
@@ -393,7 +393,7 @@ export function Header({ companyName }: { companyName: string }) {
} }
export function CartDrawer() { export function CartDrawer() {
const { t } = useI18n(); const { t, locale } = useI18n();
const isOpen = useCartStore((s) => s.isOpen); const isOpen = useCartStore((s) => s.isOpen);
const closeCart = useCartStore((s) => s.closeCart); const closeCart = useCartStore((s) => s.closeCart);
const items = useCartStore((s) => s.items); const items = useCartStore((s) => s.items);
@@ -499,7 +499,7 @@ export function CartDrawer() {
</div> </div>
</div> </div>
<p className="text-sm text-ink"> <p className="text-sm text-ink">
{formatPrice(item.priceCents * item.quantity)} {formatPrice(item.priceCents * item.quantity, "EUR", locale)}
</p> </p>
</li> </li>
); );
@@ -512,7 +512,7 @@ export function CartDrawer() {
<div className="border-t border-ink/10 p-6 space-y-4"> <div className="border-t border-ink/10 p-6 space-y-4">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span>{t.cart.subtotal}</span> <span>{t.cart.subtotal}</span>
<span>{formatPrice(total)}</span> <span>{formatPrice(total, "EUR", locale)}</span>
</div> </div>
<LinkButton href="/checkout" onClick={closeCart} className="w-full"> <LinkButton href="/checkout" onClick={closeCart} className="w-full">
{t.cart.checkout} {t.cart.checkout}
+2 -2
View File
@@ -25,7 +25,7 @@ export function StickyAddToCartBar({
product: ProductWithRelations; product: ProductWithRelations;
inStock: boolean; inStock: boolean;
}) { }) {
const { t } = useI18n(); const { t, locale } = useI18n();
const addItem = useCartStore((s) => s.addItem); const addItem = useCartStore((s) => s.addItem);
const [justAdded, setJustAdded] = useState(false); const [justAdded, setJustAdded] = useState(false);
@@ -37,7 +37,7 @@ export function StickyAddToCartBar({
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm text-ink">{product.name}</p> <p className="truncate text-sm text-ink">{product.name}</p>
<p className="text-sm text-forest">{formatPrice(product.priceCents, product.currency)}</p> <p className="text-sm text-forest">{formatPrice(product.priceCents, product.currency, locale)}</p>
</div> </div>
{inStock ? ( {inStock ? (
<Button <Button
+3 -3
View File
@@ -24,7 +24,7 @@ export const en: Dictionary = {
openSearch: "Open search", openSearch: "Open search",
openMenu: "Open menu", openMenu: "Open menu",
closeMenu: "Close menu", closeMenu: "Close menu",
openCart: (count) => (count > 0 ? `Open cart, ${count} items` : "Open cart"), openCart: (count) => (count > 0 ? `Open cart, ${count} item${count === 1 ? "" : "s"}` : "Open cart"),
skipToContent: "Skip to content", skipToContent: "Skip to content",
}, },
footer: { footer: {
@@ -1297,13 +1297,13 @@ export const en: Dictionary = {
paymentMethodsNote: paymentMethodsNote:
"Payment methods: PayPal, credit card, Apple Pay, Google Pay, SEPA direct debit, Klarna — provided via Stripe.", "Payment methods: PayPal, credit card, Apple Pay, Google Pay, SEPA direct debit, Klarna — provided via Stripe.",
yourOrderHeading: "Your Order", yourOrderHeading: "Your Order",
shippingWithCount: (count) => `Shipping (${count} items)`, shippingWithCount: (count) => `Shipping (${count} item${count === 1 ? "" : "s"})`,
shippingPickup: "Shipping (pickup)", shippingPickup: "Shipping (pickup)",
}, },
cartPage: { cartPage: {
title: "Your Cart", title: "Your Cart",
goToShop: "Go to shop", goToShop: "Go to shop",
shippingLabel: (count) => `Shipping (${count} items)`, shippingLabel: (count) => `Shipping (${count} item${count === 1 ? "" : "s"})`,
free: "free", free: "free",
total: "Total", total: "Total",
nextTierHint: (unitsUntilNext, nextPrice) => nextTierHint: (unitsUntilNext, nextPrice) =>
+9 -9
View File
@@ -76,7 +76,7 @@ export async function orderConfirmationEmail(order: OrderConfirmationInput): Pro
const rows = order.items const rows = order.items
.map( .map(
(item) => (item) =>
`<tr><td style="padding:6px 0;">${item.quantity} × ${item.productName} (${item.weightGrams} g)</td><td style="padding:6px 0;text-align:right;">${formatPrice(item.totalCents)}</td></tr>` `<tr><td style="padding:6px 0;">${item.quantity} × ${item.productName} (${item.weightGrams} g)</td><td style="padding:6px 0;text-align:right;">${formatPrice(item.totalCents, "EUR", order.locale)}</td></tr>`
) )
.join(""); .join("");
@@ -101,10 +101,10 @@ export async function orderConfirmationEmail(order: OrderConfirmationInput): Pro
${rows} ${rows}
</table> </table>
<table role="presentation" width="100%" style="font-size:14px;margin-top:12px;"> <table role="presentation" width="100%" style="font-size:14px;margin-top:12px;">
<tr><td>${s.subtotal}</td><td style="text-align:right;">${formatPrice(order.subtotalCents)}</td></tr> <tr><td>${s.subtotal}</td><td style="text-align:right;">${formatPrice(order.subtotalCents, "EUR", order.locale)}</td></tr>
<tr><td>${s.shipping}</td><td style="text-align:right;">${order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents)}</td></tr> <tr><td>${s.shipping}</td><td style="text-align:right;">${order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents, "EUR", order.locale)}</td></tr>
${order.discountCents ? `<tr><td>${s.discount}</td><td style="text-align:right;">−${formatPrice(order.discountCents)}</td></tr>` : ""} ${order.discountCents ? `<tr><td>${s.discount}</td><td style="text-align:right;">−${formatPrice(order.discountCents, "EUR", order.locale)}</td></tr>` : ""}
<tr><td style="font-weight:bold;color:#24352B;padding-top:8px;">${s.total}</td><td style="text-align:right;font-weight:bold;color:#24352B;padding-top:8px;">${formatPrice(order.totalCents)}</td></tr> <tr><td style="font-weight:bold;color:#24352B;padding-top:8px;">${s.total}</td><td style="text-align:right;font-weight:bold;color:#24352B;padding-top:8px;">${formatPrice(order.totalCents, "EUR", order.locale)}</td></tr>
</table> </table>
${deliveryHtml} ${deliveryHtml}
${giftHtml} ${giftHtml}
@@ -113,10 +113,10 @@ export async function orderConfirmationEmail(order: OrderConfirmationInput): Pro
); );
const text = `${s.heading(order.firstName)}\n\n${order.orderNumber}\n\n${order.items const text = `${s.heading(order.firstName)}\n\n${order.orderNumber}\n\n${order.items
.map((i) => `${i.quantity} x ${i.productName} (${i.weightGrams} g) — ${formatPrice(i.totalCents)}`) .map((i) => `${i.quantity} x ${i.productName} (${i.weightGrams} g) — ${formatPrice(i.totalCents, "EUR", order.locale)}`)
.join("\n")}\n\n${s.subtotal}: ${formatPrice(order.subtotalCents)}\n${s.shipping}: ${ .join("\n")}\n\n${s.subtotal}: ${formatPrice(order.subtotalCents, "EUR", order.locale)}\n${s.shipping}: ${
order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents) order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents, "EUR", order.locale)
}${order.discountCents ? `\n${s.discount}: −${formatPrice(order.discountCents)}` : ""}\n${s.total}: ${formatPrice(order.totalCents)}${ }${order.discountCents ? `\n${s.discount}: −${formatPrice(order.discountCents, "EUR", order.locale)}` : ""}\n${s.total}: ${formatPrice(order.totalCents, "EUR", order.locale)}${
order.giftMessage ? `\n\n${s.giftNote}` : "" order.giftMessage ? `\n\n${s.giftNote}` : ""
}${order.trackingUrl ? `\n\n${s.trackOrder}: ${order.trackingUrl}` : ""}`; }${order.trackingUrl ? `\n\n${s.trackOrder}: ${order.trackingUrl}` : ""}`;
+27 -11
View File
@@ -1,8 +1,24 @@
import type { Locale } from "@/i18n/config";
// BCP-47-Tag je Locale fuer Intl.*Format — "en-GB" statt "en-US" fuer
// Englisch, damit die Tagesreihenfolge (10 May 2026) der deutschen
// Konvention (10. Mai 2026) naeher bleibt als das umgestellte US-Format
// (May 10, 2026). Zahlungs-/Preisformatierung unterscheidet sich zwischen
// beiden englischen Varianten fuer EUR ohnehin nicht.
const INTL_LOCALE: Record<Locale, string> = {
de: "de-DE",
en: "en-GB",
};
/** /**
* Formatiert Cent-Betraege als deutsche Euro-Preisangabe, z.B. 990 -> "9,90 €". * Formatiert Cent-Betraege als Euro-Preisangabe in der uebergebenen Sprache,
* z.B. 990 -> "9,90 €" (de) oder "€9.90" (en). Ohne "locale" faellt die
* Funktion auf Deutsch zurueck — das gilt fuer interne/rechtlich gebundene
* Ausgaben wie Rechnungen und den Admin-Bereich, die bewusst nicht der
* Kunden-Locale folgen.
*/ */
export function formatPrice(cents: number, currency = "EUR"): string { export function formatPrice(cents: number, currency = "EUR", locale: Locale = "de"): string {
return new Intl.NumberFormat("de-DE", { return new Intl.NumberFormat(INTL_LOCALE[locale], {
style: "currency", style: "currency",
currency, currency,
}).format(cents / 100); }).format(cents / 100);
@@ -11,20 +27,20 @@ export function formatPrice(cents: number, currency = "EUR"): string {
/** /**
* Formatiert den Grundpreis (Preis pro Kilogramm), z.B. fuer Vergleichbarkeit im Shop. * Formatiert den Grundpreis (Preis pro Kilogramm), z.B. fuer Vergleichbarkeit im Shop.
*/ */
export function formatPricePerKg(cents: number, currency = "EUR"): string { export function formatPricePerKg(cents: number, currency = "EUR", locale: Locale = "de"): string {
return `${formatPrice(cents, currency)} / kg`; return `${formatPrice(cents, currency, locale)} / kg`;
} }
export function formatDate(date: Date): string { export function formatDate(date: Date, locale: Locale = "de"): string {
return new Intl.DateTimeFormat("de-DE", { return new Intl.DateTimeFormat(INTL_LOCALE[locale], {
day: "2-digit", day: "2-digit",
month: "long", month: "long",
year: "numeric", year: "numeric",
}).format(date); }).format(date);
} }
export function formatDateTime(date: Date): string { export function formatDateTime(date: Date, locale: Locale = "de"): string {
return new Intl.DateTimeFormat("de-DE", { return new Intl.DateTimeFormat(INTL_LOCALE[locale], {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
year: "numeric", year: "numeric",
@@ -33,8 +49,8 @@ export function formatDateTime(date: Date): string {
}).format(date); }).format(date);
} }
export function formatMonthYear(date: Date): string { export function formatMonthYear(date: Date, locale: Locale = "de"): string {
return new Intl.DateTimeFormat("de-DE", { return new Intl.DateTimeFormat(INTL_LOCALE[locale], {
month: "long", month: "long",
year: "numeric", year: "numeric",
}).format(date); }).format(date);