Skip to content
Merged
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
61 changes: 61 additions & 0 deletions src/app/[country]/[locale]/(storefront)/layout.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = <section>Storefront content</section>;
const layout = (await StorefrontLayout({
children: content,
params: Promise.resolve({ country: "us", locale: "en" }),
})) as ReactElement<LayoutElementProps>;

expect(layout.type).toBe(Fragment);

const [header, hiddenNavigation, main, footer] = Children.toArray(
layout.props.children,
) as ReactElement<LayoutElementProps>[];

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();
});
});
137 changes: 118 additions & 19 deletions src/app/[country]/[locale]/(storefront)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,65 @@
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 {
children: React.ReactNode;
params: Promise<{ country: string; locale: string }>;
}

interface StorefrontNavigationProps {
basePath: string;
country: string;
locale: string;
}

const EMPTY_CATEGORIES: Category[] = [];

function MobileNavigationFallback() {
return (
<div
aria-hidden="true"
className="size-10 rounded-md bg-gray-100 animate-pulse motion-reduce:animate-none"
/>
);
}

function FooterCategoryLinksFallback() {
return (
<li aria-hidden="true">
<span className="block h-4 w-24 rounded bg-white/10 animate-pulse motion-reduce:animate-none" />
</li>
);
}

/**
* Navigation categories are optional chrome, so defer their first load until
* there is a real request instead of making every prerendered page contact the
* Store API. Primitive arguments let React deduplicate category navigation
* consumers within the request; successful responses keep using the persistent
* cache in getCategories.
*/
const getRootCategories = cache(async (country: string, locale: string) => {
await connection();

return getCategories(
{
depth_eq: 0,
expand: ["children.children"],
},
{ country, locale },
)
.then((res) => res.data)
.catch((error) => {
console.error("StorefrontLayout: failed to load categories", error);
return EMPTY_CATEGORIES;
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function CategoryLinks({
categories,
basePath,
Expand All @@ -32,40 +83,88 @@ function CategoryLinks({
);
}

async function StorefrontMobileNavigation({
basePath,
country,
locale,
}: StorefrontNavigationProps) {
const rootCategories = await getRootCategories(country, locale);

return (
<HeaderMobileMenu rootCategories={rootCategories} basePath={basePath} />
);
}

async function StorefrontCategoryNavigation({
basePath,
country,
locale,
}: StorefrontNavigationProps) {
const rootCategories = await getRootCategories(country, locale);

if (rootCategories.length === 0) return null;

return (
<nav aria-label="Category navigation" className="sr-only">
<CategoryLinks categories={rootCategories} basePath={basePath} />
</nav>
);
}

async function StorefrontFooterCategoryLinks({
basePath,
country,
locale,
}: StorefrontNavigationProps) {
const rootCategories = await getRootCategories(country, locale);

return (
<FooterCategoryLinks rootCategories={rootCategories} basePath={basePath} />
);
}

export default async function StorefrontLayout({
children,
params,
}: StorefrontLayoutProps) {
const { country, locale } = await params;
const basePath = `/${country}/${locale}`;

const rootCategories = await getCategories({
depth_eq: 0,
expand: ["children.children"],
})
.then((res) => res.data)
.catch((error) => {
console.error("StorefrontLayout: failed to load categories", error);
return [] as Category[];
});

return (
<>
<Header
rootCategories={rootCategories}
basePath={basePath}
locale={locale as Locale}
mobileNavigation={
<Suspense fallback={<MobileNavigationFallback />}>
<StorefrontMobileNavigation
basePath={basePath}
country={country}
locale={locale}
/>
</Suspense>
}
/>
{rootCategories.length > 0 && (
<nav aria-label="Category navigation" className="sr-only">
<CategoryLinks categories={rootCategories} basePath={basePath} />
</nav>
)}
<Suspense fallback={null}>
<StorefrontCategoryNavigation
basePath={basePath}
country={country}
locale={locale}
/>
</Suspense>
<main className="flex-1">{children}</main>
<Footer
rootCategories={rootCategories}
basePath={basePath}
locale={locale as Locale}
categoryLinks={
<Suspense fallback={<FooterCategoryLinksFallback />}>
<StorefrontFooterCategoryLinks
basePath={basePath}
country={country}
locale={locale}
/>
</Suspense>
}
/>
</>
);
Expand Down
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",
});
});
});
Loading
Loading