Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
52 changes: 1 addition & 51 deletions src/app/[country]/[locale]/(storefront)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ import type { Metadata } from "next";
import { FeaturedProductsSection } from "@/components/home/FeaturedProductsSection";
import { HeroSection } from "@/components/home/HeroSection";
import { WholesaleSection } from "@/components/home/WholesaleSection";
import { getMarkets, resolveCurrency } from "@/lib/data/markets";
import { resolveCurrency } from "@/lib/data/markets";
import { generateHomeMetadata } from "@/lib/metadata/home";
import { getDefaultCountry, getDefaultLocale } from "@/lib/store";

interface HomePageProps {
params: Promise<{
Expand All @@ -13,55 +12,6 @@ interface HomePageProps {
}>;
}

/**
* Prebuild the homepage shell for every (country, locale) combination the
* store serves. Next.js reuses the static shell (hero + featured section
* chrome) while featured products stream in under Suspense.
*
* Cache Components requires this to return at least one entry, so we
* always include the store's configured default country/locale as a
* fallback even if the markets fetch fails.
*/
export async function generateStaticParams() {
const fallback = {
country: getDefaultCountry(),
locale: getDefaultLocale(),
};

let markets;
try {
({ data: markets } = await getMarkets());
} catch {
return [fallback];
}

const params: Array<{ country: string; locale: string }> = [];
const seen = new Set<string>();

const addParam = (country: string, locale: string) => {
const key = `${country}/${locale}`;
if (seen.has(key)) return;
seen.add(key);
params.push({ country, locale });
};

for (const market of markets) {
const locale = market.default_locale;
if (!locale) continue;
for (const country of market.countries ?? []) {
const iso = country.iso?.toLowerCase();
if (!iso) continue;
addParam(iso, locale);
}
}

if (params.length === 0) {
addParam(fallback.country, fallback.locale);
}

return params;
}

export async function generateMetadata({
params,
}: HomePageProps): Promise<Metadata> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { Policy } from "@spree/sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getPolicy } from "@/lib/data/policies";
import { buildLocalizedAlternates } from "@/lib/metadata/alternates";
import { generateMetadata } from "./page";

vi.mock("next-intl/server", () => ({
getTranslations: vi.fn(),
}));

vi.mock("@/lib/data/policies", () => ({
cachedGetPolicy: vi.fn(),
getPolicy: vi.fn(),
}));

vi.mock("@/lib/metadata/alternates", () => ({
buildLocalizedAlternates: vi.fn(),
translationFingerprint: (...fields: unknown[]) => JSON.stringify(fields),
}));

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);
vi.mocked(buildLocalizedAlternates).mockResolvedValue({
canonical: "https://store.example/us/en/policies/privacy-policy",
languages: {
en: "https://store.example/us/en/policies/privacy-policy",
de: "https://store.example/us/de/policies/datenschutz",
"x-default": "https://store.example/us/en/policies/privacy-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,
languages: {
en: canonicalUrl,
de: "https://store.example/us/de/policies/datenschutz",
"x-default": canonicalUrl,
},
},
openGraph: {
title: "Privacy Policy",
description: "Privacy Policy — Example Store",
url: canonicalUrl,
},
});
expect(getPolicy).toHaveBeenCalledWith("privacy-policy", {
country: "us",
locale: "en",
});
});
});
66 changes: 57 additions & 9 deletions src/app/[country]/[locale]/(storefront)/policies/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
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 { cachedGetPolicy, getPolicy } from "@/lib/data/policies";
import {
buildLocalizedAlternates,
translationFingerprint,
} from "@/lib/metadata/alternates";
import { getStoreName, getStoreUrl } from "@/lib/store";

interface PolicyPageProps {
params: Promise<{
Expand All @@ -15,8 +19,8 @@ interface PolicyPageProps {
export async function generateMetadata({
params,
}: PolicyPageProps): Promise<Metadata> {
const { slug, locale } = await params;
const policy = await getPolicy(slug);
const { country, locale, slug } = await params;
const policy = await getPolicy(slug, { country, locale });

const storeName = getStoreName();

Expand All @@ -31,22 +35,52 @@ export async function generateMetadata({
};
}

const description = `${policy.name} — ${storeName}`;
const storeUrl = getStoreUrl();
const localizedAlternates = storeUrl
? await buildLocalizedAlternates({
storeUrl,
country,
locale,
path: `/policies/${policy.slug}`,
currentResourceFingerprint: policyTranslationFingerprint(policy),
resolvePath: async (target) => {
const localizedPolicy = await cachedGetPolicy(policy.id, target);
return localizedPolicy
? {
path: `/policies/${localizedPolicy.slug}`,
fingerprint: policyTranslationFingerprint(localizedPolicy),
}
: undefined;
},
})
: undefined;

return {
title: storeName ? `${policy.name} | ${storeName}` : policy.name,
description: `${policy.name} — ${storeName}`,
title: policy.name,
description,
...(localizedAlternates
? {
alternates: {
canonical: localizedAlternates.canonical,
languages: localizedAlternates.languages,
},
}
: {}),
openGraph: {
title: policy.name,
description: `${policy.name} — ${storeName}`,
description,
...(localizedAlternates ? { url: localizedAlternates.canonical } : {}),
},
};
}

export default async function PolicyPage({
params,
}: PolicyPageProps): Promise<React.JSX.Element> {
const { slug, locale } = await params;
const { country, slug, locale } = await params;
const [policy, t] = await Promise.all([
getPolicy(slug),
getPolicy(slug, { country, locale }),
getTranslations({ locale: locale as Locale, namespace: "policies" }),
]);

Expand All @@ -72,3 +106,17 @@ export default async function PolicyPage({
</div>
);
}

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,
);
}
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
Loading
Loading