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:
1 parent
ac432e261b
commit
de0f6ee249
20 files changed
+137
-84
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user