{
- const { slug, locale } = await params;
+ const { country, slug, locale } = await params;
const [policy, t] = await Promise.all([
- getPolicy(slug),
+ getPolicy(slug, { country, locale }),
getTranslations({ locale: locale as Locale, namespace: "policies" }),
]);
@@ -72,3 +106,17 @@ export default async function PolicyPage({
);
}
+
+function policyTranslationFingerprint(policy: {
+ name: string;
+ slug: string;
+ body: string | null;
+ body_html: string | null;
+}): string {
+ return translationFingerprint(
+ policy.name,
+ policy.slug,
+ policy.body,
+ policy.body_html,
+ );
+}
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 fd1abb99..a08da778 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)
@@ -249,12 +251,10 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) {
{t("details")}
- {selectedVariant?.sku && (
+ {sku && (
- {t("sku")}
- -
- {selectedVariant.sku}
-
+ - {sku}
)}
{selectedVariant?.options_text && (
diff --git a/src/app/[country]/[locale]/layout.test.tsx b/src/app/[country]/[locale]/layout.test.tsx
new file mode 100644
index 00000000..615a1c56
--- /dev/null
+++ b/src/app/[country]/[locale]/layout.test.tsx
@@ -0,0 +1,196 @@
+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("@/lib/store", () => ({
+ getDefaultCountry: () => "us",
+ getDefaultLocale: () => "en",
+}));
+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 { CountryLocaleLayoutContent } 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(
+ CountryLocaleLayoutContent({
+ 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",
+ );
+ expect(mocks.getMarkets).toHaveBeenCalledWith({
+ country: "us",
+ locale: "en",
+ });
+ });
+
+ it("redirects a storefront-supported locale that is unavailable in the Market", async () => {
+ mocks.getMarkets.mockResolvedValue({
+ data: [
+ market(),
+ market({
+ id: "market-eu",
+ name: "Europe",
+ currency: "EUR",
+ default: false,
+ default_locale: "de",
+ supported_locales: ["de", "es", "fr"],
+ country_isos: ["PL"],
+ countries: [country("PL")],
+ }),
+ ],
+ });
+ mocks.headers.mockResolvedValue(
+ new Headers({
+ [REQUEST_PATHNAME_HEADER]: "/pl/pl",
+ }),
+ );
+
+ await expect(
+ CountryLocaleLayoutContent({
+ children: ,
+ params: Promise.resolve({ country: "pl", locale: "pl" }),
+ }),
+ ).rejects.toThrow("redirect:/pl/de");
+ expect(mocks.redirect).toHaveBeenCalledWith("/pl/de");
+ expect(mocks.getMarkets).toHaveBeenCalledWith({
+ country: "us",
+ locale: "en",
+ });
+ });
+
+ 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(
+ CountryLocaleLayoutContent({
+ children: ,
+ params: Promise.resolve({ country: "zz", locale: "en" }),
+ }),
+ ).rejects.toThrow("redirect:/us/en/products/coffee");
+ });
+
+ it.each([
+ ["an empty Markets response", []],
+ [
+ "a Market without countries",
+ [market({ countries: [], country_isos: [] })],
+ ],
+ ])("does not redirect the default route to itself for %s", async (_, markets) => {
+ mocks.getMarkets.mockResolvedValue({ data: markets });
+
+ await expect(
+ CountryLocaleLayoutContent({
+ children: ,
+ params: Promise.resolve({ country: "us", locale: "en" }),
+ }),
+ ).resolves.toBeDefined();
+ expect(mocks.redirect).not.toHaveBeenCalled();
+ });
+
+ it("redirects a non-default route once when no Market target is available", async () => {
+ mocks.getMarkets.mockResolvedValue({ data: [] });
+ mocks.headers.mockResolvedValue(
+ new Headers({
+ [REQUEST_PATHNAME_HEADER]: "/zz/en/products/coffee",
+ [REQUEST_SEARCH_HEADER]: "?sort=price",
+ }),
+ );
+
+ await expect(
+ CountryLocaleLayoutContent({
+ children: ,
+ params: Promise.resolve({ country: "zz", locale: "en" }),
+ }),
+ ).rejects.toThrow("redirect:/us/en/products/coffee?sort=price");
+ });
+});
diff --git a/src/app/[country]/[locale]/layout.tsx b/src/app/[country]/[locale]/layout.tsx
index 0040345a..faf27431 100644
--- a/src/app/[country]/[locale]/layout.tsx
+++ b/src/app/[country]/[locale]/layout.tsx
@@ -1,29 +1,36 @@
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 { Suspense } from "react";
+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 +40,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 {
@@ -40,41 +76,123 @@ export async function generateMetadata({
return generateStoreMetadata({ locale });
}
-export default async function CountryLocaleLayout({
+/**
+ * Market resolution can read request headers when it needs to preserve the
+ * current path during a fallback redirect. Keep the whole route decision
+ * behind one boundary so Cache Components never prerender an unvalidated
+ * Market context or treat that request data as a blocking route error.
+ */
+export default function CountryLocaleLayout(props: CountryLocaleLayoutProps) {
+ return (
+
+
+
+ );
+}
+
+export async function CountryLocaleLayoutContent({
children,
params,
}: CountryLocaleLayoutProps) {
const { country, locale } = await params;
- const markets = await getMarkets({ country, locale })
+ const requestedLocale = resolveSupportedLocale(locale);
+ if (!requestedLocale) notFound();
+
+ // Fetch Market configuration through a known-valid storefront context. The
+ // requested country/locale pair has not been validated yet; forwarding it to
+ // the Store API can fail before we get the Market data needed to redirect an
+ // unsupported pair (for example /pl/pl when Poland's Market supports de).
+ const marketLookupLocale =
+ resolveSupportedLocale(getDefaultLocale()) ?? DEFAULT_LOCALE;
+ const markets = await getMarkets({
+ country: getDefaultCountry(),
+ locale: marketLookupLocale,
+ })
.then((res) => res.data)
- .catch(() => []);
+ .catch(() => null);
+
+ const renderStorefront = async (
+ availableMarkets: NonNullable,
+ ) => {
+ const messages = await loadMessages(requestedLocale);
+
+ return (
+
+
+ {children}
+
+
+ );
+ };
+
+ // 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) {
+ return renderStorefront([]);
+ }
// 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 (!isValidCountry) {
- const defaultMarket = markets.find((m) => m.default) ?? markets[0];
- const fallbackCountry =
- defaultMarket?.countries?.[0]?.iso.toLowerCase() ?? getDefaultCountry();
- const fallbackLocale = defaultMarket?.default_locale ?? getDefaultLocale();
+ if (!currentMarket) {
+ const defaultTarget = getDefaultMarketLocaleTarget(markets);
+ const fallbackCountry = (
+ defaultTarget?.country ?? getDefaultCountry()
+ ).toLowerCase();
+ const fallbackLocale =
+ defaultTarget?.locale ??
+ resolveSupportedLocale(getDefaultLocale()) ??
+ DEFAULT_LOCALE;
- redirect(`/${fallbackCountry}/${fallbackLocale}`);
+ // A successful but unusable Markets response may only resolve to the route
+ // already being handled. Render through the outage-safe path instead of
+ // creating an infinite redirect loop.
+ if (
+ fallbackCountry === country.toLowerCase() &&
+ fallbackLocale === requestedLocale
+ ) {
+ return renderStorefront([]);
+ }
+
+ return redirectToLocalizedRoute(fallbackCountry, fallbackLocale);
}
- // Load messages statically (no runtime data access) to avoid blocking prerender
- const messages = messagesMap[locale] || messagesMap.en;
+ // 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);
+ }
+ return renderStorefront(markets);
+}
+
+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