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
29 changes: 20 additions & 9 deletions app/(store)/categories/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { Suspense } from "react";

import { storeContainer } from "@/components/layout/container";
import { pagedTitle } from "@/features/catalog/listing/canonical";
import { getListingCanonical } from "@/features/catalog/listing/canonical-query";
import { ListingSkeleton } from "@/features/catalog/listing/components/listing-skeleton";
import { ListingView } from "@/features/catalog/listing/components/listing-view";
import {
Expand All @@ -10,7 +12,8 @@ import {
} from "@/features/categories/components/category-header";
import { categoryPath } from "@/features/categories/paths";
import { getCategoryBySlug, getPrerenderedCategorySlugs } from "@/features/categories/queries";
import { env } from "@/lib/env";
import { storePageMetadata } from "@/lib/seo/site";
import { META_DESCRIPTION_MAX, presentText, textExcerpt } from "@/lib/seo/text";
import { cn } from "@/lib/utils";

// Cannot match the slug pattern, so it renders the 404 page. Cache Components refuses an empty
Expand All @@ -22,19 +25,27 @@ export async function generateStaticParams() {
return slugs.length > 0 ? slugs.map((slug) => ({ slug })) : [{ slug: NO_CATEGORY_SLUG }];
}

// spec 0013, AC-24: never reads the search params, so filters never reach the metadata.
// spec 0018, AC-12 to AC-14: the search params decide only the canonical page, through the
// parsed listing state; no filter text ever reaches the metadata (spec 0013, AC-24).
export async function generateMetadata({
params,
searchParams,
}: PageProps<"/categories/[slug]">): Promise<Metadata> {
const category = await getCategoryBySlug((await params).slug);
// AC-21: notFound() in the header adds the noindex.
if (!category) return { title: "Page not found" };
return {
title: category.metaTitle ?? category.name,
description: category.metaDescription ?? category.description ?? undefined,
alternates: {
canonical: new URL(categoryPath(category.slug), env.NEXT_PUBLIC_SITE_URL).toString(),
},
};
const canonical = await getListingCanonical(
{ kind: "category", categoryId: category.id },
categoryPath(category.slug),
searchParams,
);
return storePageMetadata({
title: pagedTitle(presentText(category.metaTitle) ?? category.name, canonical.page),
description:
presentText(category.metaDescription) ??
textExcerpt(category.description ?? "", META_DESCRIPTION_MAX),
path: canonical.path,
});
}

// The page joins the two features: the category from categories, the listing from catalog.
Expand Down
5 changes: 3 additions & 2 deletions app/(store)/cookies/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { CookieTable } from "@/features/legal/components/cookie-table";
import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page";
import { LegalPage, legalSeo } from "@/features/legal/components/legal-page";
import { cookieRegistry } from "@/lib/consent/registry";
import { CookieSettingsButton } from "@/lib/consent/cookie-settings-control";
import { storePageMetadata } from "@/lib/seo/site";

export const metadata = legalMetadata("cookies");
export const metadata = storePageMetadata(legalSeo("cookies"));

// spec 0017, AC-16 and AC-17: the policy, the registry table, and the settings button.
export default function CookiesPage() {
Expand Down
24 changes: 19 additions & 5 deletions app/(store)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,36 @@ import { Suspense } from "react";
import { storeContainer } from "@/components/layout/container";
import { AccountDeletedNotice } from "@/features/account/components/account-deleted-notice";
import { ListingSkeleton } from "@/features/catalog/listing/components/listing-skeleton";
import { pagedTitle } from "@/features/catalog/listing/canonical";
import { getListingCanonical } from "@/features/catalog/listing/canonical-query";
import { ListingView } from "@/features/catalog/listing/components/listing-view";
import { homePath } from "@/features/catalog/paths";
import { brand } from "@/lib/brand/brand";
import { env } from "@/lib/env";
import { JsonLdScript } from "@/lib/seo/json-ld";
import { defaultDescription } from "@/lib/seo/metadata";
import { site, storePageMetadata } from "@/lib/seo/site";
import { organizationJsonLd, websiteJsonLd } from "@/lib/seo/structured-data";
import { cn } from "@/lib/utils";

// spec 0013, AC-24: the home title stays; paged and filtered views point at the plain path.
export const metadata: Metadata = {
alternates: { canonical: new URL(homePath, env.NEXT_PUBLIC_SITE_URL).toString() },
};
// spec 0018, AC-12 and AC-13: a filtered or sorted view points at the plain path; an unfiltered
// paged one at itself, titled `, page N`. Reads the search params, so it renders at request time,
// like the results; crawlers still get it in the head. No filter text ever reaches it.
export async function generateMetadata({ searchParams }: PageProps<"/">): Promise<Metadata> {
const canonical = await getListingCanonical({ kind: "home" }, homePath, searchParams);
return storePageMetadata({
title: canonical.page > 1 ? { absolute: pagedTitle(brand.name, canonical.page) } : null,
description: defaultDescription(brand.name),
path: canonical.path,
});
}

// spec 0013, AC-4: every listable product, with filters, sorts and paging. The heading is the
// static shell; the results read the search params, so they render behind Suspense.
export default function StoreHomePage({ searchParams }: PageProps<"/">) {
return (
<div className={cn(storeContainer, "flex flex-col gap-12 py-12 md:gap-16 md:py-16")}>
{/* spec 0018, AC-15: in the static shell. */}
<JsonLdScript data={[organizationJsonLd(site), websiteJsonLd(site)]} />
<Suspense fallback={null}>
<AccountDeletedNotice />
</Suspense>
Expand Down
5 changes: 3 additions & 2 deletions app/(store)/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page";
import { LegalPage, legalSeo } from "@/features/legal/components/legal-page";
import { storePageMetadata } from "@/lib/seo/site";

export const metadata = legalMetadata("privacy");
export const metadata = storePageMetadata(legalSeo("privacy"));

export default function PrivacyPage() {
return <LegalPage slug="privacy" />;
Expand Down
5 changes: 4 additions & 1 deletion app/(store)/products/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
ProductDetailsSkeleton,
} from "@/features/catalog/components/product-details";
import { getPrerenderedSlugs, getProductBySlug } from "@/features/catalog/queries";
import { productSeo } from "@/features/catalog/seo";
import { storePageMetadata } from "@/lib/seo/site";
import { cn } from "@/lib/utils";

// Cannot match the slug pattern, so it renders the 404 page. Cache Components refuses an empty
Expand All @@ -22,7 +24,8 @@ export async function generateMetadata({
params,
}: PageProps<"/products/[slug]">): Promise<Metadata> {
const product = await getProductBySlug((await params).slug);
return product ? { title: product.name } : { title: "Page not found" };
// spec 0018, AC-21: a hidden product gets no product metadata; notFound() adds the noindex.
return product ? storePageMetadata(productSeo(product)) : { title: "Page not found" };
}

// Other slugs render on their first visit and are cached (spec 0005, Caching model).
Expand Down
5 changes: 3 additions & 2 deletions app/(store)/returns/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page";
import { LegalPage, legalSeo } from "@/features/legal/components/legal-page";
import { storePageMetadata } from "@/lib/seo/site";

export const metadata = legalMetadata("returns");
export const metadata = storePageMetadata(legalSeo("returns"));

export default function ReturnsPage() {
return <LegalPage slug="returns" />;
Expand Down
12 changes: 8 additions & 4 deletions app/(store)/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ import { ListingView } from "@/features/catalog/listing/components/listing-view"
import { SearchForm } from "@/features/catalog/listing/components/search-box";
import { searchQuery } from "@/features/catalog/listing/params";
import { searchPath } from "@/features/catalog/paths";
import { storePageMetadata } from "@/lib/seo/site";
import { cn } from "@/lib/utils";

// spec 0013, AC-24: the query never reaches the title or any metadata; results are not indexed.
export const metadata: Metadata = {
// spec 0013, AC-24 and spec 0018, AC-17: the query never reaches the title or any metadata;
// results are `noindex, follow`, with no canonical and the brand card as the share image.
export const metadata: Metadata = storePageMetadata({
title: "Search",
robots: { index: false, follow: true },
};
description: null,
path: searchPath,
indexable: false,
});

async function SearchResults({ searchParams }: Pick<PageProps<"/search">, "searchParams">) {
const query = searchQuery(await searchParams);
Expand Down
5 changes: 3 additions & 2 deletions app/(store)/shipping/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page";
import { LegalPage, legalSeo } from "@/features/legal/components/legal-page";
import { shippingValues } from "@/features/legal/shipping-values";
import { env } from "@/lib/env";
import { storePageMetadata } from "@/lib/seo/site";
import { getShippingSettings } from "@/lib/shipping/settings";

export const metadata = legalMetadata("shipping");
export const metadata = storePageMetadata(legalSeo("shipping"));

// spec 0017, AC-5: static plus the cached settings read, which an admin save expires.
export default async function ShippingPage() {
Expand Down
5 changes: 3 additions & 2 deletions app/(store)/terms/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page";
import { LegalPage, legalSeo } from "@/features/legal/components/legal-page";
import { storePageMetadata } from "@/lib/seo/site";

export const metadata = legalMetadata("terms");
export const metadata = storePageMetadata(legalSeo("terms"));

export default function TermsPage() {
return <LegalPage slug="terms" />;
Expand Down
7 changes: 7 additions & 0 deletions app/brand-card.png/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { brandCardResponse } from "@/lib/seo/brand-images";

// spec 0018, AC-3: a fixed URL every page names as its share image. It reads no request data,
// so it prerenders at build.
export function GET() {
return brandCardResponse();
}
10 changes: 10 additions & 0 deletions app/icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { logoSize } from "@/lib/seo/brand-assets";
import { logoResponse } from "@/lib/seo/brand-images";

// spec 0018, AC-4: the tab icon is the logo; favicon.ico stays for browsers that ask for it.
export const size = logoSize;
export const contentType = "image/png";

export default function Icon() {
return logoResponse();
}
5 changes: 4 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { brand } from "@/lib/brand/brand";
import { fontHeading, fontSans } from "@/lib/brand/fonts";
import { env } from "@/lib/env";
import { defaultDescription } from "@/lib/seo/metadata";
import { cn } from "@/lib/utils";

import "./globals.css";

// spec 0018, AC-1: every relative metadata URL resolves on the site's own origin.
export const metadata: Metadata = {
metadataBase: new URL(env.NEXT_PUBLIC_SITE_URL),
title: { default: brand.name, template: `%s · ${brand.name}` },
description: `${brand.name} online store`,
description: defaultDescription(brand.name),
};

export default function RootLayout({ children }: LayoutProps<"/">) {
Expand Down
6 changes: 6 additions & 0 deletions app/logo.png/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { logoResponse } from "@/lib/seo/brand-images";

// spec 0018, AC-4: the Organization logo, at a fixed URL. Prerendered at build.
export function GET() {
return logoResponse();
}
10 changes: 10 additions & 0 deletions app/robots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { MetadataRoute } from "next";

import { env } from "@/lib/env";
import { robotsRules } from "@/lib/seo/robots";

// spec 0018, AC-20: prerendered at build, which is right because Vercel builds each production
// deployment with VERCEL_ENV=production and every other build blocks crawling.
export default function robots(): MetadataRoute.Robots {
return robotsRules(env.VERCEL_ENV, env.NEXT_PUBLIC_SITE_URL);
}
115 changes: 115 additions & 0 deletions app/sitemap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

// spec 0018, AC-18: how the sitemap composes its two cached reads with the legal documents. The
// reads themselves (what counts as listed) are proven against a database in
// tests/db/sitemap.db.test.ts.

const mocks = vi.hoisted(() => ({
getSitemapProducts: vi.fn(),
getSitemapCategories: vi.fn(),
}));

vi.mock("server-only", () => ({}));
// Another origin than the default, so the test proves every URL follows the env.
vi.mock("@/lib/env", () => ({
env: {
NEXT_PUBLIC_SITE_URL: "https://shop.other.example",
STORE_LOCALE: "en",
STORE_COUNTRY: "PL",
STORE_CURRENCY: "PLN",
VERCEL_ENV: "development",
},
}));
vi.mock("@/features/catalog/queries", () => ({ getSitemapProducts: mocks.getSitemapProducts }));
vi.mock("@/features/categories/queries", () => ({
getSitemapCategories: mocks.getSitemapCategories,
}));

const { default: sitemap } = await import("./sitemap");
const { legalDocuments } = await import("@/lib/legal/documents");
const { legalSlugs } = await import("@/lib/legal/paths");

const origin = "https://shop.other.example";

beforeEach(() => {
mocks.getSitemapProducts.mockResolvedValue([]);
mocks.getSitemapCategories.mockResolvedValue([]);
});

describe("sitemap", () => {
it("lists the home page with no lastModified and the five legal pages with their dates (AC-18)", async () => {
const entries = await sitemap();

expect(entries[0]).toEqual({ url: `${origin}/` });
expect(entries.slice(1)).toEqual(
legalSlugs.map((slug) => ({
url: `${origin}${legalDocuments[slug].path}`,
lastModified: legalDocuments[slug].updated,
})),
);
expect(legalSlugs).toHaveLength(5);
for (const entry of entries.slice(1)) {
expect(entry.lastModified).toMatch(/^\d{4}-\d{2}-\d{2}$/);
}
});

it("lists each category and product on the site origin with its updated_at (AC-18)", async () => {
mocks.getSitemapCategories.mockResolvedValue([
{ slug: "scarves", updatedAt: "2026-10-01T10:00:00.000Z" },
]);
mocks.getSitemapProducts.mockResolvedValue([
{
slug: "wool-scarf",
updatedAt: "2026-10-02T11:00:00.000Z",
imageUrls: ["https://cdn.example/a.jpg", "https://cdn.example/b.jpg"],
},
]);

const entries = await sitemap();

expect(entries).toContainEqual({
url: `${origin}/categories/scarves`,
lastModified: "2026-10-01T10:00:00.000Z",
});
expect(entries).toContainEqual({
url: `${origin}/products/wool-scarf`,
lastModified: "2026-10-02T11:00:00.000Z",
images: ["https://cdn.example/a.jpg", "https://cdn.example/b.jpg"],
});
});

it("leaves out images for a product with no photo", async () => {
mocks.getSitemapProducts.mockResolvedValue([
{ slug: "plain-tee", updatedAt: "2026-10-02T11:00:00.000Z", imageUrls: [] },
]);

const entries = await sitemap();

const tee = entries.find((entry) => entry.url === `${origin}/products/plain-tee`);
expect(tee).toBeDefined();
expect(tee).not.toHaveProperty("images");
});

it("lists nothing beyond home, legal pages, categories and products", async () => {
mocks.getSitemapCategories.mockResolvedValue([
{ slug: "scarves", updatedAt: "2026-10-01T10:00:00.000Z" },
]);
mocks.getSitemapProducts.mockResolvedValue([
{ slug: "wool-scarf", updatedAt: "2026-10-02T11:00:00.000Z", imageUrls: [] },
]);

const entries = await sitemap();

expect(entries).toHaveLength(1 + 5 + 1 + 1);
for (const { url } of entries) {
expect(url.startsWith(`${origin}/`)).toBe(true);
expect(url).not.toContain("?");
}
});

it("fails the render when a read fails, never serving a partial list", async () => {
mocks.getSitemapProducts.mockRejectedValue(new Error("db down"));

await expect(sitemap()).rejects.toThrow("db down");
});
});
32 changes: 32 additions & 0 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { MetadataRoute } from "next";

import { homePath, productPath } from "@/features/catalog/paths";
import { getSitemapProducts } from "@/features/catalog/queries";
import { categoryPath } from "@/features/categories/paths";
import { getSitemapCategories } from "@/features/categories/queries";
import { legalDocuments } from "@/lib/legal/documents";
import { legalSlugs } from "@/lib/legal/paths";
import { absoluteUrl } from "@/lib/seo/metadata";
import { site } from "@/lib/seo/site";

// spec 0018, AC-18 and AC-19: every public page and nothing else. Both reads are cached and
// tagged, so a product or category write shows here on the next request, with no deploy.
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const [products, categories] = await Promise.all([getSitemapProducts(), getSitemapCategories()]);
return [
{ url: absoluteUrl(homePath, site) },
...legalSlugs.map((slug) => ({
url: absoluteUrl(legalDocuments[slug].path, site),
lastModified: legalDocuments[slug].updated,
})),
...categories.map((category) => ({
url: absoluteUrl(categoryPath(category.slug), site),
lastModified: category.updatedAt,
})),
...products.map((product) => ({
url: absoluteUrl(productPath(product.slug), site),
lastModified: product.updatedAt,
...(product.imageUrls.length > 0 ? { images: [...product.imageUrls] } : {}),
})),
];
}
Loading
Loading