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 { 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 });
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user