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 { formatPrice } from "@/lib/format";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { getLocale } from "@/i18n/locale";
const SUGGESTION_LIMIT = 5;
const SUGGEST_ATTEMPTS_PER_WINDOW = 60;
@@ -26,11 +27,12 @@ export async function GET(request: Request) {
return NextResponse.json({ suggestions: [] });
}
const locale = await getLocale();
const products = await getActiveProducts({ query, onlyAvailable: true });
const suggestions = products.slice(0, SUGGESTION_LIMIT).map((product) => ({
slug: product.slug,
name: product.name,
price: formatPrice(product.priceCents, product.currency),
price: formatPrice(product.priceCents, product.currency, locale),
}));
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;
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 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>
<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 className="text-right">
<OrderStatusBadge
@@ -90,12 +91,12 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
<p className="mt-1.5 text-xs text-ink/65">
{order.fulfillmentMethod === "VERSAND"
? order.shipment?.shippedAt
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt))
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt, locale))
: t.ordersPage.shippingPending
: order.pickup?.pickedUpAt
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt))
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt, locale))
: order.pickup?.readyAt
? t.ordersPage.readySince(formatDate(order.pickup.readyAt))
? t.ordersPage.readySince(formatDate(order.pickup.readyAt, locale))
: t.ordersPage.pickupPending}
</p>
</div>
@@ -150,7 +151,7 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
) : null}
<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 ? (
<Link href={`/rechnung/${order.invoice.invoiceNumber}`} className="text-sm text-forest underline">
{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.
*/
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 batch = await getBatchByCode(code);
if (!batch) notFound();
const steps = [
{ label: t.chargePage.stepHarvest, value: formatDate(batch.harvest.harvestDate) },
{ label: t.chargePage.stepFilling, value: formatDate(batch.fillDate) },
{ label: t.chargePage.stepBestBefore, value: formatDate(batch.bestBeforeDate) },
{ label: t.chargePage.stepHarvest, value: formatDate(batch.harvest.harvestDate, locale) },
{ label: t.chargePage.stepFilling, value: formatDate(batch.fillDate, locale) },
{ label: t.chargePage.stepBestBefore, value: formatDate(batch.bestBeforeDate, locale) },
];
return (
+6 -6
View File
@@ -31,7 +31,7 @@ export function CheckoutForm({
tiers: ShippingTierLike[];
bulkDiscountTiers: BulkDiscountTierLike[];
}) {
const { t } = useI18n();
const { t, locale } = useI18n();
const items = useCartStore((s) => s.items);
const subtotal = cartTotalCents(items);
const totalQuantity = cartItemCount(items);
@@ -267,14 +267,14 @@ export function CheckoutForm({
<span>
{item.quantity} × {item.name}
</span>
<span>{formatPrice(item.priceCents * item.quantity)}</span>
<span>{formatPrice(item.priceCents * item.quantity, "EUR", locale)}</span>
</li>
))}
</ul>
<div className="mt-6 pt-4 border-t border-ink/10 space-y-2 text-sm">
<div className="flex justify-between text-ink/70">
<span>{t.cart.subtotal}</span>
<span>{formatPrice(subtotal)}</span>
<span>{formatPrice(subtotal, "EUR", locale)}</span>
</div>
<div className="flex justify-between text-ink/70">
<span>
@@ -282,20 +282,20 @@ export function CheckoutForm({
? t.checkoutPage.shippingWithCount(totalQuantity)
: t.checkoutPage.shippingPickup}
</span>
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping)}</span>
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping, "EUR", locale)}</span>
</div>
{activeBulkDiscount ? (
<div className="flex justify-between text-forest">
<span>
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
</span>
<span>-{formatPrice(bulkDiscountCents)}</span>
<span>-{formatPrice(bulkDiscountCents, "EUR", locale)}</span>
</div>
) : null}
</div>
<div className="mt-3 pt-3 border-t border-ink/10 flex justify-between text-base text-forest">
<span>{t.cartPage.total}</span>
<span>{formatPrice(total)}</span>
<span>{formatPrice(total, "EUR", locale)}</span>
</div>
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
</aside>
+3 -2
View File
@@ -30,7 +30,8 @@ export async function generateMetadata(
export default async function HarvestDetailPage(
props: PageProps<"/ernten/[year]/[type]">
) {
const t = getDictionary(await getLocale());
const locale = await getLocale();
const t = getDictionary(locale);
const { year, type } = await props.params;
// Next.js liefert dynamische Segmente hier unkodiert (z.B. "Fr%C3%BChtracht"
// statt "Frühtracht") — deshalb wird explizit dekodiert, statt sich auf
@@ -53,7 +54,7 @@ export default async function HarvestDetailPage(
/>
<div>
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
{harvest.year} · {formatDate(harvest.harvestDate)}
{harvest.year} · {formatDate(harvest.harvestDate, locale)}
</p>
<h1 className="text-4xl text-forest">{harvest.type}</h1>
<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 default async function ErntenPage() {
const t = getDictionary(await getLocale());
const locale = await getLocale();
const t = getDictionary(locale);
const harvests = await getHarvests();
const byYear = harvests.reduce<Record<number, typeof harvests>>((acc, h) => {
acc[h.year] = [...(acc[h.year] ?? []), h];
@@ -46,7 +47,7 @@ export default async function ErntenPage() {
alt={harvest.type}
tone="forest"
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>
<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(
props: PageProps<"/honig/[slug]">
) {
const t = getDictionary(await getLocale());
const locale = await getLocale();
const t = getDictionary(locale);
const { slug } = await props.params;
const { error, message, notifyError, notifyMessage } = await props.searchParams;
const product = await getProductBySlug(slug);
@@ -167,18 +168,20 @@ export default async function ProductDetailPage(
<div className="mt-6 flex items-baseline gap-3">
<span className="text-2xl text-forest">
{formatPrice(product.priceCents, product.currency)}
{formatPrice(product.priceCents, product.currency, locale)}
</span>
<span className="text-sm text-ink/65">{product.weightGrams} g</span>
</div>
<p className="text-xs text-ink/65">
{formatPricePerKg(product.pricePerKgCents, product.currency)}
{formatPricePerKg(product.pricePerKgCents, product.currency, locale)}
</p>
<Link
href="/versand-zahlung"
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>
{isHoney ? (
@@ -270,7 +273,10 @@ export default async function ProductDetailPage(
label={t.productPage.detailLabels.origin}
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.storage} value={t.productPage.storageText} />
<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>
{review.comment ? <p className="mt-2 text-sm text-ink/80">{review.comment}</p> : null}
<p className="mt-2 text-xs text-ink/65">
{review.customer.firstName} · {formatDate(review.createdAt)}
{review.customer.firstName} · {formatDate(review.createdAt, locale)}
</p>
</li>
))}
+7 -6
View File
@@ -26,7 +26,8 @@ const RETURN_ELIGIBLE_STATUSES = [
];
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 returnStatusLabels = t.ordersPage.returnStatusLabels;
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>
<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 className="text-right">
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
<p className="mt-1.5 text-xs text-ink/65">
{order.fulfillmentMethod === "VERSAND"
? order.shipment?.shippedAt
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt))
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt, locale))
: t.ordersPage.shippingPending
: order.pickup?.pickedUpAt
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt))
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt, locale))
: order.pickup?.readyAt
? t.ordersPage.readySince(formatDate(order.pickup.readyAt))
? t.ordersPage.readySince(formatDate(order.pickup.readyAt, locale))
: t.ordersPage.pickupPending}
</p>
</div>
@@ -138,7 +139,7 @@ export default async function KontoBestellungenPage(props: PageProps<"/konto/bes
) : null}
<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">
{order.invoice ? (
<Link
+8 -3
View File
@@ -12,15 +12,20 @@ export const metadata: Metadata = { title: "Versand und Zahlung", alternates: {
export const dynamic = "force-dynamic";
export default async function VersandZahlungPage() {
const t = getDictionary(await getLocale());
const locale = await getLocale();
const t = getDictionary(locale);
const tiers = await getShippingTiers();
const tierParagraphs =
tiers.length > 0
? tiers.map((tier) =>
tier.maxQuantity === null
? t.versandZahlungPage.tierMinimum(tier.minQuantity, formatPrice(tier.priceCents))
: t.versandZahlungPage.tierRange(tier.minQuantity, tier.maxQuantity, formatPrice(tier.priceCents))
? t.versandZahlungPage.tierMinimum(tier.minQuantity, formatPrice(tier.priceCents, "EUR", locale))
: t.versandZahlungPage.tierRange(
tier.minQuantity,
tier.maxQuantity,
formatPrice(tier.priceCents, "EUR", locale)
)
)
: [t.versandZahlungPage.tierFallback];
+7 -7
View File
@@ -25,7 +25,7 @@ export function WarenkorbContent({
availableProducts: ProductWithRelations[];
bulkDiscountTiers: BulkDiscountTierLike[];
}) {
const { t } = useI18n();
const { t, locale } = useI18n();
const items = useCartStore((s) => s.items);
const setQuantity = useCartStore((s) => s.setQuantity);
const removeItem = useCartStore((s) => s.removeItem);
@@ -96,7 +96,7 @@ export function WarenkorbContent({
max={itemStock ?? 10}
/>
<span className="w-20 text-right text-ink">
{formatPrice(item.priceCents * item.quantity)}
{formatPrice(item.priceCents * item.quantity, "EUR", locale)}
</span>
<button
onClick={() => removeItem(item.productId)}
@@ -115,15 +115,15 @@ export function WarenkorbContent({
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span>{t.cart.subtotal}</span>
<span>{formatPrice(subtotal)}</span>
<span>{formatPrice(subtotal, "EUR", locale)}</span>
</div>
<div className="flex justify-between">
<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>
{nextTier && nextTier.unitsUntilNext <= 2 ? (
<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>
) : null}
{activeBulkDiscount ? (
@@ -131,7 +131,7 @@ export function WarenkorbContent({
<span>
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
</span>
<span>-{formatPrice(bulkDiscountCents)}</span>
<span>-{formatPrice(bulkDiscountCents, "EUR", locale)}</span>
</div>
) : nextBulkDiscount && nextBulkDiscount.unitsUntilNext <= 2 ? (
<p className="text-xs text-ink/60">
@@ -140,7 +140,7 @@ export function WarenkorbContent({
) : null}
<div className="flex justify-between text-base text-forest pt-3 border-t border-ink/10">
<span>{t.cartPage.total}</span>
<span>{formatPrice(subtotal - bulkDiscountCents + shipping)}</span>
<span>{formatPrice(subtotal - bulkDiscountCents + shipping, "EUR", locale)}</span>
</div>
</div>
<LinkButton href="/checkout" className="w-full mt-6">