diff --git a/src/app/[country]/[locale]/(storefront)/policies/[slug]/page.test.tsx b/src/app/[country]/[locale]/(storefront)/policies/[slug]/page.test.tsx new file mode 100644 index 00000000..06f24a70 --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/policies/[slug]/page.test.tsx @@ -0,0 +1,56 @@ +import type { Policy } from "@spree/sdk"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getPolicy } from "@/lib/data/policies"; +import { generateMetadata } from "./page"; + +vi.mock("next-intl/server", () => ({ + getTranslations: vi.fn(), +})); + +vi.mock("@/lib/data/policies", () => ({ + getPolicy: vi.fn(), +})); + +const policy = { + id: "policy-1", + name: "Privacy Policy", + slug: "privacy-policy", + body: null, + body_html: null, +} satisfies Policy; + +describe("policy metadata", () => { + beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://store.example/"); + vi.stubEnv("NEXT_PUBLIC_STORE_NAME", "Example Store"); + vi.mocked(getPolicy).mockResolvedValue(policy); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + }); + + it("sets the localized policy URL as canonical", async () => { + const metadata = await generateMetadata({ + params: Promise.resolve({ + country: "us", + locale: "en", + slug: "privacy-policy", + }), + }); + + const canonicalUrl = "https://store.example/us/en/policies/privacy-policy"; + + expect(metadata).toMatchObject({ + title: "Privacy Policy", + description: "Privacy Policy — Example Store", + alternates: { canonical: canonicalUrl }, + openGraph: { + title: "Privacy Policy", + description: "Privacy Policy — Example Store", + url: canonicalUrl, + }, + }); + }); +}); diff --git a/src/app/[country]/[locale]/(storefront)/policies/[slug]/page.tsx b/src/app/[country]/[locale]/(storefront)/policies/[slug]/page.tsx index 61353a48..7401fc2f 100644 --- a/src/app/[country]/[locale]/(storefront)/policies/[slug]/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/policies/[slug]/page.tsx @@ -2,7 +2,8 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { getPolicy } from "@/lib/data/policies"; -import { getStoreName } from "@/lib/store"; +import { buildCanonicalUrl } from "@/lib/seo"; +import { getStoreName, getStoreUrl } from "@/lib/store"; interface PolicyPageProps { params: Promise<{ @@ -15,7 +16,7 @@ interface PolicyPageProps { export async function generateMetadata({ params, }: PolicyPageProps): Promise { - const { slug, locale } = await params; + const { country, locale, slug } = await params; const policy = await getPolicy(slug); const storeName = getStoreName(); @@ -31,12 +32,23 @@ export async function generateMetadata({ }; } + const description = `${policy.name} — ${storeName}`; + const storeUrl = getStoreUrl(); + const canonicalUrl = storeUrl + ? buildCanonicalUrl( + storeUrl, + `/${country}/${locale}/policies/${policy.slug}`, + ) + : undefined; + return { - title: storeName ? `${policy.name} | ${storeName}` : policy.name, - description: `${policy.name} — ${storeName}`, + title: policy.name, + description, + ...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}), openGraph: { title: policy.name, - description: `${policy.name} — ${storeName}`, + description, + ...(canonicalUrl ? { url: canonicalUrl } : {}), }, }; } diff --git a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.test.tsx b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.test.tsx new file mode 100644 index 00000000..03b1c17a --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.test.tsx @@ -0,0 +1,88 @@ +import type { Product } from "@spree/sdk"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { PRODUCT_PAGE_EXPAND } from "@/lib/data/cached"; +import { ProductDetails } from "./ProductDetails"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/components/products/MediaGallery", () => ({ + MediaGallery: () => null, +})); + +vi.mock("@/components/products/ProductCustomFields", () => ({ + ProductCustomFields: () => null, +})); + +vi.mock("@/contexts/CartContext", () => ({ + useCart: () => ({ addItem: vi.fn() }), +})); + +vi.mock("@/contexts/HiddenPricingContext", () => ({ + useHiddenPricing: () => null, +})); + +vi.mock("@/contexts/StoreContext", () => ({ + useStore: () => ({ currency: "USD" }), +})); + +vi.mock("@/lib/analytics/gtm", () => ({ + trackAddToCart: vi.fn(), + trackViewItem: vi.fn(), +})); + +const productWithoutCustomVariants = { + id: "product-1", + name: "Single Variant Product", + slug: "single-variant-product", + default_variant_id: "variant-master", + default_variant: { + id: "variant-master", + product_id: "product-1", + sku: "MASTER-SKU-001", + options_text: "", + purchasable: true, + in_stock: true, + price: { + display_amount: "$25.00", + amount_in_cents: 2500, + compare_at_amount_in_cents: null, + display_compare_at_amount: null, + }, + original_price: null, + }, + variants: [], + option_types: [], + media: [], + purchasable: true, + in_stock: true, + price: { + display_amount: "$25.00", + amount_in_cents: 2500, + compare_at_amount_in_cents: null, + display_compare_at_amount: null, + }, + original_price: null, + description_html: null, + custom_fields: [], +} as unknown as Product; + +describe("ProductDetails", () => { + it("requests the default variant for the product page", () => { + expect(PRODUCT_PAGE_EXPAND).toContain("default_variant"); + }); + + it("shows the master SKU when a product has no custom variants", () => { + render( + , + ); + + expect(screen.getByText("sku")).toBeInTheDocument(); + expect(screen.getByText("MASTER-SKU-001")).toBeInTheDocument(); + }); +}); diff --git a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx index 2dbf368e..e1860e42 100644 --- a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx +++ b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx @@ -94,6 +94,8 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) { : price?.display_compare_at_amount) ?? null) : null; + const sku = selectedVariant?.sku ?? product.default_variant?.sku; + // Purchasability const isPurchasable = hasVariants ? (selectedVariant?.purchasable ?? false) @@ -250,12 +252,10 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) { {t("details")}
- {selectedVariant?.sku && ( + {sku && (
{t("sku")}
-
- {selectedVariant.sku} -
+
{sku}
)} {selectedVariant?.options_text && ( diff --git a/src/app/layout.test.tsx b/src/app/layout.test.tsx new file mode 100644 index 00000000..84f03509 --- /dev/null +++ b/src/app/layout.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import RootLayout from "./layout"; + +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("RootLayout", () => { + it("allows browser extensions to modify the root element before hydration", () => { + const layout = RootLayout({ children:
Storefront
}); + + expect(layout.type).toBe("html"); + expect(layout.props.suppressHydrationWarning).toBe(true); + }); +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8c84fff8..3652e72f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -40,7 +40,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + {spreeApiOrigin && ( <> 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",