From e8e5e6f62a573349e2fa05412078e8c0dc534c0f Mon Sep 17 00:00:00 2001
From: laaichiu <134155205+laaichiu@users.noreply.github.com>
Date: Fri, 24 Jul 2026 11:30:25 +0800
Subject: [PATCH 01/11] fix: show SKU for products without custom variants
(#191)
---
.../products/[slug]/ProductDetails.test.tsx | 88 +++++++++++++++++++
.../products/[slug]/ProductDetails.tsx | 8 +-
src/lib/data/cached.ts | 1 +
3 files changed, 93 insertions(+), 4 deletions(-)
create mode 100644 src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.test.tsx
diff --git a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.test.tsx b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.test.tsx
new file mode 100644
index 00000000..03b1c17a
--- /dev/null
+++ b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.test.tsx
@@ -0,0 +1,88 @@
+import type { Product } from "@spree/sdk";
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { PRODUCT_PAGE_EXPAND } from "@/lib/data/cached";
+import { ProductDetails } from "./ProductDetails";
+
+vi.mock("next-intl", () => ({
+ useTranslations: () => (key: string) => key,
+}));
+
+vi.mock("@/components/products/MediaGallery", () => ({
+ MediaGallery: () => null,
+}));
+
+vi.mock("@/components/products/ProductCustomFields", () => ({
+ ProductCustomFields: () => null,
+}));
+
+vi.mock("@/contexts/CartContext", () => ({
+ useCart: () => ({ addItem: vi.fn() }),
+}));
+
+vi.mock("@/contexts/HiddenPricingContext", () => ({
+ useHiddenPricing: () => null,
+}));
+
+vi.mock("@/contexts/StoreContext", () => ({
+ useStore: () => ({ currency: "USD" }),
+}));
+
+vi.mock("@/lib/analytics/gtm", () => ({
+ trackAddToCart: vi.fn(),
+ trackViewItem: vi.fn(),
+}));
+
+const productWithoutCustomVariants = {
+ id: "product-1",
+ name: "Single Variant Product",
+ slug: "single-variant-product",
+ default_variant_id: "variant-master",
+ default_variant: {
+ id: "variant-master",
+ product_id: "product-1",
+ sku: "MASTER-SKU-001",
+ options_text: "",
+ purchasable: true,
+ in_stock: true,
+ price: {
+ display_amount: "$25.00",
+ amount_in_cents: 2500,
+ compare_at_amount_in_cents: null,
+ display_compare_at_amount: null,
+ },
+ original_price: null,
+ },
+ variants: [],
+ option_types: [],
+ media: [],
+ purchasable: true,
+ in_stock: true,
+ price: {
+ display_amount: "$25.00",
+ amount_in_cents: 2500,
+ compare_at_amount_in_cents: null,
+ display_compare_at_amount: null,
+ },
+ original_price: null,
+ description_html: null,
+ custom_fields: [],
+} as unknown as Product;
+
+describe("ProductDetails", () => {
+ it("requests the default variant for the product page", () => {
+ expect(PRODUCT_PAGE_EXPAND).toContain("default_variant");
+ });
+
+ it("shows the master SKU when a product has no custom variants", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("sku")).toBeInTheDocument();
+ expect(screen.getByText("MASTER-SKU-001")).toBeInTheDocument();
+ });
+});
diff --git a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx
index 2dbf368e..e1860e42 100644
--- a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx
+++ b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx
@@ -94,6 +94,8 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) {
: price?.display_compare_at_amount) ?? null)
: null;
+ const sku = selectedVariant?.sku ?? product.default_variant?.sku;
+
// Purchasability
const isPurchasable = hasVariants
? (selectedVariant?.purchasable ?? false)
@@ -250,12 +252,10 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) {
{t("details")}
- {selectedVariant?.sku && (
+ {sku && (
- {t("sku")}
- -
- {selectedVariant.sku}
-
+ - {sku}
)}
{selectedVariant?.options_text && (
diff --git a/src/lib/data/cached.ts b/src/lib/data/cached.ts
index fe6c03dd..d7ea3d23 100644
--- a/src/lib/data/cached.ts
+++ b/src/lib/data/cached.ts
@@ -5,6 +5,7 @@ import { getProduct } from "./products";
/** Expand list used on the product detail page. */
export const PRODUCT_PAGE_EXPAND = [
+ "default_variant",
"variants",
"media",
"option_types",
From 81eb9b57d1f445c56793042e79083298f557368f Mon Sep 17 00:00:00 2001
From: laaichiu <134155205+laaichiu@users.noreply.github.com>
Date: Fri, 24 Jul 2026 16:10:19 +0800
Subject: [PATCH 02/11] fix: add reliable policy canonical metadata (#188)
---
.../policies/[slug]/page.test.tsx | 56 +++++++++++++++++++
.../(storefront)/policies/[slug]/page.tsx | 22 ++++++--
src/lib/data/__tests__/policies.test.ts | 56 +++++++++++++++++++
src/lib/data/policies.ts | 28 ++++++++--
4 files changed, 152 insertions(+), 10 deletions(-)
create mode 100644 src/app/[country]/[locale]/(storefront)/policies/[slug]/page.test.tsx
create mode 100644 src/lib/data/__tests__/policies.test.ts
diff --git a/src/app/[country]/[locale]/(storefront)/policies/[slug]/page.test.tsx b/src/app/[country]/[locale]/(storefront)/policies/[slug]/page.test.tsx
new file mode 100644
index 00000000..06f24a70
--- /dev/null
+++ b/src/app/[country]/[locale]/(storefront)/policies/[slug]/page.test.tsx
@@ -0,0 +1,56 @@
+import type { Policy } from "@spree/sdk";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { getPolicy } from "@/lib/data/policies";
+import { generateMetadata } from "./page";
+
+vi.mock("next-intl/server", () => ({
+ getTranslations: vi.fn(),
+}));
+
+vi.mock("@/lib/data/policies", () => ({
+ getPolicy: vi.fn(),
+}));
+
+const policy = {
+ id: "policy-1",
+ name: "Privacy Policy",
+ slug: "privacy-policy",
+ body: null,
+ body_html: null,
+} satisfies Policy;
+
+describe("policy metadata", () => {
+ beforeEach(() => {
+ vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://store.example/");
+ vi.stubEnv("NEXT_PUBLIC_STORE_NAME", "Example Store");
+ vi.mocked(getPolicy).mockResolvedValue(policy);
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.clearAllMocks();
+ });
+
+ it("sets the localized policy URL as canonical", async () => {
+ const metadata = await generateMetadata({
+ params: Promise.resolve({
+ country: "us",
+ locale: "en",
+ slug: "privacy-policy",
+ }),
+ });
+
+ const canonicalUrl = "https://store.example/us/en/policies/privacy-policy";
+
+ expect(metadata).toMatchObject({
+ title: "Privacy Policy",
+ description: "Privacy Policy — Example Store",
+ alternates: { canonical: canonicalUrl },
+ openGraph: {
+ title: "Privacy Policy",
+ description: "Privacy Policy — Example Store",
+ url: canonicalUrl,
+ },
+ });
+ });
+});
diff --git a/src/app/[country]/[locale]/(storefront)/policies/[slug]/page.tsx b/src/app/[country]/[locale]/(storefront)/policies/[slug]/page.tsx
index 61353a48..7401fc2f 100644
--- a/src/app/[country]/[locale]/(storefront)/policies/[slug]/page.tsx
+++ b/src/app/[country]/[locale]/(storefront)/policies/[slug]/page.tsx
@@ -2,7 +2,8 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getPolicy } from "@/lib/data/policies";
-import { getStoreName } from "@/lib/store";
+import { buildCanonicalUrl } from "@/lib/seo";
+import { getStoreName, getStoreUrl } from "@/lib/store";
interface PolicyPageProps {
params: Promise<{
@@ -15,7 +16,7 @@ interface PolicyPageProps {
export async function generateMetadata({
params,
}: PolicyPageProps): Promise {
- const { slug, locale } = await params;
+ const { country, locale, slug } = await params;
const policy = await getPolicy(slug);
const storeName = getStoreName();
@@ -31,12 +32,23 @@ export async function generateMetadata({
};
}
+ const description = `${policy.name} — ${storeName}`;
+ const storeUrl = getStoreUrl();
+ const canonicalUrl = storeUrl
+ ? buildCanonicalUrl(
+ storeUrl,
+ `/${country}/${locale}/policies/${policy.slug}`,
+ )
+ : undefined;
+
return {
- title: storeName ? `${policy.name} | ${storeName}` : policy.name,
- description: `${policy.name} — ${storeName}`,
+ title: policy.name,
+ description,
+ ...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
openGraph: {
title: policy.name,
- description: `${policy.name} — ${storeName}`,
+ description,
+ ...(canonicalUrl ? { url: canonicalUrl } : {}),
},
};
}
diff --git a/src/lib/data/__tests__/policies.test.ts b/src/lib/data/__tests__/policies.test.ts
new file mode 100644
index 00000000..062bafda
--- /dev/null
+++ b/src/lib/data/__tests__/policies.test.ts
@@ -0,0 +1,56 @@
+import { SpreeError } from "@spree/sdk";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const getPolicyFromApi = vi.fn();
+
+vi.mock("@/lib/spree", () => ({
+ getClient: () => ({ policies: { get: getPolicyFromApi } }),
+ getLocaleOptions: vi.fn().mockResolvedValue({ country: "us", locale: "en" }),
+}));
+
+vi.mock("next/cache", () => ({
+ cacheLife: vi.fn(),
+ cacheTag: vi.fn(),
+}));
+
+import { cachedGetPolicy } from "@/lib/data/policies";
+
+describe("cachedGetPolicy", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns null only for a real Store API 404", async () => {
+ getPolicyFromApi.mockRejectedValue(
+ new SpreeError(
+ { error: { code: "not_found", message: "Policy not found" } },
+ 404,
+ ),
+ );
+
+ await expect(
+ cachedGetPolicy("missing", { country: "us", locale: "en" }),
+ ).resolves.toBeNull();
+ });
+
+ it("does not turn a Store API 500 into a cacheable not-found result", async () => {
+ const error = new SpreeError(
+ { error: { code: "internal_error", message: "Store API unavailable" } },
+ 500,
+ );
+ getPolicyFromApi.mockRejectedValue(error);
+
+ await expect(
+ cachedGetPolicy("privacy", { country: "us", locale: "en" }),
+ ).rejects.toBe(error);
+ });
+
+ it("does not hide transport failures", async () => {
+ const error = new TypeError("fetch failed");
+ getPolicyFromApi.mockRejectedValue(error);
+
+ await expect(
+ cachedGetPolicy("privacy", { country: "us", locale: "en" }),
+ ).rejects.toBe(error);
+ });
+});
diff --git a/src/lib/data/policies.ts b/src/lib/data/policies.ts
index 776b7384..ce9a01eb 100644
--- a/src/lib/data/policies.ts
+++ b/src/lib/data/policies.ts
@@ -1,9 +1,27 @@
"use server";
-import type { Policy } from "@spree/sdk";
-import { getClient } from "@/lib/spree";
+import { type Policy, SpreeError } from "@spree/sdk";
+import { cacheLife, cacheTag } from "next/cache";
+import { getClient, getLocaleOptions } from "@/lib/spree";
-export async function getPolicy(slug: string): Promise {
- const client = getClient();
- return client.policies.get(slug).catch(() => null);
+export async function cachedGetPolicy(
+ slugOrId: string,
+ options: { locale?: string; country?: string },
+): Promise {
+ "use cache: remote";
+ cacheLife("tenMinutes");
+ cacheTag("policies", `policy:${slugOrId}`);
+ try {
+ return await getClient().policies.get(slugOrId, options);
+ } catch (error) {
+ if (error instanceof SpreeError && error.status === 404) return null;
+ throw error;
+ }
+}
+
+export async function getPolicy(
+ slugOrId: string,
+ options?: { locale?: string; country?: string },
+): Promise {
+ return cachedGetPolicy(slugOrId, options ?? (await getLocaleOptions()));
}
From 1cbdb5db5e74cf0961795ce1e0c89ceedebcdba5 Mon Sep 17 00:00:00 2001
From: laaichiu <134155205+laaichiu@users.noreply.github.com>
Date: Fri, 24 Jul 2026 16:11:15 +0800
Subject: [PATCH 03/11] fix: make locale routing and sitemaps market-aware
---
.../[country]/[locale]/(storefront)/page.tsx | 52 +-
src/app/[country]/[locale]/layout.test.tsx | 118 ++++
src/app/[country]/[locale]/layout.tsx | 154 ++++-
src/app/dev/layout.tsx | 7 +
src/app/robots.ts | 4 +
src/app/sitemap.test.ts | 263 ++++++++
src/app/sitemap.ts | 617 ++++++++++--------
src/components/layout/DocumentShell.test.tsx | 40 ++
.../layout/DocumentShell.tsx} | 27 +-
src/i18n/__tests__/locales.test.ts | 41 ++
src/i18n/__tests__/markets.test.ts | 70 ++
src/i18n/__tests__/routing.test.ts | 21 +
src/i18n/locales.ts | 51 ++
src/i18n/markets.ts | 93 +++
src/i18n/normalize.ts | 94 +++
src/i18n/request.ts | 30 +-
src/i18n/routing.ts | 26 +
src/lib/data/__tests__/sitemap.test.ts | 75 +++
src/lib/data/sitemap.ts | 79 +++
src/lib/spree/middleware.test.ts | 86 +++
src/lib/spree/middleware.ts | 92 ++-
src/proxy.ts | 2 +
src/types/next-intl.d.ts | 3 +-
23 files changed, 1649 insertions(+), 396 deletions(-)
create mode 100644 src/app/[country]/[locale]/layout.test.tsx
create mode 100644 src/app/dev/layout.tsx
create mode 100644 src/app/sitemap.test.ts
create mode 100644 src/components/layout/DocumentShell.test.tsx
rename src/{app/layout.tsx => components/layout/DocumentShell.tsx} (72%)
create mode 100644 src/i18n/__tests__/locales.test.ts
create mode 100644 src/i18n/__tests__/markets.test.ts
create mode 100644 src/i18n/__tests__/routing.test.ts
create mode 100644 src/i18n/locales.ts
create mode 100644 src/i18n/markets.ts
create mode 100644 src/i18n/normalize.ts
create mode 100644 src/i18n/routing.ts
create mode 100644 src/lib/data/__tests__/sitemap.test.ts
create mode 100644 src/lib/data/sitemap.ts
create mode 100644 src/lib/spree/middleware.test.ts
diff --git a/src/app/[country]/[locale]/(storefront)/page.tsx b/src/app/[country]/[locale]/(storefront)/page.tsx
index 577a4ac3..57784f7a 100644
--- a/src/app/[country]/[locale]/(storefront)/page.tsx
+++ b/src/app/[country]/[locale]/(storefront)/page.tsx
@@ -2,9 +2,8 @@ import type { Metadata } from "next";
import { FeaturedProductsSection } from "@/components/home/FeaturedProductsSection";
import { HeroSection } from "@/components/home/HeroSection";
import { WholesaleSection } from "@/components/home/WholesaleSection";
-import { getMarkets, resolveCurrency } from "@/lib/data/markets";
+import { resolveCurrency } from "@/lib/data/markets";
import { generateHomeMetadata } from "@/lib/metadata/home";
-import { getDefaultCountry, getDefaultLocale } from "@/lib/store";
interface HomePageProps {
params: Promise<{
@@ -13,55 +12,6 @@ interface HomePageProps {
}>;
}
-/**
- * Prebuild the homepage shell for every (country, locale) combination the
- * store serves. Next.js reuses the static shell (hero + featured section
- * chrome) while featured products stream in under Suspense.
- *
- * Cache Components requires this to return at least one entry, so we
- * always include the store's configured default country/locale as a
- * fallback even if the markets fetch fails.
- */
-export async function generateStaticParams() {
- const fallback = {
- country: getDefaultCountry(),
- locale: getDefaultLocale(),
- };
-
- let markets;
- try {
- ({ data: markets } = await getMarkets());
- } catch {
- return [fallback];
- }
-
- const params: Array<{ country: string; locale: string }> = [];
- const seen = new Set();
-
- const addParam = (country: string, locale: string) => {
- const key = `${country}/${locale}`;
- if (seen.has(key)) return;
- seen.add(key);
- params.push({ country, locale });
- };
-
- for (const market of markets) {
- const locale = market.default_locale;
- if (!locale) continue;
- for (const country of market.countries ?? []) {
- const iso = country.iso?.toLowerCase();
- if (!iso) continue;
- addParam(iso, locale);
- }
- }
-
- if (params.length === 0) {
- addParam(fallback.country, fallback.locale);
- }
-
- return params;
-}
-
export async function generateMetadata({
params,
}: HomePageProps): Promise {
diff --git a/src/app/[country]/[locale]/layout.test.tsx b/src/app/[country]/[locale]/layout.test.tsx
new file mode 100644
index 00000000..2b7cb0d9
--- /dev/null
+++ b/src/app/[country]/[locale]/layout.test.tsx
@@ -0,0 +1,118 @@
+import type { Country, Market } from "@spree/sdk";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { REQUEST_PATHNAME_HEADER, REQUEST_SEARCH_HEADER } from "@/i18n/routing";
+
+const mocks = vi.hoisted(() => ({
+ getMarkets: vi.fn(),
+ headers: vi.fn(),
+ notFound: vi.fn(() => {
+ throw new Error("not-found");
+ }),
+ redirect: vi.fn((location: string) => {
+ throw new Error(`redirect:${location}`);
+ }),
+}));
+
+vi.mock("next/headers", () => ({ headers: mocks.headers }));
+vi.mock("next/navigation", () => ({
+ notFound: mocks.notFound,
+ redirect: mocks.redirect,
+}));
+vi.mock("next-intl", () => ({
+ NextIntlClientProvider: ({ children }: { children: React.ReactNode }) =>
+ children,
+}));
+vi.mock("@/lib/data/markets", () => ({ getMarkets: mocks.getMarkets }));
+vi.mock("@/components/layout/DocumentShell", () => ({
+ DocumentShell: ({ children }: { children: React.ReactNode }) => children,
+}));
+vi.mock("@/components/cart/CartDrawer", () => ({ CartDrawer: () => null }));
+vi.mock("@/components/seo/JsonLd", () => ({ JsonLd: () => null }));
+vi.mock("@/components/ui/sonner", () => ({ Toaster: () => null }));
+vi.mock("@/contexts/AuthContext", () => ({
+ AuthProvider: ({ children }: { children: React.ReactNode }) => children,
+}));
+vi.mock("@/contexts/CartContext", () => ({
+ CartProvider: ({ children }: { children: React.ReactNode }) => children,
+}));
+vi.mock("@/contexts/StoreContext", () => ({
+ StoreProvider: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+import CountryLocaleLayout from "./layout";
+
+function country(iso: string): Country {
+ return {
+ iso,
+ iso3: iso,
+ name: iso,
+ states_required: false,
+ zipcode_required: false,
+ } as Country;
+}
+
+function market(overrides: Partial = {}): Market {
+ return {
+ id: "market-1",
+ name: "Market",
+ currency: "USD",
+ default_locale: "en",
+ tax_inclusive: false,
+ default: true,
+ country_isos: ["US"],
+ supported_locales: ["en"],
+ countries: [country("US")],
+ ...overrides,
+ } as Market;
+}
+
+describe("CountryLocaleLayout Market fallback", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("redirects to the Market locale without dropping path or query", async () => {
+ mocks.getMarkets.mockResolvedValue({
+ data: [
+ market({
+ default_locale: "es",
+ supported_locales: ["es"],
+ country_isos: ["AR"],
+ countries: [country("AR")],
+ }),
+ ],
+ });
+ mocks.headers.mockResolvedValue(
+ new Headers({
+ [REQUEST_PATHNAME_HEADER]: "/ar/en/products/coffee",
+ [REQUEST_SEARCH_HEADER]: "?sort=price",
+ }),
+ );
+
+ await expect(
+ CountryLocaleLayout({
+ children: ,
+ params: Promise.resolve({ country: "ar", locale: "en" }),
+ }),
+ ).rejects.toThrow("redirect:/ar/es/products/coffee?sort=price");
+ expect(mocks.redirect).toHaveBeenCalledWith(
+ "/ar/es/products/coffee?sort=price",
+ );
+ });
+
+ it("redirects an unknown country to the default Market and keeps the page", async () => {
+ mocks.getMarkets.mockResolvedValue({ data: [market()] });
+ mocks.headers.mockResolvedValue(
+ new Headers({
+ [REQUEST_PATHNAME_HEADER]: "/zz/en/products/coffee",
+ }),
+ );
+
+ await expect(
+ CountryLocaleLayout({
+ children: ,
+ params: Promise.resolve({ country: "zz", locale: "en" }),
+ }),
+ ).rejects.toThrow("redirect:/us/en/products/coffee");
+ });
+});
diff --git a/src/app/[country]/[locale]/layout.tsx b/src/app/[country]/[locale]/layout.tsx
index 0040345a..05e6b142 100644
--- a/src/app/[country]/[locale]/layout.tsx
+++ b/src/app/[country]/[locale]/layout.tsx
@@ -1,29 +1,35 @@
import type { Metadata } from "next";
-import { redirect } from "next/navigation";
+import { headers } from "next/headers";
+import { notFound, redirect } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
+import "../../globals.css";
import { CartDrawer } from "@/components/cart/CartDrawer";
+import { DocumentShell } from "@/components/layout/DocumentShell";
import { JsonLd } from "@/components/seo/JsonLd";
import { Toaster } from "@/components/ui/sonner";
import { AuthProvider } from "@/contexts/AuthContext";
import { CartProvider } from "@/contexts/CartContext";
import { StoreProvider } from "@/contexts/StoreContext";
+import {
+ DEFAULT_LOCALE,
+ loadMessages,
+ resolveSupportedLocale,
+} from "@/i18n/locales";
+import {
+ findMarketForCountry,
+ getDefaultMarketLocaleTarget,
+ getMarketDefaultLocale,
+ isLocaleEnabledForMarket,
+} from "@/i18n/markets";
+import {
+ buildLocalizedRedirectPath,
+ REQUEST_PATHNAME_HEADER,
+ REQUEST_SEARCH_HEADER,
+} from "@/i18n/routing";
import { getMarkets } from "@/lib/data/markets";
import { generateStoreMetadata } from "@/lib/metadata/store";
import { buildOrganizationJsonLd } from "@/lib/seo";
import { getDefaultCountry, getDefaultLocale } from "@/lib/store";
-import deMessages from "../../../../messages/de.json";
-import enMessages from "../../../../messages/en.json";
-import esMessages from "../../../../messages/es.json";
-import frMessages from "../../../../messages/fr.json";
-import plMessages from "../../../../messages/pl.json";
-
-const messagesMap: Record = {
- en: enMessages,
- de: deMessages,
- es: esMessages,
- fr: frMessages,
- pl: plMessages,
-};
interface CountryLocaleLayoutProps {
children: React.ReactNode;
@@ -33,6 +39,35 @@ interface CountryLocaleLayoutProps {
}>;
}
+async function redirectToLocalizedRoute(
+ country: string,
+ locale: string,
+): Promise {
+ const requestHeaders = await headers();
+ redirect(
+ buildLocalizedRedirectPath({
+ country,
+ locale,
+ pathname: requestHeaders.get(REQUEST_PATHNAME_HEADER),
+ search: requestHeaders.get(REQUEST_SEARCH_HEADER),
+ }),
+ );
+}
+
+/**
+ * Root layouts with dynamic segments must provide their required build-time
+ * params. Prebuild only the configured default route so every nested page is
+ * not multiplied by the full Market list. Other valid routes render on demand.
+ */
+export function generateStaticParams() {
+ return [
+ {
+ country: getDefaultCountry(),
+ locale: resolveSupportedLocale(getDefaultLocale()) ?? DEFAULT_LOCALE,
+ },
+ ];
+}
+
export async function generateMetadata({
params,
}: CountryLocaleLayoutProps): Promise {
@@ -46,35 +81,88 @@ export default async function CountryLocaleLayout({
}: CountryLocaleLayoutProps) {
const { country, locale } = await params;
- const markets = await getMarkets({ country, locale })
+ const requestedLocale = resolveSupportedLocale(locale);
+ if (!requestedLocale) notFound();
+
+ const markets = await getMarkets({ country, locale: requestedLocale })
.then((res) => res.data)
- .catch(() => []);
+ .catch(() => null);
+
+ // Let route-level data handling surface an API outage instead of redirecting
+ // the request back to itself. Static storefront locale validation still runs.
+ if (markets === null) {
+ const messages = await loadMessages(requestedLocale);
+ return (
+
+
+ {children}
+
+
+ );
+ }
// Validate that the URL country belongs to an available market.
// If not, redirect server-side to avoid SSR with wrong prices.
- const isValidCountry = markets.some((market) =>
- market.countries?.some(
- (c) => c.iso.toLowerCase() === country.toLowerCase(),
- ),
- );
+ const currentMarket = findMarketForCountry(markets, country);
+
+ if (!currentMarket) {
+ const defaultTarget = getDefaultMarketLocaleTarget(markets);
+ const fallbackCountry = defaultTarget?.country ?? getDefaultCountry();
+ const fallbackLocale =
+ defaultTarget?.locale ??
+ resolveSupportedLocale(getDefaultLocale()) ??
+ DEFAULT_LOCALE;
- if (!isValidCountry) {
- const defaultMarket = markets.find((m) => m.default) ?? markets[0];
- const fallbackCountry =
- defaultMarket?.countries?.[0]?.iso.toLowerCase() ?? getDefaultCountry();
- const fallbackLocale = defaultMarket?.default_locale ?? getDefaultLocale();
+ return redirectToLocalizedRoute(fallbackCountry, fallbackLocale);
+ }
- redirect(`/${fallbackCountry}/${fallbackLocale}`);
+ // A globally available bundle is not necessarily enabled for every Market.
+ // Redirect to a renderable locale instead of letting an automatically
+ // negotiated country/locale combination become a storefront 404.
+ if (!isLocaleEnabledForMarket(currentMarket, requestedLocale)) {
+ const fallbackLocale = getMarketDefaultLocale(currentMarket);
+ if (!fallbackLocale) notFound();
+ return redirectToLocalizedRoute(country, fallbackLocale);
}
- // Load messages statically (no runtime data access) to avoid blocking prerender
- const messages = messagesMap[locale] || messagesMap.en;
+ const messages = await loadMessages(requestedLocale);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+interface CountryLocaleProvidersProps {
+ children: React.ReactNode;
+ country: string;
+ locale: Locale;
+ markets: Awaited>["data"];
+ messages: IntlMessages;
+}
+function CountryLocaleProviders({
+ children,
+ country,
+ locale,
+ markets,
+ messages,
+}: CountryLocaleProvidersProps) {
return (
-
+
{children};
+}
diff --git a/src/app/robots.ts b/src/app/robots.ts
index 9f5ec347..327e3675 100644
--- a/src/app/robots.ts
+++ b/src/app/robots.ts
@@ -2,6 +2,10 @@ import type { MetadataRoute } from "next";
import { getStoreUrl } from "@/lib/store";
import { generateSitemaps } from "./sitemap";
+// Keep the sitemap index in sync with catalog growth instead of freezing the
+// chunk list at deployment time.
+export const dynamic = "force-dynamic";
+
export default async function robots(): Promise {
const baseUrl = (getStoreUrl() || "").replace(/\/$/, "") || undefined;
const sitemaps = await generateSitemaps();
diff --git a/src/app/sitemap.test.ts b/src/app/sitemap.test.ts
new file mode 100644
index 00000000..4102fa0b
--- /dev/null
+++ b/src/app/sitemap.test.ts
@@ -0,0 +1,263 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const api = vi.hoisted(() => ({
+ marketsList: vi.fn(),
+ productsList: vi.fn(),
+ categoriesList: vi.fn(),
+}));
+
+vi.mock("@/lib/data/sitemap", () => ({
+ getSitemapMarkets: async (options: { country: string; locale: string }) =>
+ (await api.marketsList(options)).data,
+ getSitemapResourceCount: async (
+ resource: "products" | "categories",
+ _marketId: string,
+ options: { country: string; locale: string },
+ ) => {
+ const response = await (resource === "products"
+ ? api.productsList({ page: 1, limit: 1 }, options)
+ : api.categoriesList(
+ { page: 1, limit: 1, parent_id_not_null: true },
+ options,
+ ));
+ return response.meta.count;
+ },
+ getSitemapProductPage: async (
+ _marketId: string,
+ page: number,
+ limit: number,
+ options: { country: string; locale: string },
+ ) =>
+ (await api.productsList({ page, limit, expand: ["media"] }, options)).data,
+ getSitemapCategoryPage: async (
+ _marketId: string,
+ page: number,
+ limit: number,
+ options: { country: string; locale: string },
+ ) =>
+ (
+ await api.categoriesList(
+ { page, limit, parent_id_not_null: true },
+ options,
+ )
+ ).data,
+}));
+
+vi.mock("@/lib/store", () => ({
+ getDefaultCountry: () => "us",
+ getDefaultLocale: () => "en",
+ getStoreUrl: () => "https://store.example",
+}));
+
+function marketCountries(...isos: string[]) {
+ return [
+ {
+ id: "market-1",
+ default: true,
+ default_locale: "en",
+ supported_locales: ["en"],
+ countries: isos.map((iso) => ({ iso })),
+ },
+ ];
+}
+
+function mockCatalog(productCount: number, categoryCount: number): void {
+ api.productsList.mockImplementation(
+ async ({ page, limit }: { page: number; limit: number }) => {
+ if (limit === 1) {
+ return { data: [], meta: { count: productCount } };
+ }
+
+ return {
+ data: Array.from({ length: limit }, (_, index) => ({
+ id: `product-${(page - 1) * limit + index}`,
+ slug: `p-${(page - 1) * limit + index}`,
+ })),
+ meta: { count: productCount },
+ };
+ },
+ );
+ api.categoriesList.mockImplementation(
+ async ({ page, limit }: { page: number; limit: number }) => {
+ if (limit === 1) {
+ return { data: [], meta: { count: categoryCount } };
+ }
+
+ return {
+ data: Array.from({ length: limit }, (_, index) => ({
+ id: `category-${(page - 1) * limit + index}`,
+ permalink: `c-${(page - 1) * limit + index}`,
+ is_root: false,
+ })),
+ meta: { count: categoryCount },
+ };
+ },
+ );
+}
+
+describe("localized sitemap generation", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ });
+
+ it("reuses translated catalog pages across countries with the same locale", async () => {
+ api.marketsList.mockResolvedValue({ data: marketCountries("US", "CA") });
+ mockCatalog(2, 1);
+ const { default: sitemap } = await import("@/app/sitemap");
+
+ const entries = await sitemap({ id: Promise.resolve("0") });
+
+ expect(entries).toHaveLength(12);
+ expect(entries.map((entry) => entry.url)).toEqual(
+ expect.arrayContaining([
+ "https://store.example/us/en/products/p-0",
+ "https://store.example/ca/en/products/p-0",
+ "https://store.example/us/en/c/c-0",
+ "https://store.example/ca/en/c/c-0",
+ ]),
+ );
+ expect(
+ api.productsList.mock.calls.filter(([params]) => params.limit === 100),
+ ).toHaveLength(1);
+ expect(
+ api.categoriesList.mock.calls.filter(([params]) => params.limit === 100),
+ ).toHaveLength(1);
+ });
+
+ it("calculates sitemap files from locale counts without loading catalog pages", async () => {
+ api.marketsList.mockResolvedValue({ data: marketCountries("US", "CA") });
+ mockCatalog(30_000, 0);
+ const { generateSitemaps } = await import("@/app/sitemap");
+
+ await expect(generateSitemaps()).resolves.toEqual(
+ Array.from({ length: 7 }, (_, id) => ({ id })),
+ );
+ expect(
+ api.productsList.mock.calls.filter(([params]) => params.limit === 100),
+ ).toHaveLength(0);
+ expect(
+ api.categoriesList.mock.calls.filter(([params]) => params.limit === 100),
+ ).toHaveLength(0);
+ });
+
+ it("loads only API pages intersecting the requested sitemap chunk", async () => {
+ api.marketsList.mockResolvedValue({ data: marketCountries("US", "CA") });
+ mockCatalog(30_000, 0);
+ const { default: sitemap } = await import("@/app/sitemap");
+
+ const entries = await sitemap({ id: Promise.resolve("1") });
+ const loadedPages = api.productsList.mock.calls
+ .filter(([params]) => params.limit === 100)
+ .map(([params]) => params.page);
+
+ expect(entries).toHaveLength(10_000);
+ expect(entries[0]?.url).toBe("https://store.example/us/en/products/p-9997");
+ expect(entries.at(-1)?.url).toBe(
+ "https://store.example/us/en/products/p-19996",
+ );
+ expect(Math.min(...loadedPages)).toBe(100);
+ expect(Math.max(...loadedPages)).toBe(200);
+ expect(loadedPages).toHaveLength(101);
+ });
+
+ it("keeps inventories isolated when Markets share the same locale", async () => {
+ api.marketsList.mockResolvedValue({
+ data: [
+ ...marketCountries("US"),
+ {
+ ...marketCountries("AS")[0],
+ id: "market-2",
+ default: false,
+ },
+ ],
+ });
+ api.productsList.mockImplementation(
+ async (
+ { page, limit }: { page: number; limit: number },
+ { country }: { country: string },
+ ) => {
+ const count = country === "us" ? 2 : 0;
+ return {
+ data:
+ limit === 1
+ ? []
+ : Array.from({ length: count }, (_, index) => ({
+ id: `product-${page}-${index}`,
+ slug: `us-product-${index}`,
+ })),
+ meta: { count },
+ };
+ },
+ );
+ api.categoriesList.mockImplementation(async () => ({
+ data: [],
+ meta: { count: 0 },
+ }));
+ const { default: sitemap } = await import("@/app/sitemap");
+
+ const entries = await sitemap({ id: Promise.resolve("0") });
+ const urls = entries.map((entry) => entry.url);
+
+ expect(urls).toContain("https://store.example/us/en/products/us-product-0");
+ expect(urls).not.toContain(
+ "https://store.example/as/en/products/us-product-0",
+ );
+ expect(entries).toHaveLength(8);
+ expect(
+ api.productsList.mock.calls.filter(([params]) => params.limit === 1),
+ ).toHaveLength(2);
+ });
+
+ it("does not retain process-level catalog pages across sitemap requests", async () => {
+ api.marketsList.mockResolvedValue({ data: marketCountries("US") });
+ mockCatalog(1, 0);
+ const { default: sitemap } = await import("@/app/sitemap");
+
+ await sitemap({ id: Promise.resolve("0") });
+ await sitemap({ id: Promise.resolve("0") });
+
+ expect(
+ api.productsList.mock.calls.filter(([params]) => params.limit === 100),
+ ).toHaveLength(2);
+ });
+
+ it("keeps categories when a product page request fails", async () => {
+ api.marketsList.mockResolvedValue({ data: marketCountries("US") });
+ api.productsList.mockImplementation(
+ async ({ limit }: { limit: number }) => {
+ if (limit === 1) return { data: [], meta: { count: 1 } };
+ throw new Error("products unavailable");
+ },
+ );
+ api.categoriesList.mockImplementation(
+ async ({ limit }: { limit: number }) => ({
+ data:
+ limit === 1
+ ? []
+ : [{ id: "category-1", permalink: "coffee", is_root: false }],
+ meta: { count: 1 },
+ }),
+ );
+ const { default: sitemap } = await import("@/app/sitemap");
+
+ const entries = await sitemap({ id: Promise.resolve("0") });
+
+ expect(entries.map((entry) => entry.url)).toContain(
+ "https://store.example/us/en/c/coffee",
+ );
+ expect(entries.some((entry) => entry.url.includes("/products/p-"))).toBe(
+ false,
+ );
+ });
+
+ it("rejects invalid sitemap chunk identifiers before loading data", async () => {
+ const { default: sitemap } = await import("@/app/sitemap");
+
+ await expect(sitemap({ id: Promise.resolve("-1") })).resolves.toEqual([]);
+ await expect(sitemap({ id: Promise.resolve("invalid") })).resolves.toEqual(
+ [],
+ );
+ expect(api.marketsList).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index 5fad5159..1d9b0bf8 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -1,42 +1,55 @@
-import type { Category, Media, Product } from "@spree/sdk";
-import { getClient } from "@/lib/spree";
-import { getDefaultCountry, getDefaultLocale, getStoreUrl } from "@/lib/store";
-
-type ProductWithMedia = Product & {
- media?: Media[];
- updated_at?: string;
-};
-
-type CategoryWithTimestamp = Category & {
- updated_at?: string;
-};
-
import type { MetadataRoute } from "next";
+import { DEFAULT_LOCALE, resolveSupportedLocale } from "@/i18n/locales";
+import {
+ getMarketLocaleTargets,
+ type MarketLocaleTarget,
+} from "@/i18n/markets";
+import {
+ getSitemapCategoryPage,
+ getSitemapMarkets,
+ getSitemapProductPage,
+ getSitemapResourceCount,
+ type SitemapCategory,
+ type SitemapProduct,
+} from "@/lib/data/sitemap";
+import { getDefaultCountry, getDefaultLocale, getStoreUrl } from "@/lib/store";
export const dynamic = "force-dynamic";
-interface CountryLocale {
- country: string;
- locale: string;
-}
+type CountryLocale = MarketLocaleTarget;
interface LocaleOptions {
locale: string;
country: string;
}
-/** Google's limit is 50,000 URLs per sitemap file. */
-const URLS_PER_SITEMAP = 50_000;
+interface LocaleCatalog extends LocaleOptions {
+ marketId: string;
+ productCount: number;
+ categoryCount: number;
+}
+
+interface PageCaches {
+ products: Map>;
+ categories: Map>;
+}
+
+/**
+ * Google permits 50,000 URLs, but 10,000 keeps each request below roughly 100
+ * Store API pages and avoids long-running sitemap responses on large catalogs.
+ */
+const URLS_PER_SITEMAP = 10_000;
const STATIC_PAGES_PER_LOCALE = 3;
const ITEMS_PER_PAGE = 100;
const MAX_PAGES = 1000;
+const MAX_CONCURRENT_PAGE_REQUESTS = 8;
+const MAX_CONCURRENT_CATALOG_REQUESTS = 4;
/** Maximum items we can actually fetch, given pagination limits. */
const MAX_FETCHABLE_ITEMS = ITEMS_PER_PAGE * MAX_PAGES;
/**
- * Default locale options for build-time API calls.
- * During build (generateSitemaps / sitemap), cookies() is not available,
- * so we pass explicit locale options to bypass the cookie-based resolution.
+ * Default locale options for build-time API calls. During build, cookies() is
+ * not available, so sitemap requests always pass explicit locale options.
*/
function getDefaultLocaleOptions(): LocaleOptions {
return {
@@ -45,97 +58,62 @@ function getDefaultLocaleOptions(): LocaleOptions {
};
}
-/**
- * Module-level caches so that multiple sitemap({id}) calls during the same
- * `next build` process reuse already-fetched data instead of hitting the
- * API O(chunks) times.
- *
- * Products and categories are cached per locale:country because Spree
- * returns locale-dependent slugs/permalinks.
- */
-const cachedProductsByLocale = new Map>();
-const cachedCategoriesByLocale = new Map<
- string,
- Promise
->();
-let cachedCountryLocales: Promise | null = null;
-
-function localeCacheKey(locale: string, country: string): string {
- return `${locale}:${country}`;
-}
+async function resolveLocaleCatalogs(
+ countryLocales: CountryLocale[],
+): Promise