Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
},
});
});
});
22 changes: 17 additions & 5 deletions src/app/[country]/[locale]/(storefront)/policies/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand All @@ -15,7 +16,7 @@ interface PolicyPageProps {
export async function generateMetadata({
params,
}: PolicyPageProps): Promise<Metadata> {
const { slug, locale } = await params;
const { country, locale, slug } = await params;
const policy = await getPolicy(slug);

const storeName = getStoreName();
Expand All @@ -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 } : {}),
},
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<ProductDetails
product={productWithoutCustomVariants}
basePath="/us/en"
/>,
);

expect(screen.getByText("sku")).toBeInTheDocument();
expect(screen.getByText("MASTER-SKU-001")).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -250,12 +252,10 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) {
{t("details")}
</h2>
<dl className="space-y-3">
{selectedVariant?.sku && (
{sku && (
<div className="flex">
<dt className="w-32 text-gray-500 text-sm">{t("sku")}</dt>
<dd className="text-gray-900 text-sm">
{selectedVariant.sku}
</dd>
<dd className="text-gray-900 text-sm">{sku}</dd>
</div>
)}
{selectedVariant?.options_text && (
Expand Down
27 changes: 27 additions & 0 deletions src/app/layout.test.tsx
Original file line number Diff line number Diff line change
@@ -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: <main>Storefront</main> });

expect(layout.type).toBe("html");
expect(layout.props.suppressHydrationWarning).toBe(true);
});
});
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<head>
{spreeApiOrigin && (
<>
Expand Down
1 change: 1 addition & 0 deletions src/lib/data/cached.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading