Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2742b88
feat(catalog): category pages, home listing, search, filters and sort
kris1027 Oct 16, 2026
45a4407
feat(categories): arrange products inside a category
kris1027 Oct 16, 2026
e8131cb
test(catalog): e2e for discovery, filters, search and category arrange
kris1027 Oct 16, 2026
9c770c9
docs(catalog): spec 0013 for categories, search and filters, build ti…
kris1027 Oct 16, 2026
91a214b
docs(catalog): verify steps for spec 0013
kris1027 Oct 16, 2026
c64d667
test(catalog): more listing and params edge cases for spec 0013
kris1027 Oct 16, 2026
1ca1c1a
docs(catalog): verify results and sync for spec 0013, feature 13 done
kris1027 Oct 16, 2026
cf3b2e3
fix(catalog): cap option groups after dropping unknown ones
kris1027 Oct 17, 2026
2ec0681
fix(catalog): read a decimal comma in the price filters
kris1027 Oct 17, 2026
df91a61
refactor(catalog): parse the listing's scalar params with Zod
kris1027 Oct 17, 2026
ca717b3
refactor(catalog): drop reassigned locals from the listing rules
kris1027 Oct 17, 2026
6bf85a8
refactor(catalog): share the listing's link, type and count shapes
kris1027 Oct 17, 2026
dd8f6ad
refactor(catalog): build listing cards from availability, not made-up…
kris1027 Oct 17, 2026
d6306fb
refactor(categories): split arranging out of the category products list
kris1027 Oct 17, 2026
cd1c417
docs(categories): say why the arrange log event carries only a count
kris1027 Oct 17, 2026
5cca36b
fix(storefront): show the header search box wherever the desktop nav …
kris1027 Oct 17, 2026
032a0cb
fix(catalog): keep one Apply button when JavaScript is off
kris1027 Oct 17, 2026
526c58c
fix(catalog): drop the empty state line AC-21 does not ask for
kris1027 Oct 17, 2026
cb279d6
test(catalog): e2e for the category's fields, titles, canonical and f…
kris1027 Oct 17, 2026
812da1e
docs(catalog): amend spec 0013 after review, tick the value sourcing …
kris1027 Oct 17, 2026
e2f26ea
fix(catalog): block only scripts in the no-JS e2e so CSS still loads
kris1027 Oct 17, 2026
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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ MCP servers: Supabase (connected), Stripe (connected), Vercel (connected), Playw
## Context files

- [emails/AGENTS.md](emails/AGENTS.md): React Email templates sent through Resend
- [src/features/catalog/AGENTS.md](src/features/catalog/AGENTS.md): products, variants, images, stock history, the admin create, edit and arrange pages, cached storefront reads
- [src/features/categories/AGENTS.md](src/features/categories/AGENTS.md): admin category pages, product links, category reordering
- [src/features/catalog/AGENTS.md](src/features/catalog/AGENTS.md): products, variants, images, stock history, the admin create, edit and arrange pages, cached storefront reads, the home, search and category listings with filters
- [src/features/categories/AGENTS.md](src/features/categories/AGENTS.md): admin category pages, product links, category reordering, arranging products inside a category, the storefront nav and category pages
- [src/features/cart/AGENTS.md](src/features/cart/AGENTS.md): the signed cart cookie, cart actions and caps, the expired cart cron
- [src/features/checkout/AGENTS.md](src/features/checkout/AGENTS.md): the checkout form, `startCheckout` and the Stripe session, the confirmation page
- [src/features/orders/AGENTS.md](src/features/orders/AGENTS.md): the Stripe webhook, order transitions, the reconcile cron, the expired order PII purge cron, admin orders
Expand Down
71 changes: 71 additions & 0 deletions app/(store)/categories/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { Metadata } from "next";
import { Suspense } from "react";

import { storeContainer } from "@/components/layout/container";
import { ListingSkeleton } from "@/features/catalog/listing/components/listing-skeleton";
import { ListingView } from "@/features/catalog/listing/components/listing-view";
import {
CategoryHeader,
CategoryHeaderSkeleton,
} 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 { cn } from "@/lib/utils";

// Cannot match the slug pattern, so it renders the 404 page. Cache Components refuses an empty
// list, and a store with no visible category has no slugs to give.
const NO_CATEGORY_SLUG = "__none__";

export async function generateStaticParams() {
const slugs = await getPrerenderedCategorySlugs();
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.
export async function generateMetadata({
params,
}: PageProps<"/categories/[slug]">): Promise<Metadata> {
const category = await getCategoryBySlug((await params).slug);
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(),
},
};
}

// The page joins the two features: the category from categories, the listing from catalog.
async function CategoryListing({
params,
searchParams,
}: Pick<PageProps<"/categories/[slug]">, "params" | "searchParams">) {
const category = await getCategoryBySlug((await params).slug);
// The header shows the not found page.
if (!category) return null;
return (
<ListingView
scope={{ kind: "category", categoryId: category.id }}
path={categoryPath(category.slug)}
searchParams={searchParams}
heading={{ text: "Products", visible: false }}
/>
);
}

// spec 0013, AC-1 to AC-3. The header reads only the path (static for prerendered slugs); the
// results read the search params, so they render per request.
export default function CategoryPage({ params, searchParams }: PageProps<"/categories/[slug]">) {
return (
<div className={cn(storeContainer, "flex flex-col gap-10 py-12 md:gap-12 md:py-16")}>
<Suspense fallback={<CategoryHeaderSkeleton />}>
<CategoryHeader params={params} />
</Suspense>
<Suspense fallback={<ListingSkeleton />}>
<CategoryListing params={params} searchParams={searchParams} />
</Suspense>
</div>
);
}
12 changes: 11 additions & 1 deletion app/(store)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,22 @@ import { Suspense } from "react";
import { StoreShell } from "@/components/layout/store-shell";
import { AccountLink } from "@/features/account/components/account-link";
import { CartLink, CartLinkView } from "@/features/cart/components/cart-link";
import { HeaderSearch } from "@/features/catalog/listing/components/search-box";
import { categoryPath } from "@/features/categories/paths";
import { getNavCategories } from "@/features/categories/queries";

export default function StoreLayout({ children }: LayoutProps<"/">) {
// The nav is a cached read tagged `categories`, so the shell stays static (spec 0013, AC-3).
export default async function StoreLayout({ children }: LayoutProps<"/">) {
const categories = await getNavCategories();
return (
<StoreShell
navItems={categories.map((category) => ({
href: categoryPath(category.slug),
label: category.name,
}))}
actions={
<>
<HeaderSearch />
<AccountLink />
<Suspense fallback={<CartLinkView count={0} />}>
<CartLink />
Expand Down
25 changes: 21 additions & 4 deletions app/(store)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import type { Metadata } from "next";
import { Suspense } from "react";

import { storeContainer } from "@/components/layout/container";
import { AccountDeletedNotice } from "@/features/account/components/account-deleted-notice";
import { ProductGrid } from "@/features/catalog/components/product-grid";
import { ListingSkeleton } from "@/features/catalog/listing/components/listing-skeleton";
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 { cn } from "@/lib/utils";

// Served from the cache: the grid's read is tagged `catalog` (spec 0005, AC-6).
export default function StoreHomePage() {
// 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 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")}>
<Suspense fallback={null}>
Expand All @@ -19,7 +29,14 @@ export default function StoreHomePage() {
Everything we sell, in one place.
</p>
</header>
<ProductGrid />
<Suspense fallback={<ListingSkeleton />}>
<ListingView
scope={{ kind: "home" }}
path={homePath}
searchParams={searchParams}
heading={{ text: "Shop all", visible: true }}
/>
</Suspense>
</div>
);
}
51 changes: 51 additions & 0 deletions app/(store)/search/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import { Suspense } from "react";

import { storeContainer } from "@/components/layout/container";
import { ListingSkeleton } from "@/features/catalog/listing/components/listing-skeleton";
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 { 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 = {
title: "Search",
robots: { index: false, follow: true },
};

async function SearchResults({ searchParams }: Pick<PageProps<"/search">, "searchParams">) {
const query = searchQuery(await searchParams);
return (
<div className="flex flex-col gap-10">
{/* Keyed by the query, so a new search shows its own text in the box. */}
<SearchForm
key={query}
id="search-query"
label="Search products"
defaultValue={query}
className="max-w-xl"
/>
<ListingView
scope={{ kind: "search" }}
path={searchPath}
searchParams={searchParams}
heading={{ text: "Results", visible: false }}
/>
</div>
);
}

// spec 0013, AC-6 to AC-9: the box and the results both read the query, so both sit behind
// Suspense; the heading is the static shell.
export default function SearchPage({ searchParams }: PageProps<"/search">) {
return (
<div className={cn(storeContainer, "flex flex-col gap-8 py-12 md:py-16")}>
<h1 className="font-heading text-4xl md:text-5xl">Search</h1>
<Suspense fallback={<ListingSkeleton />}>
<SearchResults searchParams={searchParams} />
</Suspense>
</div>
);
}
16 changes: 13 additions & 3 deletions docs/scope/scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ _These are recommendations to keep your build orderly, not requirements. Skip an
| 10 | Admin order management | Slice 4 | done |
| 11 | Order emails | Slice 5 | done |
| 12 | Customer accounts | Slice 6 | done |
| 13 | Categories, search & filters | Slice 7 | planned |
| 13 | Categories, search & filters | Slice 7 | done |
| 14 | Discounts & promo codes | Slice 8 | planned |
| 15 | Admin sales dashboard | Slice 9 | planned |
| 16 | Legal pages & cookie consent | Launch | planned |
Expand Down Expand Up @@ -248,10 +248,19 @@ spec [0012](../specs/0012-customer-accounts/index.md) · code in [src/features/a

## Slice 7: Discovery

### 13. Categories, search & filters · needs a decision
### 13. Categories, search & filters · done
Help customers find products: category pages, text search, filters by price and variant options, sorting.
**Done when:** a customer can browse by category, search by name, filter and sort results, and the URL reflects the filters so results can be shared; empty and no results states render.
- [ ] Design it (spec): `/architect categories, search & filters`
spec [0013](../specs/0013-categories-search-filters/index.md) · code in [src/features/catalog/listing/](../../src/features/catalog/listing/), [src/features/categories/](../../src/features/categories/)
- [x] Design it (spec): `/architect categories, search & filters`
- [x] Build it: `/develop categories, search & filters`
- [x] Thin thread: `categories` cache tag and its expiry, the cached listing index, header nav, category pages with paging, not found and empty states (AC-1 to AC-3, AC-5, AC-22)
- [x] Home and search: home becomes the full listing, text folding, the header search box, `/search` with Best match and the short query prompt (AC-4, AC-6 to AC-10)
- [x] Filters and sort: option groups and counts, per variant matching, price, stock and sale, URL repair and serialization, sort, the panel, Sheet, chips and no results states (AC-11 to AC-21)
- [x] Admin arrange inside a category (AC-23)
- [x] Safety net: category metadata and canonicals, e2e with axe and keyboard on home, category and search (AC-1 to AC-25)
- [x] Verify it: `/check verify categories, search & filters`
- [x] Test it: `/test categories, search & filters`

## Slice 8: Discounts

Expand Down Expand Up @@ -302,6 +311,7 @@ Out of scope for the current build pass, kept so the plan stays honest.
- **Wishlist**: save products for later · needs a decision
- **Dark mode**: a dark token set with its own contrast pass and a theme switch; the semantic tokens rule keeps it a token change · needs a decision · from spec 0003
- **Sweep abandoned product image uploads**: delete files in `product-images` that no image row and no order line references, older than a day, if storage cost grows · from spec 0009
- **Storefront listing in SQL**: move search, filters and counts from the cached in memory index to SQL per request (`pg_trgm`, `unaccent`) once the catalog passes about 2,000 active products or the index nears the cache entry limit · needs a decision · from spec 0013
- **Trigram search indexes for admin orders**: `pg_trgm` indexes on the searched order columns, once the list is measured as slow · from spec 0010
- **Redact customer data at Stripe**: remove the name, email and address Stripe keeps on expired Checkout Sessions, if its redaction tooling fits · needs a decision · from spec 0008
- **Resend bounce and complaint webhooks**: record bounced and complained on `email_sends` and show "Bounced" on the order page, once live · from spec 0011
Expand Down
Loading
Loading