diff --git a/src/app/[country]/[locale]/(storefront)/layout.test.tsx b/src/app/[country]/[locale]/(storefront)/layout.test.tsx new file mode 100644 index 00000000..d26b90e1 --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/layout.test.tsx @@ -0,0 +1,61 @@ +import { + Children, + Fragment, + type ReactElement, + type ReactNode, + Suspense, +} from "react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("next/server", () => ({ connection: vi.fn() })); +vi.mock("@/lib/data/categories", () => ({ getCategories: vi.fn() })); +vi.mock("@/components/layout/Header", () => ({ + Header: () => null, + HeaderMobileMenu: () => null, +})); +vi.mock("@/components/layout/Footer", () => ({ + Footer: () => null, + FooterCategoryLinks: () => null, +})); + +import { Footer } from "@/components/layout/Footer"; +import { Header } from "@/components/layout/Header"; +import StorefrontLayout from "./layout"; + +interface LayoutElementProps { + children?: ReactNode; + mobileNavigation?: ReactElement<{ fallback: ReactNode }>; + categoryLinks?: ReactElement<{ fallback: ReactNode }>; + fallback?: ReactNode; +} + +describe("StorefrontLayout", () => { + it("keeps page chrome outside the category navigation Suspense boundaries", async () => { + const content =
Storefront content
; + const layout = (await StorefrontLayout({ + children: content, + params: Promise.resolve({ country: "us", locale: "en" }), + })) as ReactElement; + + expect(layout.type).toBe(Fragment); + + const [header, hiddenNavigation, main, footer] = Children.toArray( + layout.props.children, + ) as ReactElement[]; + + expect(header.type).toBe(Header); + expect(hiddenNavigation.type).toBe(Suspense); + expect(main.type).toBe("main"); + expect(main.props.children).toBe(content); + expect(footer.type).toBe(Footer); + + const mobileNavigation = header.props.mobileNavigation; + const categoryLinks = footer.props.categoryLinks; + + expect(mobileNavigation?.type).toBe(Suspense); + expect(mobileNavigation?.props.fallback).not.toBeNull(); + expect(hiddenNavigation.props.fallback).toBeNull(); + expect(categoryLinks?.type).toBe(Suspense); + expect(categoryLinks?.props.fallback).not.toBeNull(); + }); +}); diff --git a/src/app/[country]/[locale]/(storefront)/layout.tsx b/src/app/[country]/[locale]/(storefront)/layout.tsx index 1ae28ecd..2b3be4af 100644 --- a/src/app/[country]/[locale]/(storefront)/layout.tsx +++ b/src/app/[country]/[locale]/(storefront)/layout.tsx @@ -1,7 +1,9 @@ import type { Category } from "@spree/sdk"; import Link from "next/link"; -import { Footer } from "@/components/layout/Footer"; -import { Header } from "@/components/layout/Header"; +import { connection } from "next/server"; +import { cache, Suspense } from "react"; +import { Footer, FooterCategoryLinks } from "@/components/layout/Footer"; +import { Header, HeaderMobileMenu } from "@/components/layout/Header"; import { getCategories } from "@/lib/data/categories"; interface StorefrontLayoutProps { @@ -9,6 +11,55 @@ interface StorefrontLayoutProps { params: Promise<{ country: string; locale: string }>; } +interface StorefrontNavigationProps { + basePath: string; + country: string; + locale: string; +} + +const EMPTY_CATEGORIES: Category[] = []; + +function MobileNavigationFallback() { + return ( + ); } + +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> { + const uniqueTargets = new Map(); -function getCachedProducts( - localeOpts: LocaleOptions, -): Promise { - const key = localeCacheKey(localeOpts.locale, localeOpts.country); - let cached = cachedProductsByLocale.get(key); - if (!cached) { - cached = fetchAllProducts(localeOpts).catch((err) => { - cachedProductsByLocale.delete(key); - throw err; - }); - cachedProductsByLocale.set(key, cached); + for (const target of countryLocales) { + const key = catalogKey(target); + if (!uniqueTargets.has(key)) uniqueTargets.set(key, target); } - return cached; -} -function getCachedCategories( - localeOpts: LocaleOptions, -): Promise { - const key = localeCacheKey(localeOpts.locale, localeOpts.country); - let cached = cachedCategoriesByLocale.get(key); - if (!cached) { - cached = fetchAllCategories(localeOpts).catch((err) => { - cachedCategoriesByLocale.delete(key); - throw err; - }); - cachedCategoriesByLocale.set(key, cached); + const targets = Array.from(uniqueTargets.entries()); + const entries: Array = []; + for ( + let offset = 0; + offset < targets.length; + offset += MAX_CONCURRENT_CATALOG_REQUESTS + ) { + const batch = targets.slice( + offset, + offset + MAX_CONCURRENT_CATALOG_REQUESTS, + ); + entries.push( + ...(await Promise.all( + batch.map( + async ([key, target]) => + [key, await buildLocaleCatalog(target)] as const, + ), + )), + ); } - return cached; + return new Map(entries); } -function getCachedCountryLocales(): Promise { - if (!cachedCountryLocales) { - cachedCountryLocales = resolveCountryLocales().catch((err) => { - cachedCountryLocales = null; - throw err; - }); - } - return cachedCountryLocales; +function catalogKey(target: { marketId: string; locale: string }): string { + return `${target.marketId}:${target.locale.toLowerCase()}`; } /** - * Splits the sitemap into multiple files when the total URL count - * exceeds 50,000 (Google's per-sitemap limit). - * - * Next.js generates /sitemap/0.xml, /sitemap/1.xml, etc. - * robots.ts references all chunks via generateSitemaps(). - * - * @see https://nextjs.org/docs/app/api-reference/functions/generate-sitemaps + * Splits the sitemap into bounded files. Counts are fetched once per + * Market/locale pair: countries in one Market share an inventory, while two + * Markets using the same locale may expose entirely different catalogs. */ export async function generateSitemaps(): Promise> { try { - const countryLocales = await getCachedCountryLocales(); - - // Lightweight count — fetch only 1 record per request to read meta.count. - // Category count is approximate (includes root categories filtered out during generation), - // so we may produce one extra sitemap file at most — harmless for SEO. - const [productCount, categoryCount] = await Promise.all([ - fetchTotalCount("products"), - fetchTotalCount("categories"), - ]); - - const urlsPerLocale = - STATIC_PAGES_PER_LOCALE + - Math.min(productCount, MAX_FETCHABLE_ITEMS) + - Math.min(categoryCount, MAX_FETCHABLE_ITEMS); - const totalUrls = urlsPerLocale * countryLocales.length; + const countryLocales = await resolveCountryLocales(); + const catalogs = await resolveLocaleCatalogs(countryLocales); + const totalUrls = countryLocales.reduce((total, target) => { + const catalog = catalogs.get(catalogKey(target)); + return total + (catalog ? catalogSize(catalog) : 0); + }, 0); const sitemapCount = Math.max(1, Math.ceil(totalUrls / URLS_PER_SITEMAP)); - return Array.from({ length: sitemapCount }, (_, i) => ({ id: i })); + return Array.from({ length: sitemapCount }, (_, id) => ({ id })); } catch { - // API may be unavailable at build time — return a single sitemap chunk - // that will be populated at request time. + // API may be unavailable at build time. Return a single sitemap chunk that + // will be populated on demand when the API recovers. return [{ id: 0 }]; } } @@ -144,197 +122,326 @@ export default async function sitemap(props: { id: Promise; }): Promise { const id = Number(await props.id); + if (!Number.isSafeInteger(id) || id < 0) return []; - const candidate = (getStoreUrl() || "").replace(/\/$/, ""); - - let baseUrl: string; - try { - const parsed = new URL(candidate); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error(`Unsupported protocol: ${parsed.protocol}`); - } - baseUrl = parsed.origin + parsed.pathname.replace(/\/$/, ""); - } catch { - console.error( - "Sitemap generation skipped: neither NEXT_PUBLIC_SITE_URL nor " + - "NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL is set or valid. " + - "Sitemaps require absolute http(s) URLs.", - ); - return []; - } + const baseUrl = resolveBaseUrl(); + if (!baseUrl) return []; let countryLocales: CountryLocale[]; + let catalogs: Map; try { - countryLocales = await getCachedCountryLocales(); - } catch (err) { - console.error("Sitemap generation failed: API unavailable.", err); + countryLocales = await resolveCountryLocales(); + catalogs = await resolveLocaleCatalogs(countryLocales); + } catch (error) { + console.error("Sitemap generation failed: API unavailable.", error); return []; } - // Build entries for all locales, then slice to the requested chunk. - // For most stores (< 50k URLs) this produces a single chunk so no slicing occurs. + const chunkStart = id * URLS_PER_SITEMAP; + const chunkEnd = chunkStart + URLS_PER_SITEMAP; const entries: MetadataRoute.Sitemap = []; + const pageCaches: PageCaches = { + products: new Map(), + categories: new Map(), + }; + let targetStart = 0; - for (const { country, locale } of countryLocales) { - const basePath = `${baseUrl}/${country}/${locale}`; - const localeOpts: LocaleOptions = { locale, country }; - - let products: ProductWithMedia[]; - let categories: CategoryWithTimestamp[]; + for (const target of countryLocales) { + const catalog = catalogs.get(catalogKey(target)); + if (!catalog) continue; - try { - [products, categories] = await Promise.all([ - getCachedProducts(localeOpts), - getCachedCategories(localeOpts), - ]); - } catch (err) { - console.error(`Sitemap: skipping ${country}/${locale} — API error.`, err); + const targetEnd = targetStart + catalogSize(catalog); + if (targetEnd <= chunkStart) { + targetStart = targetEnd; continue; } + if (targetStart >= chunkEnd) break; - const nonRootCategories = categories.filter((c) => !c.is_root); + const basePath = `${baseUrl}/${target.country}/${target.locale}`; + appendStaticEntries(entries, basePath, targetStart, chunkStart, chunkEnd); - // Static pages — no reliable publish timestamp, omit lastModified - entries.push( - { - url: basePath, - changeFrequency: "daily", - priority: 1, - }, - { - url: `${basePath}/products`, - changeFrequency: "daily", - priority: 0.8, - }, - { - url: `${basePath}/c`, - changeFrequency: "weekly", - priority: 0.7, - }, + const productStart = targetStart + STATIC_PAGES_PER_LOCALE; + const productEnd = productStart + catalog.productCount; + const categoryStart = productEnd; + const categoryEnd = categoryStart + catalog.categoryCount; + + const productRange = intersectRange( + productStart, + productEnd, + chunkStart, + chunkEnd, + ); + const categoryRange = intersectRange( + categoryStart, + categoryEnd, + chunkStart, + chunkEnd, ); - // Product pages with image sitemaps (locale-aware slugs) - for (const product of products) { - entries.push({ - url: `${basePath}/products/${product.slug}`, - ...(product.updated_at - ? { lastModified: new Date(product.updated_at) } - : {}), - changeFrequency: "weekly", - priority: 0.6, - ...(product.media && product.media.length > 0 - ? { - images: product.media - .map((img: Media) => img.original_url || img.large_url) - .filter((url: string | null): url is string => url != null), - } - : {}), - }); + const [products, categories] = await Promise.allSettled([ + productRange + ? fetchProductRange( + catalog, + productRange.start - productStart, + productRange.end - productStart, + pageCaches, + ) + : Promise.resolve([]), + categoryRange + ? fetchCategoryRange( + catalog, + categoryRange.start - categoryStart, + categoryRange.end - categoryStart, + pageCaches, + ) + : Promise.resolve([]), + ]); + + if (products.status === "fulfilled") { + appendProductEntries(entries, basePath, products.value); + } else { + console.error( + `Sitemap: skipping products for ${target.country}/${target.locale}.`, + products.reason, + ); + } + if (categories.status === "fulfilled") { + appendCategoryEntries(entries, basePath, categories.value); + } else { + console.error( + `Sitemap: skipping categories for ${target.country}/${target.locale}.`, + categories.reason, + ); } - // Category pages (locale-aware permalinks) - for (const category of nonRootCategories) { - entries.push({ - url: `${basePath}/c/${category.permalink}`, - ...(category.updated_at - ? { lastModified: new Date(category.updated_at) } - : {}), - changeFrequency: "weekly", - priority: 0.5, - }); + targetStart = targetEnd; + } + + return entries; +} + +function resolveBaseUrl(): string | undefined { + const candidate = (getStoreUrl() || "").replace(/\/$/, ""); + + try { + const parsed = new URL(candidate); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`Unsupported protocol: ${parsed.protocol}`); } + return parsed.origin + parsed.pathname.replace(/\/$/, ""); + } catch { + console.error( + "Sitemap generation skipped: neither NEXT_PUBLIC_SITE_URL nor " + + "NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL is set or valid. " + + "Sitemaps require absolute http(s) URLs.", + ); + return undefined; } +} + +function catalogSize(catalog: LocaleCatalog): number { + return STATIC_PAGES_PER_LOCALE + catalog.productCount + catalog.categoryCount; +} + +function intersectRange( + rangeStart: number, + rangeEnd: number, + chunkStart: number, + chunkEnd: number, +): { start: number; end: number } | undefined { + const start = Math.max(rangeStart, chunkStart); + const end = Math.min(rangeEnd, chunkEnd); + return start < end ? { start, end } : undefined; +} - // Return only the slice for this sitemap chunk - if (id === 0 && entries.length <= URLS_PER_SITEMAP) { - return entries; +function appendStaticEntries( + entries: MetadataRoute.Sitemap, + basePath: string, + targetStart: number, + chunkStart: number, + chunkEnd: number, +): void { + const staticEntries: MetadataRoute.Sitemap = [ + { url: basePath, changeFrequency: "daily", priority: 1 }, + { + url: `${basePath}/products`, + changeFrequency: "daily", + priority: 0.8, + }, + { url: `${basePath}/c`, changeFrequency: "weekly", priority: 0.7 }, + ]; + + for (const [offset, entry] of staticEntries.entries()) { + const position = targetStart + offset; + if (position >= chunkStart && position < chunkEnd) entries.push(entry); } - const start = id * URLS_PER_SITEMAP; - return entries.slice(start, start + URLS_PER_SITEMAP); } -/** - * Resolves the list of country/locale pairs to include in the sitemap - * by fetching all markets from the Spree API. Each market contains its - * countries and default locale, so no env-based configuration is needed. - */ -async function resolveCountryLocales(): Promise { - const localeOptions = getDefaultLocaleOptions(); - const { data: markets } = await getClient().markets.list(localeOptions); - - const seen = new Set(); - const result: CountryLocale[] = []; - - for (const market of markets) { - for (const country of market.countries ?? []) { - const iso = country.iso.toLowerCase(); - if (seen.has(iso)) continue; - seen.add(iso); - result.push({ - country: iso, - locale: market.default_locale || localeOptions.locale, - }); - } +function appendProductEntries( + entries: MetadataRoute.Sitemap, + basePath: string, + products: SitemapProduct[], +): void { + for (const product of products) { + entries.push({ + url: `${basePath}/products/${product.slug}`, + ...(product.updated_at + ? { lastModified: new Date(product.updated_at) } + : {}), + changeFrequency: "weekly", + priority: 0.6, + ...(product.media && product.media.length > 0 + ? { + images: product.media + .map((image) => image.original_url || image.large_url) + .filter((url: string | null): url is string => url != null), + } + : {}), + }); } +} - return result.length > 0 - ? result - : [{ country: localeOptions.country, locale: localeOptions.locale }]; +function appendCategoryEntries( + entries: MetadataRoute.Sitemap, + basePath: string, + categories: SitemapCategory[], +): void { + for (const category of categories) { + if (category.is_root) continue; + entries.push({ + url: `${basePath}/c/${category.permalink}`, + ...(category.updated_at + ? { lastModified: new Date(category.updated_at) } + : {}), + changeFrequency: "weekly", + priority: 0.5, + }); + } } -/** - * Fetches only the total count for products or categories without loading all data. - * Used by generateSitemaps() to calculate the number of sitemap files needed. - */ -async function fetchTotalCount( - resource: "products" | "categories", -): Promise { +/** Resolve every valid country/locale URL exposed by configured Markets. */ +async function resolveCountryLocales(): Promise { const localeOptions = getDefaultLocaleOptions(); - const client = getClient(); - const response = - resource === "products" - ? await client.products.list({ page: 1, limit: 1 }, localeOptions) - : await client.categories.list({ page: 1, limit: 1 }, localeOptions); - return response.meta.count; + const markets = await getSitemapMarkets(localeOptions); + const targets = getMarketLocaleTargets(markets); + + return targets.length > 0 + ? targets + : [ + { + marketId: "default", + country: localeOptions.country, + locale: + resolveSupportedLocale(localeOptions.locale) ?? DEFAULT_LOCALE, + }, + ]; } -async function fetchAllProducts( - localeOptions: LocaleOptions, -): Promise { - const allProducts: ProductWithMedia[] = []; - let page = 1; - let totalPages = 1; - - do { - const response = await getClient().products.list( - { page, limit: ITEMS_PER_PAGE, expand: ["media"] }, - localeOptions, - ); - allProducts.push(...(response.data as ProductWithMedia[])); - totalPages = response.meta.pages; - page++; - } while (page <= totalPages && page <= MAX_PAGES); +async function buildLocaleCatalog( + target: CountryLocale, +): Promise { + const localeOptions = { locale: target.locale, country: target.country }; + const [productCount, categoryCount] = await Promise.all([ + getSitemapResourceCount("products", target.marketId, localeOptions), + getSitemapResourceCount("categories", target.marketId, localeOptions), + ]); - return allProducts; + return { + marketId: target.marketId, + ...localeOptions, + productCount: Math.min(productCount, MAX_FETCHABLE_ITEMS), + categoryCount: Math.min(categoryCount, MAX_FETCHABLE_ITEMS), + }; } -async function fetchAllCategories( - localeOptions: LocaleOptions, -): Promise { - const allCategories: CategoryWithTimestamp[] = []; - let page = 1; - let totalPages = 1; - - do { - const response = await getClient().categories.list( - { page, limit: ITEMS_PER_PAGE }, - localeOptions, - ); - allCategories.push(...response.data); - totalPages = response.meta.pages; - page++; - } while (page <= totalPages && page <= MAX_PAGES); +async function fetchProductRange( + catalog: LocaleCatalog, + start: number, + end: number, + pageCaches: PageCaches, +): Promise { + return fetchItemRange(start, end, (page) => + getCachedProductPage(catalog, page, pageCaches.products), + ); +} - return allCategories; +async function fetchCategoryRange( + catalog: LocaleCatalog, + start: number, + end: number, + pageCaches: PageCaches, +): Promise { + return fetchItemRange(start, end, (page) => + getCachedCategoryPage(catalog, page, pageCaches.categories), + ); +} + +async function fetchItemRange( + start: number, + end: number, + loadPage: (page: number) => Promise, +): Promise { + if (start >= end) return []; + + const firstPage = Math.floor(start / ITEMS_PER_PAGE) + 1; + const lastPage = Math.ceil(end / ITEMS_PER_PAGE); + const pages = Array.from( + { length: lastPage - firstPage + 1 }, + (_, index) => firstPage + index, + ); + const items: T[] = []; + + for ( + let offset = 0; + offset < pages.length; + offset += MAX_CONCURRENT_PAGE_REQUESTS + ) { + const batch = pages.slice(offset, offset + MAX_CONCURRENT_PAGE_REQUESTS); + const results = await Promise.all(batch.map(loadPage)); + for (const result of results) items.push(...result); + } + + const loadedRangeStart = (firstPage - 1) * ITEMS_PER_PAGE; + return items.slice(start - loadedRangeStart, end - loadedRangeStart); +} + +function getCachedProductPage( + catalog: LocaleCatalog, + page: number, + cache: PageCaches["products"], +): Promise { + const key = `${catalogKey(catalog)}:${page}`; + let cached = cache.get(key); + if (!cached) { + cached = getSitemapProductPage(catalog.marketId, page, ITEMS_PER_PAGE, { + locale: catalog.locale, + country: catalog.country, + }).catch((error) => { + cache.delete(key); + throw error; + }); + cache.set(key, cached); + } + return cached; +} + +function getCachedCategoryPage( + catalog: LocaleCatalog, + page: number, + cache: PageCaches["categories"], +): Promise { + const key = `${catalogKey(catalog)}:${page}`; + let cached = cache.get(key); + if (!cached) { + cached = getSitemapCategoryPage(catalog.marketId, page, ITEMS_PER_PAGE, { + locale: catalog.locale, + country: catalog.country, + }).catch((error) => { + cache.delete(key); + throw error; + }); + cache.set(key, cached); + } + return cached; } diff --git a/src/components/layout/DocumentShell.test.tsx b/src/components/layout/DocumentShell.test.tsx new file mode 100644 index 00000000..b08a3e50 --- /dev/null +++ b/src/components/layout/DocumentShell.test.tsx @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; +import { DocumentShell } from "./DocumentShell"; + +vi.mock("next/font/google", () => ({ + Geist: () => ({ variable: "--font-geist" }), +})); + +vi.mock("@next/third-parties/google", () => ({ + GoogleTagManager: () => null, +})); + +vi.mock("@vercel/analytics/next", () => ({ + Analytics: () => null, +})); + +vi.mock("@vercel/speed-insights/next", () => ({ + SpeedInsights: () => null, +})); + +describe("DocumentShell", () => { + it("renders the route locale and tolerates extension root mutations", () => { + const document = DocumentShell({ + children:
Storefront
, + locale: "de", + }); + + expect(document.type).toBe("html"); + expect(document.props.lang).toBe("de"); + expect(document.props.dir).toBe("ltr"); + expect(document.props.suppressHydrationWarning).toBe(true); + }); + + it("sets the document direction for RTL language tags", () => { + const document = DocumentShell({ + children:
Storefront
, + locale: "ar", + }); + + expect(document.props.dir).toBe("rtl"); + }); +}); diff --git a/src/app/layout.tsx b/src/components/layout/DocumentShell.tsx similarity index 72% rename from src/app/layout.tsx rename to src/components/layout/DocumentShell.tsx index 8c84fff8..3fe5db8c 100644 --- a/src/app/layout.tsx +++ b/src/components/layout/DocumentShell.tsx @@ -1,11 +1,9 @@ import { GoogleTagManager } from "@next/third-parties/google"; import { Analytics } from "@vercel/analytics/next"; import { SpeedInsights } from "@vercel/speed-insights/next"; -import type { Metadata } from "next"; import { Geist } from "next/font/google"; -import "./globals.css"; import { Suspense } from "react"; -import { getStoreDescription, getStoreName } from "@/lib/store"; +import { localeDirection } from "@/i18n/locales"; const gtmId = process.env.GTM_ID; const spreeApiOrigin = (() => { @@ -24,23 +22,16 @@ const geist = Geist({ display: "swap", }); -const rootStoreName = getStoreName(); - -export const metadata: Metadata = { - title: { - template: `%s | ${rootStoreName}`, - default: rootStoreName, - }, - description: getStoreDescription(), -}; - -export default function RootLayout({ - children, -}: Readonly<{ +interface DocumentShellProps { children: React.ReactNode; -}>) { + locale: string; +} + +/** Shared document markup for each root layout. */ +export function DocumentShell({ children, locale }: DocumentShellProps) { return ( - + + {/* biome-ignore lint/style/noHeadElement: this shell is used only by Next.js root layouts */} {spreeApiOrigin && ( <> diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index 6b15cf48..3604d8ec 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -1,6 +1,7 @@ import type { Category } from "@spree/sdk"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; +import type { ReactNode } from "react"; import { POLICY_LINKS } from "@/lib/constants/policies"; import { isWholesaleEnabled } from "@/lib/spree"; import { getStoreDescription, getStoreName } from "@/lib/store"; @@ -16,16 +17,33 @@ const quickstartUrl = const learnMoreUrl = "https://spreecommerce.org"; interface FooterProps { - rootCategories: Category[]; basePath: string; locale: Locale; + categoryLinks: ReactNode; } -export async function Footer({ +interface FooterCategoryLinksProps { + rootCategories: Category[]; + basePath: string; +} + +export function FooterCategoryLinks({ rootCategories, basePath, - locale, -}: FooterProps) { +}: FooterCategoryLinksProps) { + return rootCategories.map((category) => ( +
  • + + {category.name} + +
  • + )); +} + +export async function Footer({ basePath, locale, categoryLinks }: FooterProps) { const t = await getTranslations({ locale, namespace: "footer" }); const tp = await getTranslations({ locale, namespace: "policies" }); const wholesaleEnabled = isWholesaleEnabled(); @@ -84,16 +102,7 @@ export async function Footer({ {t("allProducts")} - {rootCategories.map((category) => ( -
  • - - {category.name} - -
  • - ))} + {categoryLinks} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index c7691157..1cb0c403 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -4,6 +4,7 @@ import dynamic from "next/dynamic"; import Image from "next/image"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; +import type { ReactNode } from "react"; import { CartButton } from "@/components/layout/CartButton"; import { SearchToggle } from "@/components/layout/SearchToggle"; import { Button } from "@/components/ui/button"; @@ -35,15 +36,33 @@ const LazyRegionPreferences = dynamic( const storeName = getStoreName(); interface HeaderProps { - rootCategories: Category[]; basePath: string; locale: Locale; + mobileNavigation: ReactNode; } -export async function Header({ +interface HeaderMobileMenuProps { + rootCategories: Category[]; + basePath: string; +} + +export function HeaderMobileMenu({ rootCategories, basePath, +}: HeaderMobileMenuProps) { + return ( + + ); +} + +export async function Header({ + basePath, locale, + mobileNavigation, }: HeaderProps) { const t = await getTranslations({ locale, namespace: "header" }); const wholesaleEnabled = isWholesaleEnabled(); @@ -51,13 +70,7 @@ export async function Header({ return ( - } + left={mobileNavigation} center={ { + it("resolves configured locales case-insensitively", () => { + expect(resolveSupportedLocale("EN")).toBe("en"); + expect(resolveSupportedLocale("it")).toBeUndefined(); + expect(SUPPORTED_LOCALES).toContain(DEFAULT_LOCALE); + }); + + it("canonicalizes BCP 47 and Rails-style locale codes", () => { + expect(canonicalizeLocale("zh_cn")).toBe("zh-CN"); + expect(canonicalizeLocale("sr_latn_rs")).toBe("sr-Latn-RS"); + expect(canonicalizeLocale("not_a_locale_!")).toBeUndefined(); + }); + + it("preserves configured spelling and negotiates a base language", () => { + const supported = ["en", "zh-CN"] as const; + expect(matchLocale("zh-cn", supported)).toBe("zh-CN"); + expect(negotiateLocale("en-US", supported)).toBe("en"); + }); + + it("honors Accept-Language quality weights and rejects q=0 entries", () => { + const supported = ["en", "de"] as const; + + expect(negotiateAcceptLanguage("de;q=0.2, en-US;q=0.9", supported)).toBe( + "en", + ); + expect(negotiateAcceptLanguage("de;q=0, en;q=0.5", supported)).toBe("en"); + }); +}); diff --git a/src/i18n/__tests__/markets.test.ts b/src/i18n/__tests__/markets.test.ts new file mode 100644 index 00000000..093b3caa --- /dev/null +++ b/src/i18n/__tests__/markets.test.ts @@ -0,0 +1,70 @@ +import type { Country, Market } from "@spree/sdk"; +import { describe, expect, it } from "vitest"; +import { + findMarketForCountry, + getDefaultMarketLocaleTarget, + getMarketLocales, + getMarketLocaleTargets, + isLocaleEnabledForMarket, +} from "@/i18n/markets"; + +function market(overrides: Partial = {}): Market { + return { + id: "market-1", + name: "North America", + currency: "USD", + default_locale: "en", + tax_inclusive: false, + default: true, + country_isos: ["US"], + supported_locales: ["de", "en", "it"], + countries: [country("US")], + ...overrides, + } as Market; +} + +function country(iso: string): Country { + return { + iso, + iso3: iso, + name: iso, + states_required: false, + zipcode_required: false, + } as Country; +} + +describe("Market locale routes", () => { + it("intersects Market locales with storefront message bundles", () => { + const current = market(); + expect(getMarketLocales(current)).toEqual(["en", "de"]); + expect(isLocaleEnabledForMarket(current, "DE")).toBe(true); + expect(isLocaleEnabledForMarket(current, "fr")).toBe(false); + }); + + it("builds deduplicated country and locale targets across Markets", () => { + const markets = [ + market({ countries: [country("US"), country("CA")] }), + market({ + id: "market-2", + default: false, + default_locale: "de", + supported_locales: ["de"], + countries: [country("DE")], + }), + ]; + + expect(getMarketLocaleTargets(markets)).toEqual([ + { marketId: "market-1", country: "us", locale: "en" }, + { marketId: "market-1", country: "us", locale: "de" }, + { marketId: "market-1", country: "ca", locale: "en" }, + { marketId: "market-1", country: "ca", locale: "de" }, + { marketId: "market-2", country: "de", locale: "de" }, + ]); + expect(findMarketForCountry(markets, "ca")?.id).toBe("market-1"); + expect(getDefaultMarketLocaleTarget(markets)).toEqual({ + marketId: "market-1", + country: "us", + locale: "en", + }); + }); +}); diff --git a/src/i18n/__tests__/routing.test.ts b/src/i18n/__tests__/routing.test.ts new file mode 100644 index 00000000..e3993fbf --- /dev/null +++ b/src/i18n/__tests__/routing.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { buildLocalizedRedirectPath } from "@/i18n/routing"; + +describe("localized route fallback", () => { + it("replaces the locale while preserving the page and query", () => { + expect( + buildLocalizedRedirectPath({ + country: "AR", + locale: "es", + pathname: "/ar/en/products/coffee", + search: "?sort=price", + }), + ).toBe("/ar/es/products/coffee?sort=price"); + }); + + it("falls back to the target root when request context is unavailable", () => { + expect(buildLocalizedRedirectPath({ country: "us", locale: "en" })).toBe( + "/us/en", + ); + }); +}); diff --git a/src/i18n/locales.ts b/src/i18n/locales.ts new file mode 100644 index 00000000..f9588e35 --- /dev/null +++ b/src/i18n/locales.ts @@ -0,0 +1,51 @@ +import { canonicalizeLocale, matchLocale } from "@/i18n/normalize"; + +/** + * The single registry for storefront message bundles. Adding a locale here + * updates the runtime loader, SupportedLocale type, middleware allow-list and + * hreflang filtering together. Dynamic imports keep unused bundles out of the + * active route chunk. + */ +const MESSAGE_LOADERS = { + de: () => import("../../messages/de.json"), + en: () => import("../../messages/en.json"), + es: () => import("../../messages/es.json"), + fr: () => import("../../messages/fr.json"), + pl: () => import("../../messages/pl.json"), +} as const; + +export type SupportedLocale = keyof typeof MESSAGE_LOADERS; + +export const DEFAULT_LOCALE: SupportedLocale = "en"; + +export const SUPPORTED_LOCALES = Object.freeze( + Object.keys(MESSAGE_LOADERS) as SupportedLocale[], +); + +const RTL_LANGUAGES = new Set(["ar", "fa", "he", "ur", "yi"]); + +export function resolveSupportedLocale( + value: string | undefined, +): SupportedLocale | undefined { + return matchLocale(value, SUPPORTED_LOCALES) as SupportedLocale | undefined; +} + +export async function loadMessages( + locale: SupportedLocale, +): Promise { + const module = await MESSAGE_LOADERS[locale](); + return module.default as IntlMessages; +} + +export function localeLanguage(locale: string): string { + const canonical = canonicalizeLocale(locale) ?? locale; + try { + return new Intl.Locale(canonical).language; + } catch { + return canonical.split("-")[0].toLowerCase(); + } +} + +export function localeDirection(locale: string): "ltr" | "rtl" { + return RTL_LANGUAGES.has(localeLanguage(locale)) ? "rtl" : "ltr"; +} diff --git a/src/i18n/markets.ts b/src/i18n/markets.ts new file mode 100644 index 00000000..4a4c86dc --- /dev/null +++ b/src/i18n/markets.ts @@ -0,0 +1,93 @@ +import type { Market } from "@spree/sdk"; +import { + DEFAULT_LOCALE, + resolveSupportedLocale, + type SupportedLocale, +} from "@/i18n/locales"; + +export interface MarketLocaleTarget { + marketId: string; + country: string; + locale: SupportedLocale; +} + +export function findMarketForCountry( + markets: Market[], + country: string, +): Market | undefined { + const normalizedCountry = country.toLowerCase(); + return markets.find((market) => + market.countries?.some( + (candidate) => candidate.iso.toLowerCase() === normalizedCountry, + ), + ); +} + +/** Return only locales that both the Market and this storefront can render. */ +export function getMarketLocales(market: Market): SupportedLocale[] { + const locales: SupportedLocale[] = []; + + for (const candidate of [ + market.default_locale, + ...(market.supported_locales ?? []), + ]) { + const locale = resolveSupportedLocale(candidate); + if (locale && !locales.includes(locale)) locales.push(locale); + } + + return locales; +} + +export function isLocaleEnabledForMarket( + market: Market, + locale: string, +): boolean { + const supportedLocale = resolveSupportedLocale(locale); + return supportedLocale + ? getMarketLocales(market).includes(supportedLocale) + : false; +} + +/** Resolve the Market's default locale only when the storefront can render it. */ +export function getMarketDefaultLocale( + market: Market, +): SupportedLocale | undefined { + const locales = getMarketLocales(market); + return resolveSupportedLocale(market.default_locale) ?? locales[0]; +} + +/** Build every valid country/locale route exposed by the configured Markets. */ +export function getMarketLocaleTargets( + markets: Market[], +): MarketLocaleTarget[] { + const targets: MarketLocaleTarget[] = []; + const seen = new Set(); + + for (const market of markets) { + const locales = getMarketLocales(market); + for (const country of market.countries ?? []) { + const countryIso = country.iso.toLowerCase(); + for (const locale of locales) { + const key = `${countryIso}/${locale.toLowerCase()}`; + if (seen.has(key)) continue; + seen.add(key); + targets.push({ marketId: market.id, country: countryIso, locale }); + } + } + } + + return targets; +} + +/** Resolve the configured default storefront route. */ +export function getDefaultMarketLocaleTarget( + markets: Market[], +): MarketLocaleTarget | undefined { + const defaultMarket = markets.find((market) => market.default) ?? markets[0]; + const country = defaultMarket?.countries?.[0]?.iso.toLowerCase(); + if (!defaultMarket || !country) return undefined; + + const defaultLocale = getMarketDefaultLocale(defaultMarket) ?? DEFAULT_LOCALE; + + return { marketId: defaultMarket.id, country, locale: defaultLocale }; +} diff --git a/src/i18n/normalize.ts b/src/i18n/normalize.ts new file mode 100644 index 00000000..62581953 --- /dev/null +++ b/src/i18n/normalize.ts @@ -0,0 +1,94 @@ +/** Return a canonical BCP 47 locale, accepting Rails-style underscores. */ +export function canonicalizeLocale( + value: string | undefined, +): string | undefined { + if (!value) return undefined; + + try { + return Intl.getCanonicalLocales(value.trim().replaceAll("_", "-"))[0]; + } catch { + return undefined; + } +} + +/** Match a locale case-insensitively while preserving the configured spelling. */ +export function matchLocale( + value: string | undefined, + supportedLocales: readonly string[], +): string | undefined { + const canonical = canonicalizeLocale(value)?.toLowerCase(); + if (!canonical) return undefined; + + return supportedLocales.find( + (supported) => canonicalizeLocale(supported)?.toLowerCase() === canonical, + ); +} + +/** + * Match an exact locale first, then its base language (for example en-US → en). + * This is used for browser language negotiation, not explicit route validation. + */ +export function negotiateLocale( + value: string | undefined, + supportedLocales: readonly string[], +): string | undefined { + const exact = matchLocale(value, supportedLocales); + if (exact) return exact; + + const canonical = canonicalizeLocale(value); + if (!canonical) return undefined; + + try { + return matchLocale(new Intl.Locale(canonical).language, supportedLocales); + } catch { + return undefined; + } +} + +/** + * Resolve an Accept-Language header according to its q weights. Entries with + * q=0 are explicitly unacceptable and are ignored; equal weights retain the + * browser's original order. + */ +export function negotiateAcceptLanguage( + header: string | null | undefined, + supportedLocales: readonly string[], +): string | undefined { + if (!header) return undefined; + + const preferences = header + .split(",") + .map((part, index) => { + const [candidate, ...parameters] = part.trim().split(";"); + const qualityParameter = parameters.find((parameter) => + parameter.trim().toLowerCase().startsWith("q="), + ); + const quality = qualityParameter + ? Number(qualityParameter.trim().slice(2)) + : 1; + const validQuality = + Number.isFinite(quality) && quality >= 0 && quality <= 1 ? quality : 0; + + return { + candidate: candidate.trim(), + index, + quality: validQuality, + }; + }) + .filter( + ({ candidate, quality }) => + candidate !== "*" && candidate.length > 0 && quality > 0, + ) + .sort((left, right) => + right.quality === left.quality + ? left.index - right.index + : right.quality - left.quality, + ); + + for (const { candidate } of preferences) { + const locale = negotiateLocale(candidate, supportedLocales); + if (locale) return locale; + } + + return undefined; +} diff --git a/src/i18n/request.ts b/src/i18n/request.ts index 5545d33f..327ec60a 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -1,31 +1,19 @@ import type { Locale } from "next-intl"; import { getRequestConfig } from "next-intl/server"; - -const supportedLocales: Locale[] = ["en", "de", "pl", "es", "fr"]; - -function isValidLocale(value: string | undefined): value is Locale { - return !!value && supportedLocales.includes(value as Locale); -} +import { + DEFAULT_LOCALE, + loadMessages, + resolveSupportedLocale, +} from "@/i18n/locales"; export default getRequestConfig(async ({ locale, requestLocale }) => { // 1. Use explicit locale if provided (e.g. getMessages({ locale: 'en' })) // 2. Fall back to requestLocale from the [locale] route segment // 3. Default to "en" - let resolvedLocale: Locale; - - if (isValidLocale(locale)) { - resolvedLocale = locale; - } else { - const requested = await requestLocale; - resolvedLocale = isValidLocale(requested) ? requested : "en"; - } - - let messages: IntlMessages; - try { - messages = (await import(`../../messages/${resolvedLocale}.json`)).default; - } catch { - messages = (await import("../../messages/en.json")).default; - } + const requested = locale ?? (await requestLocale); + const resolvedLocale: Locale = + resolveSupportedLocale(requested) ?? DEFAULT_LOCALE; + const messages = await loadMessages(resolvedLocale); return { locale: resolvedLocale, messages }; }); diff --git a/src/i18n/routing.ts b/src/i18n/routing.ts new file mode 100644 index 00000000..3199d2e3 --- /dev/null +++ b/src/i18n/routing.ts @@ -0,0 +1,26 @@ +export const REQUEST_PATHNAME_HEADER = "x-spree-request-pathname"; +export const REQUEST_SEARCH_HEADER = "x-spree-request-search"; + +interface LocalizedRedirectParams { + country: string; + locale: string; + pathname?: string | null; + search?: string | null; +} + +const LOCALIZED_PREFIX = /^\/[a-z]{2}\/[a-z]{2,3}(?:-[a-z0-9]{2,8})*(?=\/|$)/i; + +/** Replace only the country/locale prefix while retaining the requested page. */ +export function buildLocalizedRedirectPath({ + country, + locale, + pathname, + search, +}: LocalizedRedirectParams): string { + const prefix = `/${country.toLowerCase()}/${locale}`; + const matchedPrefix = pathname?.match(LOCALIZED_PREFIX)?.[0]; + const suffix = matchedPrefix ? pathname?.slice(matchedPrefix.length) : ""; + const normalizedSearch = search?.startsWith("?") ? search : ""; + + return `${prefix}${suffix || ""}${normalizedSearch}`; +} 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/__tests__/sitemap.test.ts b/src/lib/data/__tests__/sitemap.test.ts new file mode 100644 index 00000000..36c943e0 --- /dev/null +++ b/src/lib/data/__tests__/sitemap.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const api = vi.hoisted(() => ({ + marketsList: vi.fn(), + productsList: vi.fn(), + categoriesList: vi.fn(), +})); + +const cache = vi.hoisted(() => ({ + life: vi.fn(), + tag: vi.fn(), +})); + +vi.mock("@/lib/spree", () => ({ + getClient: () => ({ + markets: { list: api.marketsList }, + products: { list: api.productsList }, + categories: { list: api.categoriesList }, + }), +})); + +vi.mock("next/cache", () => ({ + cacheLife: cache.life, + cacheTag: cache.tag, +})); + +import { + getSitemapCategoryPage, + getSitemapResourceCount, +} from "@/lib/data/sitemap"; + +describe("sitemap data cache", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("counts only categories that produce sitemap URLs", async () => { + api.categoriesList.mockResolvedValue({ + data: [], + meta: { count: 19 }, + }); + + await expect( + getSitemapResourceCount("categories", "market-1", { + country: "us", + locale: "en", + }), + ).resolves.toBe(19); + + expect(api.categoriesList).toHaveBeenCalledWith( + { page: 1, limit: 1, parent_id_not_null: true }, + { country: "us", locale: "en" }, + ); + expect(cache.life).toHaveBeenCalledWith("tenMinutes"); + expect(cache.tag).toHaveBeenCalledWith( + "sitemap", + "categories", + "sitemap-market:market-1", + ); + }); + + it("keeps root categories out of cached sitemap pages", async () => { + api.categoriesList.mockResolvedValue({ data: [], meta: { count: 0 } }); + + await getSitemapCategoryPage("market-1", 2, 100, { + country: "us", + locale: "de", + }); + + expect(api.categoriesList).toHaveBeenCalledWith( + { page: 2, limit: 100, parent_id_not_null: true }, + { country: "us", locale: "de" }, + ); + }); +}); 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", diff --git a/src/lib/data/categories.ts b/src/lib/data/categories.ts index 5accdedf..2875fea8 100644 --- a/src/lib/data/categories.ts +++ b/src/lib/data/categories.ts @@ -14,12 +14,15 @@ async function cachedListCategories( return getClient().categories.list(params, options); } -export async function getCategories(params?: CategoryListParams) { - const options = await getLocaleOptions(); - return cachedListCategories(params, options); +export async function getCategories( + params?: CategoryListParams, + options?: { locale?: string; country?: string }, +) { + const localeOptions = options ?? (await getLocaleOptions()); + return cachedListCategories(params, localeOptions); } -async function cachedGetCategory( +export async function cachedGetCategory( idOrPermalink: string, params: { expand?: string[] } | undefined, options: { locale?: string; country?: string }, 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())); } diff --git a/src/lib/data/sitemap.ts b/src/lib/data/sitemap.ts new file mode 100644 index 00000000..b7e9c565 --- /dev/null +++ b/src/lib/data/sitemap.ts @@ -0,0 +1,79 @@ +"use server"; + +import type { Category, Media, Product } from "@spree/sdk"; +import { cacheLife, cacheTag } from "next/cache"; +import { getClient } from "@/lib/spree"; + +interface LocaleOptions { + locale: string; + country: string; +} + +export type SitemapProduct = Product & { + media?: Media[]; + updated_at?: string; +}; + +export type SitemapCategory = Category & { + updated_at?: string; +}; + +export type SitemapResource = "products" | "categories"; + +export async function getSitemapMarkets(options: LocaleOptions) { + "use cache: remote"; + cacheLife("hours"); + cacheTag("markets", "sitemap"); + return (await getClient().markets.list(options)).data; +} + +export async function getSitemapResourceCount( + resource: SitemapResource, + marketId: string, + options: LocaleOptions, +): Promise { + "use cache: remote"; + cacheLife("tenMinutes"); + cacheTag("sitemap", resource, `sitemap-market:${marketId}`); + + const response = + resource === "products" + ? await getClient().products.list({ page: 1, limit: 1 }, options) + : await getClient().categories.list( + { page: 1, limit: 1, parent_id_not_null: true }, + options, + ); + return Math.max(0, response.meta.count); +} + +export async function getSitemapProductPage( + marketId: string, + page: number, + limit: number, + options: LocaleOptions, +): Promise { + "use cache: remote"; + cacheLife("tenMinutes"); + cacheTag("sitemap", "products", `sitemap-market:${marketId}`); + const response = await getClient().products.list( + { page, limit, expand: ["media"] }, + options, + ); + return response.data as SitemapProduct[]; +} + +export async function getSitemapCategoryPage( + marketId: string, + page: number, + limit: number, + options: LocaleOptions, +): Promise { + "use cache: remote"; + cacheLife("tenMinutes"); + cacheTag("sitemap", "categories", `sitemap-market:${marketId}`); + const response = await getClient().categories.list( + { page, limit, parent_id_not_null: true }, + options, + ); + return response.data; +} diff --git a/src/lib/metadata/__tests__/alternates.test.ts b/src/lib/metadata/__tests__/alternates.test.ts new file mode 100644 index 00000000..11bea089 --- /dev/null +++ b/src/lib/metadata/__tests__/alternates.test.ts @@ -0,0 +1,446 @@ +import type { Country, Market } from "@spree/sdk"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { MarketLocaleTarget } from "@/i18n/markets"; +import { getMarkets } from "@/lib/data/markets"; +import { + buildHreflangLanguages, + buildLocalizedAlternates, + translationFingerprint, +} from "@/lib/metadata/alternates"; + +vi.mock("@/lib/data/markets", () => ({ + getMarkets: vi.fn(), +})); + +vi.mock("@/lib/store", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDefaultCountry: () => "us", + getDefaultLocale: () => "en", + }; +}); + +function market(overrides: Partial = {}): Market { + return { + id: "market-1", + name: "North America", + currency: "USD", + default_locale: "en", + tax_inclusive: false, + default: true, + country_isos: ["US"], + supported_locales: ["de", "en", "it"], + countries: [country("US")], + ...overrides, + } as Market; +} + +function country(iso: string): Country { + return { + iso, + iso3: iso, + name: iso, + states_required: false, + zipcode_required: false, + } as Country; +} + +describe("buildHreflangLanguages", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("builds a self-referencing language cluster for a single-country Market", async () => { + vi.mocked(getMarkets).mockResolvedValue({ data: [market()] }); + + const languages = await buildHreflangLanguages({ + storeUrl: "https://store.example/", + country: "US", + locale: "en", + path: "", + }); + + expect(languages).toEqual({ + en: "https://store.example/us/en", + de: "https://store.example/us/de", + "x-default": "https://store.example/us/en", + }); + }); + + it("links every country in the current Market with regional codes", async () => { + vi.mocked(getMarkets).mockResolvedValue({ + data: [market({ countries: [country("US"), country("CA")] })], + }); + + const languages = await buildHreflangLanguages({ + storeUrl: "https://store.example", + country: "ca", + locale: "de", + path: "/products", + }); + + expect(languages).toEqual({ + "en-US": "https://store.example/us/en/products", + "de-US": "https://store.example/us/de/products", + "en-CA": "https://store.example/ca/en/products", + "de-CA": "https://store.example/ca/de/products", + "x-default": "https://store.example/us/en/products", + }); + }); + + it("links localized routes across every Market in the storefront", async () => { + vi.mocked(getMarkets).mockResolvedValue({ + data: [ + market(), + market({ + id: "market-2", + default: false, + default_locale: "de", + supported_locales: ["de"], + countries: [country("DE")], + }), + ], + }); + + const languages = await buildHreflangLanguages({ + storeUrl: "https://store.example", + country: "de", + locale: "de", + path: "/products", + }); + + expect(languages).toEqual({ + "en-US": "https://store.example/us/en/products", + "de-US": "https://store.example/us/de/products", + "de-DE": "https://store.example/de/de/products", + "x-default": "https://store.example/us/en/products", + }); + }); + + it("omits resource locales that are only default-locale fallback content", async () => { + vi.mocked(getMarkets).mockResolvedValue({ + data: [market({ supported_locales: ["en", "de", "fr"] })], + }); + const english = translationFingerprint("Coffee mug", "coffee-mug"); + const resolvePath = vi.fn(async (target: MarketLocaleTarget) => { + if (target.locale === "de") { + return { + path: "/products/deutsche-tasse", + fingerprint: translationFingerprint( + "Deutsche Tasse", + "deutsche-tasse", + ), + }; + } + return { + path: "/products/coffee-mug", + fingerprint: english, + }; + }); + + const languages = await buildHreflangLanguages({ + storeUrl: "https://store.example", + country: "us", + locale: "en", + path: "/products/coffee-mug", + currentResourceFingerprint: english, + resolvePath, + }); + + expect(resolvePath).toHaveBeenCalledTimes(2); + expect(languages).toEqual({ + en: "https://store.example/us/en/products/coffee-mug", + de: "https://store.example/us/de/products/deutsche-tasse", + "x-default": "https://store.example/us/en/products/coffee-mug", + }); + }); + + it("does not advertise a current resource page when it is fallback content", async () => { + vi.mocked(getMarkets).mockResolvedValue({ data: [market()] }); + const fallback = translationFingerprint("Coffee mug", "coffee-mug"); + const resolvePath = vi.fn(async () => ({ + path: "/products/coffee-mug", + fingerprint: fallback, + })); + + const alternates = await buildLocalizedAlternates({ + storeUrl: "https://store.example", + country: "us", + locale: "de", + path: "/products/coffee-mug", + currentResourceFingerprint: fallback, + resolvePath, + }); + + expect(alternates).toEqual({ + canonical: "https://store.example/us/en/products/coffee-mug", + languages: { + en: "https://store.example/us/en/products/coffee-mug", + "x-default": "https://store.example/us/en/products/coffee-mug", + }, + }); + }); + + it("detects Store-default fallback content in a non-default Market", async () => { + vi.mocked(getMarkets).mockResolvedValue({ + data: [ + market({ + supported_locales: ["en"], + countries: [country("US")], + }), + market({ + id: "market-eu", + default: false, + default_locale: "de", + supported_locales: ["de", "es", "fr"], + countries: [country("DE")], + }), + ], + }); + const english = translationFingerprint("Coffee mug", "coffee-mug"); + const german = translationFingerprint("Kaffeetasse", "kaffeetasse"); + const french = translationFingerprint("Tasse à café", "tasse-a-cafe"); + const resolvePath = vi.fn(async (target: MarketLocaleTarget) => { + if (target.locale === "de") { + return { + path: "/products/kaffeetasse", + fingerprint: german, + }; + } + if (target.locale === "fr") { + return { + path: "/products/tasse-a-cafe", + fingerprint: french, + }; + } + return { + path: "/products/coffee-mug", + fingerprint: english, + }; + }); + + const alternates = await buildLocalizedAlternates({ + storeUrl: "https://store.example", + country: "de", + locale: "es", + path: "/products/coffee-mug", + currentResourceFingerprint: english, + resolvePath, + }); + + expect(alternates).toEqual({ + canonical: "https://store.example/de/de/products/kaffeetasse", + languages: { + "en-US": "https://store.example/us/en/products/coffee-mug", + "de-DE": "https://store.example/de/de/products/kaffeetasse", + "fr-DE": "https://store.example/de/fr/products/tasse-a-cafe", + "x-default": "https://store.example/us/en/products/coffee-mug", + }, + }); + }); + + it("keeps x-default stable when the default Market lacks the resource", async () => { + vi.mocked(getMarkets).mockResolvedValue({ + data: [ + market({ + supported_locales: ["en"], + countries: [country("US")], + }), + market({ + id: "market-eu", + default: false, + default_locale: "de", + supported_locales: ["de", "fr"], + countries: [country("DE")], + }), + ], + }); + const english = translationFingerprint("Coffee mug"); + const german = translationFingerprint("Kaffeetasse"); + const french = translationFingerprint("Tasse à café"); + const resolvePath = vi.fn(async (target: MarketLocaleTarget) => { + if (target.marketId === "market-1") return null; + if (target.locale === "de") { + return { path: "/products/kaffeetasse", fingerprint: german }; + } + if (target.locale === "fr") { + return { path: "/products/tasse-a-cafe", fingerprint: french }; + } + return { path: "/products/coffee-mug", fingerprint: english }; + }); + + const [germanAlternates, frenchAlternates] = await Promise.all([ + buildLocalizedAlternates({ + storeUrl: "https://store.example", + country: "de", + locale: "de", + path: "/products/kaffeetasse", + currentResourceFingerprint: german, + resolvePath, + }), + buildLocalizedAlternates({ + storeUrl: "https://store.example", + country: "de", + locale: "fr", + path: "/products/tasse-a-cafe", + currentResourceFingerprint: french, + resolvePath, + }), + ]); + + expect(germanAlternates.languages["x-default"]).toBe( + "https://store.example/de/de/products/kaffeetasse", + ); + expect(frenchAlternates.languages["x-default"]).toBe( + germanAlternates.languages["x-default"], + ); + }); + + it("starts default and alternate resource requests in parallel", async () => { + vi.mocked(getMarkets).mockResolvedValue({ + data: [market({ supported_locales: ["en", "de", "fr"] })], + }); + let resolveDefault: + | ((value: { path: string; fingerprint: string }) => void) + | undefined; + const resolvePath = vi.fn((target: MarketLocaleTarget) => { + if (target.locale === "en") { + return new Promise<{ path: string; fingerprint: string }>((resolve) => { + resolveDefault = resolve; + }); + } + return Promise.resolve({ + path: "/products/tasse", + fingerprint: translationFingerprint("Tasse"), + }); + }); + + const pending = buildLocalizedAlternates({ + storeUrl: "https://store.example", + country: "us", + locale: "de", + path: "/products/tasse", + currentResourceFingerprint: translationFingerprint("Tasse"), + resolvePath, + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(resolvePath).toHaveBeenCalledWith( + expect.objectContaining({ locale: "fr" }), + ); + resolveDefault?.({ + path: "/products/coffee-mug", + fingerprint: translationFingerprint("Coffee mug"), + }); + + await expect(pending).resolves.toEqual( + expect.objectContaining({ + canonical: "https://store.example/us/de/products/tasse", + }), + ); + }); + + it("uses the default Market country when the configured country is unavailable", async () => { + vi.mocked(getMarkets).mockResolvedValue({ + data: [ + market({ + id: "market-eu", + default_locale: "de", + supported_locales: ["de"], + countries: [country("DE"), country("AT")], + }), + ], + }); + + const languages = await buildHreflangLanguages({ + storeUrl: "https://store.example", + country: "de", + locale: "de", + path: "/products", + }); + + expect(languages).toEqual({ + "de-DE": "https://store.example/de/de/products", + "de-AT": "https://store.example/at/de/products", + "x-default": "https://store.example/de/de/products", + }); + }); + + it("keeps the current canonical when the resource has a real translation", async () => { + vi.mocked(getMarkets).mockResolvedValue({ data: [market()] }); + const resolvePath = vi.fn(async (target: MarketLocaleTarget) => ({ + path: + target.locale === "en" + ? "/products/coffee-mug" + : "/products/deutsche-tasse", + fingerprint: translationFingerprint( + target.locale === "en" ? "Coffee mug" : "Deutsche Tasse", + ), + })); + + const alternates = await buildLocalizedAlternates({ + storeUrl: "https://store.example", + country: "us", + locale: "de", + path: "/products/deutsche-tasse", + currentResourceFingerprint: translationFingerprint("Deutsche Tasse"), + resolvePath, + }); + + expect(alternates).toEqual({ + canonical: "https://store.example/us/de/products/deutsche-tasse", + languages: { + en: "https://store.example/us/en/products/coffee-mug", + de: "https://store.example/us/de/products/deutsche-tasse", + "x-default": "https://store.example/us/en/products/coffee-mug", + }, + }); + }); + + it("resolves each resource locale once across a multi-country Market", async () => { + vi.mocked(getMarkets).mockResolvedValue({ + data: [market({ countries: [country("US"), country("CA")] })], + }); + const english = translationFingerprint("Coffee mug"); + const resolvePath = vi.fn(async () => ({ + path: "/products/deutsche-tasse", + fingerprint: translationFingerprint("Deutsche Tasse"), + })); + + const languages = await buildHreflangLanguages({ + storeUrl: "https://store.example", + country: "us", + locale: "en", + path: "/products/coffee-mug", + currentResourceFingerprint: english, + resolvePath, + }); + + expect(resolvePath).toHaveBeenCalledTimes(1); + expect(Object.keys(languages)).toEqual([ + "en-US", + "de-US", + "en-CA", + "de-CA", + "x-default", + ]); + }); + + it("falls back to the current page when Markets are unavailable", async () => { + vi.mocked(getMarkets).mockRejectedValue(new Error("API unavailable")); + + const languages = await buildHreflangLanguages({ + storeUrl: "https://store.example", + country: "us", + locale: "en", + path: "/products", + }); + + expect(languages).toEqual({ + en: "https://store.example/us/en/products", + "x-default": "https://store.example/us/en/products", + }); + }); +}); diff --git a/src/lib/metadata/alternates.ts b/src/lib/metadata/alternates.ts new file mode 100644 index 00000000..04c69a8e --- /dev/null +++ b/src/lib/metadata/alternates.ts @@ -0,0 +1,302 @@ +import type { Market } from "@spree/sdk"; +import { resolveSupportedLocale, type SupportedLocale } from "@/i18n/locales"; +import { + getDefaultMarketLocaleTarget, + getMarketDefaultLocale, + getMarketLocaleTargets, + type MarketLocaleTarget, +} from "@/i18n/markets"; +import { canonicalizeLocale } from "@/i18n/normalize"; +import { getMarkets } from "@/lib/data/markets"; +import { buildCanonicalUrl } from "@/lib/seo"; +import { getDefaultCountry, getDefaultLocale } from "@/lib/store"; + +export interface LocalizedResourcePath { + path: string; + fingerprint: string; +} + +type LocalizedPathResolver = ( + target: MarketLocaleTarget, +) => Promise; + +interface BuildHreflangLanguagesParams { + storeUrl: string; + country: string; + locale: string; + path: string; + currentResourceFingerprint?: string; + resolvePath?: LocalizedPathResolver; +} + +export interface LocalizedAlternates { + canonical: string; + languages: Record; +} + +interface StoreHreflangCluster { + targets: MarketLocaleTarget[]; + currentTarget: MarketLocaleTarget; + xDefaultTarget: MarketLocaleTarget; + contentDefaultLocale?: SupportedLocale; + marketDefaultLocales: Map; + includeCountry: boolean; +} + +/** + * Produce a stable comparison value for fields translated by Spree/Mobility. + * The Store API falls back to the Store default locale when a translation is + * absent, so resource alternates are published only when their translated + * fields differ from that fallback payload. + */ +export function translationFingerprint(...fields: unknown[]): string { + return JSON.stringify(fields); +} + +function getStoreHreflangCluster( + markets: Market[], + country: string, + locale: SupportedLocale, +): StoreHreflangCluster | undefined { + const targets = getMarketLocaleTargets(markets); + const normalizedCountry = country.toLowerCase(); + const currentTarget = targets.find( + (target) => + target.country === normalizedCountry && target.locale === locale, + ); + if (!currentTarget) return undefined; + + const configuredCountry = getDefaultCountry(); + const configuredLocale = resolveSupportedLocale(getDefaultLocale()); + const configuredTarget = configuredLocale + ? targets.find( + (target) => + target.country === configuredCountry && + target.locale === configuredLocale, + ) + : undefined; + const marketDefaultTarget = getDefaultMarketLocaleTarget(markets); + const xDefaultTarget = + configuredTarget ?? + (marketDefaultTarget + ? targets.find( + (target) => targetKey(target) === targetKey(marketDefaultTarget), + ) + : undefined) ?? + targets[0]; + if (!xDefaultTarget) return undefined; + + const defaultMarket = markets.find((market) => market.default) ?? markets[0]; + const contentDefaultLocale = resolveSupportedLocale( + defaultMarket?.default_locale, + ); + const marketDefaultLocales = new Map(); + for (const market of markets) { + const marketDefaultLocale = getMarketDefaultLocale(market); + if (marketDefaultLocale) { + marketDefaultLocales.set(market.id, marketDefaultLocale); + } + } + + return { + targets, + currentTarget, + xDefaultTarget, + contentDefaultLocale, + marketDefaultLocales, + includeCountry: new Set(targets.map((target) => target.country)).size > 1, + }; +} + +function normalizeHrefLang( + target: MarketLocaleTarget, + includeCountry: boolean, +): string { + const canonicalLocale = canonicalizeLocale(target.locale) ?? target.locale; + if (!includeCountry) return canonicalLocale; + + try { + const locale = new Intl.Locale(canonicalLocale); + return [locale.language, locale.script, target.country.toUpperCase()] + .filter(Boolean) + .join("-"); + } catch { + return `${canonicalLocale.split("-")[0].toLowerCase()}-${target.country.toUpperCase()}`; + } +} + +function withLocalePrefix( + country: string, + locale: string, + path: string, +): string { + const normalizedPath = path ? (path.startsWith("/") ? path : `/${path}`) : ""; + return `/${country.toLowerCase()}/${locale}${normalizedPath}`; +} + +/** + * Build a reciprocal hreflang cluster across every Market served by this + * storefront. Multi-country stores use regional codes (for example en-US and + * en-CA) to avoid duplicate language keys. + * + * Resource pages provide a stable-ID resolver and fingerprints of translated + * fields. This prevents Spree's default-locale fallback from being mistaken + * for a real translation. Resolver failures are omitted without failing all + * metadata generation. + */ +export async function buildLocalizedAlternates({ + storeUrl, + country, + locale, + path, + currentResourceFingerprint, + resolvePath, +}: BuildHreflangLanguagesParams): Promise { + const currentLocale = resolveSupportedLocale(locale); + const currentUrl = buildCanonicalUrl( + storeUrl, + withLocalePrefix(country, locale, path), + ); + let cluster: StoreHreflangCluster | undefined; + + try { + const { data: markets } = await getMarkets({ country, locale }); + if (currentLocale) { + cluster = getStoreHreflangCluster(markets, country, currentLocale); + } + } catch { + // Keep self-referencing metadata available during Store API outages. + } + + if (!cluster || !currentLocale) { + return { + canonical: currentUrl, + languages: { + ...(currentLocale ? { [currentLocale]: currentUrl } : {}), + "x-default": currentUrl, + }, + }; + } + + const currentTarget = cluster.currentTarget; + const resolvedResources = new Map< + string, + Promise + >(); + + if (currentResourceFingerprint !== undefined) { + resolvedResources.set( + resourceKey(currentTarget), + Promise.resolve({ path, fingerprint: currentResourceFingerprint }), + ); + } + + const resolveLocalizedResource = ( + target: MarketLocaleTarget, + ): Promise => { + if (!resolvePath) { + return Promise.resolve({ path, fingerprint: "" }); + } + + const key = resourceKey(target); + const cachedResource = resolvedResources.get(key); + if (cachedResource) return cachedResource; + + const pendingResource = Promise.resolve() + .then(() => resolvePath(target)) + .catch(() => null); + resolvedResources.set(key, pendingResource); + return pendingResource; + }; + + const verifyTranslations = Boolean( + resolvePath !== undefined && + currentResourceFingerprint !== undefined && + cluster.contentDefaultLocale, + ); + const resourcesPromise = Promise.all( + cluster.targets.map(async (target) => { + const fallbackTarget = cluster.contentDefaultLocale + ? { ...target, locale: cluster.contentDefaultLocale } + : undefined; + const [resource, fallbackResource] = await Promise.all([ + resolveLocalizedResource(target), + verifyTranslations && fallbackTarget + ? resolveLocalizedResource(fallbackTarget) + : Promise.resolve(undefined), + ]); + return { target, resource, fallbackResource }; + }), + ); + const resolvedResourcesByTarget = await resourcesPromise; + + const languages: Record = {}; + const urlsByTarget = new Map(); + const fallbackTargets = new Set(); + + for (const { + target, + resource, + fallbackResource, + } of resolvedResourcesByTarget) { + if (!resource) continue; + const isFallback = Boolean( + verifyTranslations && + target.locale !== cluster.contentDefaultLocale && + fallbackResource && + resource.fingerprint === fallbackResource.fingerprint, + ); + if (isFallback) { + fallbackTargets.add(targetKey(target)); + continue; + } + + const hrefLang = normalizeHrefLang(target, cluster.includeCountry); + const url = buildCanonicalUrl( + storeUrl, + withLocalePrefix(target.country, target.locale, resource.path), + ); + languages[hrefLang] = url; + urlsByTarget.set(targetKey(target), url); + } + + languages["x-default"] = + urlsByTarget.get(targetKey(cluster.xDefaultTarget)) ?? + Object.values(languages)[0] ?? + currentUrl; + + const currentIsFallback = fallbackTargets.has(targetKey(currentTarget)); + const currentMarketDefaultLocale = cluster.marketDefaultLocales.get( + currentTarget.marketId, + ); + const currentMarketDefaultTarget = currentMarketDefaultLocale + ? { + ...currentTarget, + locale: currentMarketDefaultLocale, + } + : undefined; + const canonical = currentIsFallback + ? ((currentMarketDefaultTarget + ? urlsByTarget.get(targetKey(currentMarketDefaultTarget)) + : undefined) ?? + urlsByTarget.get(targetKey(cluster.xDefaultTarget)) ?? + Object.values(languages)[0] ?? + currentUrl) + : currentUrl; + + return { canonical, languages }; +} + +export async function buildHreflangLanguages( + params: BuildHreflangLanguagesParams, +): Promise> { + return (await buildLocalizedAlternates(params)).languages; +} + +function resourceKey(target: MarketLocaleTarget): string { + return `${target.marketId}:${target.locale.toLowerCase()}`; +} + +function targetKey(target: MarketLocaleTarget): string { + return `${resourceKey(target)}:${target.country.toLowerCase()}`; +} diff --git a/src/lib/metadata/categories.ts b/src/lib/metadata/categories.ts index 382a2955..fa371814 100644 --- a/src/lib/metadata/categories.ts +++ b/src/lib/metadata/categories.ts @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { buildHreflangLanguages } from "@/lib/metadata/alternates"; import { buildCanonicalUrl } from "@/lib/seo"; import { getStoreUrl } from "@/lib/store"; @@ -15,11 +16,26 @@ export async function generateCategoriesMetadata({ const canonicalUrl = storeUrl ? buildCanonicalUrl(storeUrl, `/${country}/${locale}/c`) : undefined; + const languages = storeUrl + ? await buildHreflangLanguages({ + storeUrl, + country, + locale, + path: "/c", + }) + : undefined; return { title: "Categories", description: "Browse all product categories.", - ...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}), + ...(canonicalUrl + ? { + alternates: { + canonical: canonicalUrl, + ...(languages ? { languages } : {}), + }, + } + : {}), openGraph: { title: "Categories", description: "Browse all product categories.", diff --git a/src/lib/metadata/category.ts b/src/lib/metadata/category.ts index ce9a69d3..2e6b5902 100644 --- a/src/lib/metadata/category.ts +++ b/src/lib/metadata/category.ts @@ -1,6 +1,10 @@ import type { Metadata } from "next"; import { getCachedCategory } from "@/lib/data/cached"; -import { buildCanonicalUrl } from "@/lib/seo"; +import { cachedGetCategory } from "@/lib/data/categories"; +import { + buildLocalizedAlternates, + translationFingerprint, +} from "@/lib/metadata/alternates"; import { getStoreUrl } from "@/lib/store"; export interface CategoryMetadataParams { @@ -33,22 +37,43 @@ export async function generateCategoryMetadata({ `Browse ${category.name} products.`; const storeUrl = getStoreUrl(); - const canonicalUrl = storeUrl - ? buildCanonicalUrl( + const localizedAlternates = storeUrl + ? await buildLocalizedAlternates({ storeUrl, - `/${country}/${locale}/c/${category.permalink}`, - ) + country, + locale, + path: `/c/${category.permalink}`, + currentResourceFingerprint: categoryTranslationFingerprint(category), + resolvePath: async (target) => { + const localizedCategory = await cachedGetCategory( + category.id, + undefined, + { country: target.country, locale: target.locale }, + ); + return { + path: `/c/${localizedCategory.permalink}`, + fingerprint: categoryTranslationFingerprint(localizedCategory), + }; + }, + }) : undefined; return { title, description, ...(category.meta_keywords ? { keywords: category.meta_keywords } : {}), - ...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}), + ...(localizedAlternates + ? { + alternates: { + canonical: localizedAlternates.canonical, + languages: localizedAlternates.languages, + }, + } + : {}), openGraph: { title, description, - ...(canonicalUrl ? { url: canonicalUrl } : {}), + ...(localizedAlternates ? { url: localizedAlternates.canonical } : {}), type: "website", ...(category.image_url ? { images: [{ url: category.image_url, alt: category.name }] } @@ -62,3 +87,23 @@ export async function generateCategoryMetadata({ }, }; } + +function categoryTranslationFingerprint(category: { + name: string; + permalink: string; + description: string; + description_html: string; + meta_title: string | null; + meta_description: string | null; + meta_keywords: string | null; +}): string { + return translationFingerprint( + category.name, + category.permalink, + category.description, + category.description_html, + category.meta_title, + category.meta_description, + category.meta_keywords, + ); +} diff --git a/src/lib/metadata/home.ts b/src/lib/metadata/home.ts index 9a88ed3a..362c8631 100644 --- a/src/lib/metadata/home.ts +++ b/src/lib/metadata/home.ts @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { buildHreflangLanguages } from "@/lib/metadata/alternates"; import { buildCanonicalUrl, SOCIAL_IMAGE_PATH } from "@/lib/seo"; import { getStoreMetaDescription, @@ -21,11 +22,26 @@ export async function generateHomeMetadata({ const canonicalUrl = storeUrl ? buildCanonicalUrl(storeUrl, `/${country}/${locale}`) : undefined; + const languages = storeUrl + ? await buildHreflangLanguages({ + storeUrl, + country, + locale, + path: "", + }) + : undefined; return { title: { absolute: storeName }, description, - ...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}), + ...(canonicalUrl + ? { + alternates: { + canonical: canonicalUrl, + ...(languages ? { languages } : {}), + }, + } + : {}), openGraph: { title: storeName, description, diff --git a/src/lib/metadata/product.ts b/src/lib/metadata/product.ts index 67e8d1ab..b6236ec1 100644 --- a/src/lib/metadata/product.ts +++ b/src/lib/metadata/product.ts @@ -1,6 +1,12 @@ import type { Metadata } from "next"; import { getCachedProduct, PRODUCT_METADATA_EXPAND } from "@/lib/data/cached"; -import { buildCanonicalUrl, stripHtml } from "@/lib/seo"; +import { cachedGetProduct } from "@/lib/data/products"; +import { + buildLocalizedAlternates, + translationFingerprint, +} from "@/lib/metadata/alternates"; +import { stripHtml } from "@/lib/seo"; +import { DEFAULT_SURFACE } from "@/lib/spree"; import { getStoreUrl } from "@/lib/store"; interface ProductMetadataParams { @@ -29,11 +35,26 @@ export async function generateProductMetadata({ : `Shop ${product.name}`; const storeUrl = getStoreUrl(); - const canonicalUrl = storeUrl - ? buildCanonicalUrl( + const localizedAlternates = storeUrl + ? await buildLocalizedAlternates({ storeUrl, - `/${country}/${locale}/products/${product.slug}`, - ) + country, + locale, + path: `/products/${product.slug}`, + currentResourceFingerprint: productTranslationFingerprint(product), + resolvePath: async (target) => { + const localizedProduct = await cachedGetProduct( + product.id, + [], + { country: target.country, locale: target.locale }, + DEFAULT_SURFACE, + ); + return { + path: `/products/${localizedProduct.slug}`, + fingerprint: productTranslationFingerprint(localizedProduct), + }; + }, + }) : undefined; const primaryMedia = product.primary_media; @@ -57,11 +78,18 @@ export async function generateProductMetadata({ title, description, ...(product.meta_keywords ? { keywords: product.meta_keywords } : {}), - ...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}), + ...(localizedAlternates + ? { + alternates: { + canonical: localizedAlternates.canonical, + languages: localizedAlternates.languages, + }, + } + : {}), openGraph: { title, description, - ...(canonicalUrl ? { url: canonicalUrl } : {}), + ...(localizedAlternates ? { url: localizedAlternates.canonical } : {}), type: "website", ...(ogImage ? { images: [ogImage] } : {}), }, @@ -75,3 +103,23 @@ export async function generateProductMetadata({ }, }; } + +function productTranslationFingerprint(product: { + name: string; + slug: string; + description: string | null; + description_html: string | null; + meta_title: string | null; + meta_description: string | null; + meta_keywords: string | null; +}): string { + return translationFingerprint( + product.name, + product.slug, + product.description, + product.description_html, + product.meta_title, + product.meta_description, + product.meta_keywords, + ); +} diff --git a/src/lib/metadata/products.ts b/src/lib/metadata/products.ts index 66fbd8bc..e6d3f90a 100644 --- a/src/lib/metadata/products.ts +++ b/src/lib/metadata/products.ts @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { buildHreflangLanguages } from "@/lib/metadata/alternates"; import { buildCanonicalUrl } from "@/lib/seo"; import { getStoreUrl } from "@/lib/store"; @@ -15,11 +16,26 @@ export async function generateProductsMetadata({ const canonicalUrl = storeUrl ? buildCanonicalUrl(storeUrl, `/${country}/${locale}/products`) : undefined; + const languages = storeUrl + ? await buildHreflangLanguages({ + storeUrl, + country, + locale, + path: "/products", + }) + : undefined; return { title: "Products", description: "Browse our full collection of products.", - ...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}), + ...(canonicalUrl + ? { + alternates: { + canonical: canonicalUrl, + ...(languages ? { languages } : {}), + }, + } + : {}), openGraph: { title: "Products", description: "Browse our full collection of products.", diff --git a/src/lib/spree/middleware.test.ts b/src/lib/spree/middleware.test.ts new file mode 100644 index 00000000..1d56f8c2 --- /dev/null +++ b/src/lib/spree/middleware.test.ts @@ -0,0 +1,86 @@ +import { NextRequest } from "next/server"; +import { describe, expect, it } from "vitest"; +import { createSpreeMiddleware } from "@/lib/spree/middleware"; + +const middleware = createSpreeMiddleware({ + defaultCountry: "us", + defaultLocale: "en", + supportedLocales: ["en", "de", "zh-CN"], +}); + +describe("Spree locale middleware", () => { + it("canonicalizes an existing country and locale prefix", () => { + const response = middleware( + new NextRequest("https://store.example/US/ZH-cn/products?sort=name"), + ); + + expect(response.headers.get("location")).toBe( + "https://store.example/us/zh-CN/products?sort=name", + ); + }); + + it("redirects an unsupported storefront locale without dropping the path", () => { + const response = middleware( + new NextRequest("https://store.example/ar/it/products/coffee"), + ); + + expect(response.headers.get("location")).toBe( + "https://store.example/us/en/products/coffee", + ); + expect(response.cookies.get("spree_country")?.value).toBe("us"); + expect(response.cookies.get("spree_locale")?.value).toBe("en"); + }); + + it("falls back to a supported locale when the configured default is unavailable", () => { + const invalidDefaultMiddleware = createSpreeMiddleware({ + defaultCountry: "us", + defaultLocale: "it", + supportedLocales: ["en", "de"], + }); + + const response = invalidDefaultMiddleware( + new NextRequest("https://store.example/us/it/products"), + ); + + expect(response.headers.get("location")).toBe( + "https://store.example/us/en/products", + ); + }); + + it("negotiates the first supported browser language", () => { + const response = middleware( + new NextRequest("https://store.example/products", { + headers: { "accept-language": "it-IT, de-DE;q=0.9, en;q=0.8" }, + }), + ); + + expect(response.headers.get("location")).toBe( + "https://store.example/us/de/products", + ); + }); + + it("uses Accept-Language quality weights instead of header order", () => { + const response = middleware( + new NextRequest("https://store.example/products", { + headers: { "accept-language": "de;q=0, en-US;q=0.9" }, + }), + ); + + expect(response.headers.get("location")).toBe( + "https://store.example/us/en/products", + ); + }); + + it("forwards the localized request path for Market-aware fallbacks", () => { + const response = middleware( + new NextRequest("https://store.example/ar/en/products/coffee?sort=price"), + ); + + expect( + response.headers.get("x-middleware-request-x-spree-request-pathname"), + ).toBe("/ar/en/products/coffee"); + expect( + response.headers.get("x-middleware-request-x-spree-request-search"), + ).toBe("?sort=price"); + }); +}); diff --git a/src/lib/spree/middleware.ts b/src/lib/spree/middleware.ts index 7b7840c4..1740526b 100644 --- a/src/lib/spree/middleware.ts +++ b/src/lib/spree/middleware.ts @@ -1,16 +1,26 @@ import { type NextRequest, NextResponse } from "next/server"; +import { + canonicalizeLocale, + matchLocale, + negotiateAcceptLanguage, + negotiateLocale, +} from "@/i18n/normalize"; +import { REQUEST_PATHNAME_HEADER, REQUEST_SEARCH_HEADER } from "@/i18n/routing"; const COUNTRY_COOKIE = "spree_country"; const LOCALE_COOKIE = "spree_locale"; const COOKIE_MAX_AGE = 365 * 24 * 60 * 60; -const HAS_COUNTRY_LOCALE = /^\/([a-z]{2})\/([a-z]{2}(?:-[a-z]{2})?)(\/|$)/i; +const HAS_COUNTRY_LOCALE = + /^\/([a-z]{2})\/([a-z]{2,3}(?:-[a-z0-9]{2,8})*)(\/|$)/i; export interface SpreeMiddlewareConfig { /** Default country ISO code (default: 'us') */ defaultCountry?: string; /** Default locale code (default: 'en') */ defaultLocale?: string; + /** Locale codes for which the storefront has message bundles. */ + supportedLocales?: readonly string[]; /** Routes to skip — prefixes matched with startsWith (default: ['/_next', '/api', '/favicon.ico']) */ staticRoutes?: string[]; } @@ -34,6 +44,22 @@ function setLocaleCookies( }); } +function nextWithLocaleContext( + request: NextRequest, + country: string, + locale: string, +): NextResponse { + const requestHeaders = new Headers(request.headers); + requestHeaders.set(REQUEST_PATHNAME_HEADER, request.nextUrl.pathname); + requestHeaders.set(REQUEST_SEARCH_HEADER, request.nextUrl.search); + + const response = NextResponse.next({ + request: { headers: requestHeaders }, + }); + setLocaleCookies(response, country, locale); + return response; +} + /** * Creates a Next.js middleware that handles: * - Redirecting bare paths to /{country}/{locale}/... @@ -46,7 +72,14 @@ export function createSpreeMiddleware( config: SpreeMiddlewareConfig = {}, ): (request: NextRequest) => NextResponse { const defaultCountry = config.defaultCountry ?? "us"; - const defaultLocale = config.defaultLocale ?? "en"; + const supportedLocales = config.supportedLocales ?? []; + const configuredDefaultLocale = config.defaultLocale ?? "en"; + const defaultLocale = + (supportedLocales.length > 0 + ? (matchLocale(configuredDefaultLocale, supportedLocales) ?? + matchLocale("en", supportedLocales) ?? + supportedLocales[0]) + : canonicalizeLocale(configuredDefaultLocale)) ?? "en"; const staticRoutes = config.staticRoutes ?? [ "/_next", "/api", @@ -70,13 +103,35 @@ export function createSpreeMiddleware( // Already has /{country}/{locale} prefix — sync cookies with URL segments const match = pathname.match(HAS_COUNTRY_LOCALE); if (match) { - const response = NextResponse.next(); - setLocaleCookies( - response, - match[1].toLowerCase(), - match[2].toLowerCase(), - ); - return response; + const country = match[1].toLowerCase(); + const originalPrefix = `/${match[1]}/${match[2]}`; + const locale = + supportedLocales.length > 0 + ? negotiateLocale(match[2], supportedLocales) + : canonicalizeLocale(match[2]); + + // An unknown locale cannot safely retain the requested country: the + // global default locale may not be enabled by that country's Market. + // Use the configured default route while preserving the remaining path. + if (!locale) { + const url = request.nextUrl.clone(); + url.pathname = `/${defaultCountry}/${defaultLocale}${pathname.slice(originalPrefix.length)}`; + const response = NextResponse.redirect(url); + setLocaleCookies(response, defaultCountry, defaultLocale); + return response; + } + + const canonicalPrefix = `/${country}/${locale}`; + + if (originalPrefix !== canonicalPrefix) { + const url = request.nextUrl.clone(); + url.pathname = `${canonicalPrefix}${pathname.slice(originalPrefix.length)}`; + const response = NextResponse.redirect(url); + setLocaleCookies(response, country, locale); + return response; + } + + return nextWithLocaleContext(request, country, locale); } // Detect country: cookie → geo headers → default @@ -87,14 +142,17 @@ export function createSpreeMiddleware( defaultCountry; // Detect locale: cookie → accept-language → default - const locale = - request.cookies.get(LOCALE_COOKIE)?.value ?? - request.headers - .get("accept-language") - ?.split(",")[0] - ?.split("-")[0] - ?.toLowerCase() ?? - defaultLocale; + const cookieValue = request.cookies.get(LOCALE_COOKIE)?.value; + const cookieLocale = + supportedLocales.length > 0 + ? negotiateLocale(cookieValue, supportedLocales) + : canonicalizeLocale(cookieValue); + const acceptLanguage = request.headers.get("accept-language"); + const acceptedLocale = + supportedLocales.length > 0 + ? negotiateAcceptLanguage(acceptLanguage, supportedLocales) + : canonicalizeLocale(acceptLanguage?.split(",")[0]?.split(";")[0]); + const locale = cookieLocale ?? acceptedLocale ?? defaultLocale; const url = request.nextUrl.clone(); url.pathname = `/${country}/${locale}${pathname === "/" ? "" : pathname}`; diff --git a/src/proxy.ts b/src/proxy.ts index bc959a88..f03b26e0 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,9 +1,11 @@ +import { SUPPORTED_LOCALES } from "@/i18n/locales"; import { createSpreeMiddleware } from "@/lib/spree/middleware"; import { getDefaultCountry, getDefaultLocale } from "@/lib/store"; export const proxy = createSpreeMiddleware({ defaultCountry: getDefaultCountry(), defaultLocale: getDefaultLocale(), + supportedLocales: SUPPORTED_LOCALES, }); export const config = { diff --git a/src/types/next-intl.d.ts b/src/types/next-intl.d.ts index 38aeb05d..e28a2e16 100644 --- a/src/types/next-intl.d.ts +++ b/src/types/next-intl.d.ts @@ -1,9 +1,10 @@ +import type { SupportedLocale } from "@/i18n/locales"; import type messages from "../../messages/en.json"; type Messages = typeof messages; declare global { - type Locale = "en" | "de" | "pl" | "es" | "fr"; + type Locale = SupportedLocale; interface IntlMessages extends Messages {} }