# Products Feature — Implementation Plan (Admin Dashboard)

**Audience:** Senior software engineers  
**Scope:** Products route only — list page, create page, edit page. No images or variants.  
**References:** Convex MCP, ShadCN UI MCP, `.agent/skills/shadcn-ui`, `convex/schema.ts`, `apps/admin` CLAUDE.md and admin-dashboard-ui rule.

---

## 1. Overview

Implement the Product Management feature for the admin dashboard as defined in the checklist (items 3.1–3.4, 3.11), limited to:

- **Product list page** — table with search, column visibility, sort, pagination, loading skeleton, row preview dialog, actions menu.
- **Create product page** — form for required and optional product fields; categories prefetched.
- **Edit product page** — same form pre-populated; archive with confirmation.

All UI must use **ShadCN UI only** (admin rule). Data is served by existing Convex `products.*` and `categories.*` APIs with small backend extensions where noted.

---

## 2. Backend (Convex) — Required Changes

### 2.1 Extend `products.create` and `products.update`

**Current state:**  
`create` accepts: `name`, `slug`, `description?`, `status`, `categoryId`, `tags`.  
`update` accepts: `id`, `name?`, `slug?`, `description?`, `status?`, `categoryId?`, `tags?`.

**Required:** Add optional fields so the admin form can persist a full product record (no SEO in MVP form if you prefer; otherwise add in 2.2).

| Field | Validator | Notes |
|-------|-----------|--------|
| `shortDescription` | `v.optional(v.string())` | 1–2 line summary |
| `brand` | `v.optional(v.string())` | e.g. "Royal Canin" |
| `attributes` | `v.optional(v.object({ petSize?, ageRange?, specialDiet?, material?, flavor? }))` | Match schema shape in `convex/schema.ts` |

**Optional (low priority):** Add `seoTitle`, `seoDescription`, `canonicalSlug` to both create and update for the “Advanced” section.

**Timestamps:** In the mutation handlers, set `createdAt: Date.now()` on insert and `updatedAt: Date.now()` on every patch so the admin can show them in the preview. Schema already has these as optional.

**Slug:** Keep slug unique by convention; no need for a separate “check slug” query if the form derives slug from name and the storefront uses it as canonical. If you want uniqueness validation, add an internal helper that checks `by_slug` before insert/update and surface a clear error.

### 2.2 Single-product fetch for admin (optional)

For the **product preview dialog**, the table already receives enriched rows from `products.list` or `products.search`. Passing the selected row into the dialog is sufficient; no extra fetch required.

If you later want to “refetch after edit” or open preview by deep link, add a public admin-only query, e.g. `products.getByIdForAdmin(id)`, that calls `requireAdmin(ctx)` and returns `getProductWithRelations(ctx, id)` (reuse existing model helper). Not required for the initial scope.

### 2.3 Sorting (optional but recommended)

`products.list` currently returns pages in index order (e.g. by status/category) with no `sortBy`/`sortOrder`. For “sort by name, brand, childCategorySlug”:

- **Option A — Client-side:** Sort the current page only (quick, no backend change).
- **Option B — Backend:** Add optional `sortBy: v.optional(v.union(v.literal("name"), v.literal("brand"), v.literal("childCategorySlug")))` and `sortOrder: v.optional(v.union(v.literal("asc"), v.literal("desc")))` to `products.list`. Use the appropriate index where possible (e.g. `by_brand`) or a single generic index and `.order()` so pagination stays consistent. Prefer Option B for correct cross-page sort.

### 2.4 Search vs list

- **Empty search:** Use `products.list({ paginationOpts, status?, categoryId? })`. Paginated; use `page`, `isDone`, `continueCursor` for the table.
- **Non-empty search:** Use `products.search({ query, status?, categoryId?, brand?, limit })`. Default `limit` is 24; for admin, pass a larger limit (e.g. 100) or document that search results are “top N” and pagination is not applied when search is active.

---

## 3. Product List Page (`apps/admin/src/app/(dashboard)/products/page.tsx`)

### 3.1 Layout (top to bottom)

1. **Title** — e.g. “Products” (heading level 1).
2. **Toolbar row** — same row, three elements:
   - **Search input** — debounced (e.g. 300 ms); when empty, table uses `products.list`; when non-empty, `products.search` with admin limit. Clear button when query is non-empty.
   - **Columns visibility dropdown** — control which columns are visible (see table columns below). Use ShadCN DropdownMenu + Checkbox items; persist preference in React state (or localStorage if desired).
   - **Create Product button** — primary CTA; links to `/products/new` (or your create route).
3. **Data table** — ShadCN Table with sortable headers for name, brand, childCategorySlug; row click or name-cell click opens preview dialog.
4. **Pagination** — below the table; only when using `products.list` (not when showing search results). Use `continueCursor` / `isDone` from pagination result; page size selector optional (e.g. 10, 25, 50).

### 3.2 Table columns — what to show (first sight)

Prioritise what an admin needs at a glance; everything else is in the preview dialog.

| Column | Sortable | Visible by default | Notes |
|--------|----------|--------------------|--------|
| **Name** | Yes | Yes | Trigger for preview dialog (clickable). |
| **Brand** | Yes | Yes | Optional field; show “—” if empty. |
| **Child category** | Yes | Yes | `childCategorySlug` (or category name if you resolve id → name). |
| **Status** | No | Yes | Badge: active / draft / archived (colour + text). |
| **Slug** | No | Yes | For quick URL reference. |
| **Tags** | No | Optional | Comma-separated or count; hide by default in columns dropdown if too noisy. |
| **Updated** | No | Optional | `updatedAt` formatted; hide by default. |

Avoid cluttering the table with description, SEO, or attributes; those belong in the **preview dialog**.

### 3.3 Loading state — skeleton

When `products.list` or `products.search` is loading, render a **skeleton that matches the table layout**: same number of columns and a fixed number of rows (e.g. 10). Use ShadCN `Skeleton` for each cell so the table doesn’t jump when data loads. No full-page spinner (per admin rules).

### 3.4 Product preview dialog

- **Trigger:** Product name cell (or whole row if you prefer; name is required for a11y).
- **Content:** Full product snapshot — all required and optional fields (name, slug, status, category, parent/child slugs, brand, tags, shortDescription, description, attributes, SEO if present, timestamps). Read-only; no form. Use ShadCN Dialog; optionally use ScrollArea for long content.
- **Data source:** Selected row from the table (no extra fetch needed for MVP).

### 3.5 Actions menu

Per row: a ShadCN DropdownMenu (e.g. “Actions” or kebab icon with `aria-label`).

- **Edit** — link to `/products/[id]/edit` (or equivalent).
- **Archive** — opens ShadCN AlertDialog: “Archive this product? It will no longer appear on the storefront.” Confirm calls `products.archive({ id })`; on success, invalidate list and close dialog.

If “Restore” (draft/active from archived) is required later, add a mutation that patches status back to `draft` and an action in the menu for archived rows.

---

## 4. Create Product Page

**Route:** e.g. `apps/admin/src/app/(dashboard)/products/new/page.tsx`.

### 4.1 Form fields (aligned with schema)

**Required (non-optional in schema):**

- `name` — text
- `slug` — text (unique; consider auto-derive from name with “Edit” override)
- `status` — select: `active` | `draft` | `archived`
- `categoryId` — select; options from prefetched categories
- `tags` — array of strings (tag input or comma-separated; allow empty `[]`)
- `parentCategorySlug` / `childCategorySlug` — **do not** put in the form; backend derives them from `categoryId` in `products.create` / `products.update`

**Optional (storefront-useful):**

- `description` — textarea (rich text out of scope)
- `shortDescription` — textarea or short text
- `brand` — text
- `attributes` — structured fields (pet size, age range, diet, material, flavor) as per schema; can be a simple key-value or dedicated inputs

**System-managed (never from form):**

- `createdAt` / `updatedAt` — set in Convex only.
- `averageRating` / `reviewCount` — default 0; only changed by reviews system.

**Advanced (collapsed section):**

- `seoTitle`, `seoDescription`, `canonicalSlug` — optional; collapse under “Advanced” or “SEO” so the main form stays minimal.

### 4.2 Categories prefetch

- In the layout or page, fetch categories once (e.g. `categories.list({})` with no `parentId`, or with `parentId` if you only need leaves). Build a flat or tree structure for a select: value = `categoryId`, label = category name (and optionally slug). Use this for the **Category** dropdown on both create and edit.

### 4.3 Validation and submission

- Use **react-hook-form** with **zod**: require `name`, `slug`, `status`, `categoryId`, `tags` (array); optional fields as nullable or optional in the schema. On submit, call `products.create` with the payload; do not send `createdAt`/`updatedAt`. Redirect to list or edit page on success; toast on error.

### 4.4 Slug uniqueness

- Either derive slug from name (e.g. slugify) and rely on backend error if duplicate, or add a small Convex query that checks existence by slug and call it on blur (optional).

---

## 5. Edit Product Page

**Route:** e.g. `apps/admin/src/app/(dashboard)/products/[id]/edit/page.tsx`.

- Load product by id. If using only public APIs, use `products.list` with a filter or `products.search` and find by id, or add `products.getByIdForAdmin` (see 2.2) for a direct load. Pre-populate the same form as create; all required and optional fields that exist on the document.
- Submit calls `products.update({ id, ...updates })`. Only send changed or user-edited fields if you want partial updates; otherwise send full form state (backend ignores undefined).
- **Archive:** Same AlertDialog pattern as on the list page; after archive, redirect to list or show status updated.

---

## 6. ShadCN Components to Install

Install via CLI (no copy-paste). Example:

```bash
npx shadcn@latest add table button input dropdown-menu dialog alert-dialog badge skeleton select form label textarea checkbox
```

Ensure **Table**, **Dialog**, **AlertDialog**, **DropdownMenu**, **Form**, **Select**, **Badge**, **Skeleton**, **Button**, **Input** are available. Add **ScrollArea** for the preview dialog if content is long. Use relative imports (no `@/` in admin app).

---

## 7. File and Route Structure

- `app/(dashboard)/products/page.tsx` — list page (client component for table state, search, pagination).
- `app/(dashboard)/products/new/page.tsx` — create product (client form or server + client form).
- `app/(dashboard)/products/[id]/edit/page.tsx` — edit product.
- Shared: product form component (used by create and edit), product preview dialog component, and optionally a reusable products table (with columns configurable by columns dropdown). Place under `components/` as appropriate (e.g. `components/products/ProductForm.tsx`, `ProductPreviewDialog.tsx`, `ProductsTable.tsx`).

---

## 8. Implementation Order

1. **Backend** — Extend `products.create` and `products.update` with optional fields; set `createdAt`/`updatedAt` in mutations. Optionally add sort args to `products.list` and/or `products.getByIdForAdmin`.
2. **ShadCN** — Install required components.
3. **Categories** — Prefetch categories (query in layout or page) and pass into form; ensure category select works.
4. **List page** — Toolbar (search, columns dropdown, Create button), table with columns above, skeleton, pagination, then wire search (list vs search).
5. **Sort** — Wire sort to column headers (client-side or via backend).
6. **Preview dialog** — Name-cell trigger, read-only full product view from row data.
7. **Actions menu** — Edit link, Archive with AlertDialog and `products.archive`.
8. **Create page** — Form with required + optional fields, zod + react-hook-form, categories select, submit → `products.create`.
9. **Edit page** — Same form, load product, submit → `products.update`; archive from list or edit page.
10. **Polish** — Empty states (no products, no search results), a11y (labels, aria-labels on icon buttons), and any columns persistence.

---

## 9. Out of Scope (This Plan)

- Product images (upload, gallery, reorder).
- Variants (CRUD, stock, price).
- Bulk status change (multi-select + `products.bulkUpdateStatus`).
- SEO as first-class in the form (optional “Advanced” only).

This plan is the single reference for implementing the products feature in the admin dashboard for senior engineers; use Convex and ShadCN MCP/skills for API and component details as needed.

---

## 10. Completed Work

**Implemented:** 2026-03-05

### Backend
- [x] Extended `products.create` with optional fields: `shortDescription`, `brand`, `attributes` (petSize, ageRange, specialDiet, material, flavor), `seoTitle`, `seoDescription`, `canonicalSlug`
- [x] Extended `products.update` with the same optional fields
- [x] Both mutations set `createdAt` / `updatedAt` timestamps
- [x] Added `products.getByIdForAdmin` — admin-only query using `requireAdmin` + `getProductWithRelations`

### ShadCN Components Installed
- [x] `table`, `badge`, `skeleton`, `dropdown-menu`, `alert-dialog`, `dialog`, `scroll-area`, `form`, `select`, `input`, `textarea`, `label`, `checkbox`, `separator`, `collapsible`

### Files Created / Modified
- [x] `convex/products.ts` — extended `create`, `update`; added `getByIdForAdmin`
- [x] `src/app/(dashboard)/products/page.tsx` — full list page (search, column visibility, client-side sort, skeleton, load-more pagination, preview dialog, actions menu)
- [x] `src/components/products/ProductPreviewDialog.tsx` — read-only full-product dialog triggered from name cell
- [x] `src/components/products/ProductActionsMenu.tsx` — per-row kebab menu (Edit link + Archive with AlertDialog)
- [x] `src/components/products/ProductForm.tsx` — shared form (create + edit); zod schema, react-hook-form, auto-slug, collapsible Attributes + SEO sections, spinner on submit
- [x] `src/app/(dashboard)/products/new/page.tsx` — create product page
- [x] `src/app/(dashboard)/products/[id]/edit/page.tsx` — edit product page (pre-populated, archive button, loading/not-found states)

### Key Decisions
- Category select shows only leaf categories (those with a `parentId`) with "Parent / Child" labels
- Sort is client-side (current page only) — sufficient for the initial scope
- Submit button shows an inline SVG spinner (`data-icon="inline-start"`) during submission per updated CLAUDE.md rule
- Link-styled buttons use `<Link className={buttonVariants({...})}>` — the `Button` component's TypeScript type does not expose the `render` / `nativeButton` props from `@base-ui/react`
