Files
the-pet-loft/apps/admin/docs/05-variants-feature-implementation-plan.md
ianshaloom 8e4309892c feat(admin): implement variant management — list, create, edit, preview, activate/deactivate, delete (Plan 05)
- Extend addVariant with dimension fields and SKU uniqueness check; expand updateVariant to full field set; update getByIdForAdmin to return all variants (active + inactive)
- Add generateSku utility to @repo/utils; auto-generates SKU from brand, product name, attributes, and weight with manual-override support
- Move ProductSearchSection to components/shared and fix nav link /variants → /variant
- Variants page: product search, loading skeleton, variants table, toolbar with create button
- VariantsTable: 8 columns, activate/deactivate toggle, delete with AlertDialog confirmation
- VariantPreviewDialog: read-only full variant details with sections for pricing, inventory, shipping, attributes
- VariantForm: zod schema with superRefine for dimension and on-sale validation, auto-SKU generation
- CreateVariantDialog and EditVariantDialog wiring dollarsToCents on submit
- Install sonner and add Toaster to root layout; install ShadCN Switch component

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:32:32 +03:00

17 KiB
Raw Blame History

Variants Feature — Implementation Plan (Admin Dashboard)

Audience: Senior software engineers
Scope: Variants route only — product search (shared with images page), variants table per product, create/edit dialogs, preview dialog. No images or other product management in this plan.
References: Convex MCP, ShadCN UI MCP, .agent/skills/shadcn-ui, convex/schema.ts (productVariants), @repo/utils (dollarsToCents, centsToDollars), admin CLAUDE.md and admin-dashboard-ui rule.


1. Overview

Implement the Variant Management feature for the admin dashboard (checklist items 3.7, 3.8, 3.9), limited to:

  • Variants page (/variant) — title; shared product search section; on product select, fetch its variants and show them in a table with create-variant button (dialog), edit action (dialog), preview (dialog on variant name click), and actions column.
  • Shared component — Product search (debounced, inline results, max 3, click to select) is implemented once in apps/admin/src/components/shared and reused on both the Images page and the Variants page.

All UI must use ShadCN UI only. Data is served by existing Convex products.* APIs with backend extensions where noted below.


2. Shared Component: Product Search Section

Location: apps/admin/src/components/shared/ProductSearchSection.tsx (or similar name).

Behaviour (align with Images plan):

  • Search input — debounced (e.g. 300 ms). Query products.search({ query, limit: 3 }).
  • Results — rendered below the input (no floating overlay). Max 3 items. Each result: product name only, clickable to select.
  • API: Controlled or uncontrolled: either accept value / onChange (selected product id + product doc) from the parent, or expose a callback onProductSelect(product) so both Images and Variants pages can react (e.g. set selected product id and fetch images/variants).

Contract:

  • Props: at minimum onProductSelect: (product: { _id: Id<"products">; name: string; ... }) => void. Optional: placeholder, debounceMs, maxResults.
  • Use relative imports. No @/ in admin app.

Usage:

  • Images page<ProductSearchSection onProductSelect={...} />; on select, fetch product (e.g. getByIdForAdmin) and show image gallery.
  • Variants page — same; on select, fetch product and show variants table.

Implement this component once; refactor the Images page to use it when both features are in place (or implement it as shared from the start on the Variants page and then wire Images to it).


3. Backend (Convex) — Required Changes

3.1 Current API

API Purpose
products.search Product search; use with limit 3 for shared search section.
products.getByIdForAdmin Returns product with variants (and images, category). Use to load variants for the selected product.
products.addVariant Create variant. See args below.
products.updateVariant Update variant. Currently only accepts id, price?, compareAtPrice?, stockQuantity?, isActive?.
products.deleteVariant Delete (or soft-delete if variant appears in orderItems).

3.2 Extend products.addVariant

Current args: productId, name, sku, price, compareAtPrice?, stockQuantity, attributes?, isActive, weight?, weightUnit?.
Schema also has: length?, width?, height?, dimensionUnit?.

Required: Add optional dimension fields so the create form can persist them when the user fills dimensions.

  • length: v.optional(v.number())
  • width: v.optional(v.number())
  • height: v.optional(v.number())
  • dimensionUnit: v.optional(v.union(v.literal("cm"), v.literal("in")))

In the handler, pass these through to ctx.db.insert("productVariants", { ... }) only when defined. Ensure weight / weightUnit remain required in practice (addVariant already defaults weight ?? 0, weightUnit ?? "g"; schema allows it).

3.3 Extend products.updateVariant

Current args: id, price?, compareAtPrice?, stockQuantity?, isActive?.

For a full edit variant form, the admin must be able to change name, sku, weight, attributes, dimensions, etc. Extend the mutation to accept all editable fields:

  • name: v.optional(v.string())
  • sku: v.optional(v.string())
  • price: v.optional(v.number())
  • compareAtPrice: v.optional(v.number())
  • stockQuantity: v.optional(v.number())
  • isActive: v.optional(v.boolean())
  • weight: v.optional(v.number())
  • weightUnit: v.optional(v.union(v.literal("g"), v.literal("kg"), v.literal("lb"), v.literal("oz")))
  • attributes: v.optional(v.object({ size: v.optional(v.string()), flavor: v.optional(v.string()), color: v.optional(v.string()) }))
  • length: v.optional(v.number()), width: v.optional(v.number()), height: v.optional(v.number())
  • dimensionUnit: v.optional(v.union(v.literal("cm"), v.literal("in")))

Handler: patch only fields that are present in the updates object. If you introduce sku updates, consider enforcing uniqueness (e.g. check by_sku for another variant with same sku and different id).


4. Variants Page Layout and Behaviour

Route: apps/admin/src/app/(dashboard)/variant/page.tsx.

4.1 Structure (top to bottom)

  1. Title — e.g. “Variants”.
  2. Product search section — Use the shared ProductSearchSection. On select: store selectedProductId (and optionally the product doc); trigger fetch of product with variants (products.getByIdForAdmin(selectedProductId)).
  3. Toolbar above table (only when a product is selected):
    • Right-aligned: Create variant button. Click opens a dialog containing the create-variant form (product is fixed; productId is the selected product).
  4. Variants table (only when a product is selected):
    • Data: product.variants from getByIdForAdmin. No separate “list variants” query.
    • Columns: show only what an admin needs at first sight (see table below). Variant name cell is clickable and opens a preview dialog with full variant data (read-only).
    • Actions column (last): Edit (opens edit dialog), Delete (AlertDialog then products.deleteVariant). Use DropdownMenu or icon buttons with aria-labels.
  5. Create variant dialog — Form in a ShadCN Dialog. On success: call products.addVariant, invalidate/refetch product, close dialog, toast.
  6. Edit variant dialog — Same form fields, pre-populated from the selected variant. On success: call products.updateVariant, invalidate/refetch, close dialog, toast.
  7. Preview dialog — Triggered by clicking the variant name. Read-only view of all variant fields (name, sku, price, compareAtPrice, stock, isActive, weight, weightUnit, attributes, dimensions). Use ShadCN Dialog; no form.

4.2 Table columns (first sight)

Column Notes
Name Clickable; opens preview dialog.
SKU Unique identifier.
Price Display using formatPrice from @repo/utils (cents → currency string).
Compare at Optional; show “—” if empty.
Stock stockQuantity.
Status Badge: Active / Inactive from isActive.
Weight e.g. {weight} {weightUnit}.
Actions Edit, Delete (dropdown or icons).

Avoid cluttering the table with attributes/dimensions; those are in the preview dialog.

4.3 Empty and loading states

  • No product selected: show only title and search; optional short hint (“Search and select a product to manage variants”).
  • Product selected, variants loading: table skeleton (same column count, e.g. 510 skeleton rows).
  • Product selected, no variants: empty state message and prominent Create variant button.

5. Create / Edit Form — Fields and Validation

5.1 Required vs optional (schema-aligned)

Field Required Form / mutation notes
productId Yes Set by system; not in form (selected product).
name Yes Text.
sku Yes Text; unique (backend or zod + async check).
price Yes Display in dollars; convert to cents on submit with dollarsToCents from @repo/utils.
stockQuantity Yes Number; default 0 on create.
isActive Yes Boolean; default true on create.
weight Yes Number.
weightUnit Yes Select: g, kg, lb, oz. Default for UK context: kg.
compareAtPrice Optional Show only when “On Sale” toggle is on. Cents in DB; form in dollars.
attributes Optional size, flavor, color — only if product uses them.
length Optional Only if dimensions are used.
width Optional Same.
height Optional Same.
dimensionUnit Optional Only show when at least one of length/width/height is set. Default cm.

5.2 Minimum viable variant (create)

name + sku + price (dollars → cents) + stockQuantity + isActive + weight + weightUnit

Default stockQuantity: 0, isActive: true on create.

5.3 Form behaviour

  • Price: Single field in dollars (e.g. 19.99). On submit: dollarsToCents(value) before sending to Convex. Never ask the admin to type cents.
  • Compare at price: Only render this field when an “On Sale” (or “Has compare-at price”) toggle is true. Keeps the form simple when not on sale.
  • Dimension unit: Show only when at least one of length, width, height has a value. Use watch on those three fields (react-hook-form); default dimension unit cm.
  • Dimensions group: If one of length/width/height is filled, require all three (and dimension unit). Add a zod refinement: e.g. “If any dimension is set, all three and dimensionUnit must be set.”
  • Weight unit: Default kg for UK pet-store context (override default in form defaultValues).

5.4 Validation (zod)

  • Required: name (non-empty), sku (non-empty), price (positive number), stockQuantity (integer ≥ 0), isActive (boolean), weight (≥ 0), weightUnit (enum).
  • Optional: compareAtPrice (positive number when “On Sale”), attributes (object with optional size, flavor, color), length/width/height (positive numbers), dimensionUnit (when dimensions present).
  • Refinement: (length != null || width != null || height != null) ⇒ all three and dimensionUnit present.

Use react-hook-form with zodResolver and ShadCN Form/Input/Select/Switch components. Form lives inside the Create and Edit dialogs.


6. ShadCN Components

Ensure these are available (install via CLI if not):

  • Table, Button, Input, Select, Label, Form (with FormField, FormItem, FormControl, FormMessage), Dialog, AlertDialog, Badge, Skeleton, DropdownMenu.
npx shadcn@latest add table button input select form dialog alert-dialog badge skeleton dropdown-menu label

Use relative imports. Icon buttons (e.g. Edit, Delete) must have aria-label.


7. File and Component Structure

  • app/(dashboard)/variant/page.tsx — main page (client: selected product, variants data from getByIdForAdmin, create/edit dialog open state).
  • components/shared/ProductSearchSection.tsx — shared product search (used by /images and /variant).
  • Optional: components/variants/VariantsTable.tsx — table + name-cell preview trigger + actions column.
  • Optional: components/variants/VariantForm.tsx — shared form for create and edit (props: defaultValues, onSubmit, productId for create).
  • Optional: components/variants/VariantPreviewDialog.tsx — read-only full variant details.
  • Optional: components/variants/CreateVariantDialog.tsx and EditVariantDialog.tsx — dialogs that wrap the form and call addVariant/updateVariant.

8. Implementation Order

  1. Shared component — Implement ProductSearchSection in components/shared. Wire it on the Variants page with onProductSelect; optionally refactor Images page to use it.
  2. Backend — Extend addVariant with optional length, width, height, dimensionUnit. Extend updateVariant with full set of editable fields (name, sku, price, compareAtPrice, stockQuantity, isActive, weight, weightUnit, attributes, dimensions). Ensure SKU uniqueness on update if needed.
  3. Variants page shell — Title, ProductSearchSection, state for selected product. When product selected, fetch products.getByIdForAdmin(productId) and store product (with variants).
  4. Table — Render variants in ShadCN Table; columns: name (clickable), sku, price (formatPrice), compareAtPrice, stock, status (Badge), weight + unit, actions. Skeleton when loading; empty state when no variants.
  5. Preview dialog — Variant name click opens dialog; display all variant fields read-only.
  6. Create variant dialog — Button above table; dialog with form (name, sku, price in dollars, stock, isActive, weight, weightUnit [default kg], optional compare-at when “On Sale”, optional attributes, optional dimensions with refinement). Submit → dollarsToCents(price) (and compareAtPrice if present) → products.addVariant → refetch, close, toast.
  7. Edit variant dialog — Same form pre-filled; submit → products.updateVariant with changed fields; refetch, close, toast.
  8. Delete — Actions column: delete with AlertDialog confirmation → products.deleteVariant → refetch, toast.
  9. Polish — a11y (labels, aria-labels), error toasts, optional inline stock/price edit (if you want to keep 3.8/3.9 minimal without opening edit dialog).

9. Out of Scope (This Plan)

  • Images route changes (only reuse of ProductSearchSection).
  • Product create/edit or other product management.
  • Bulk variant operations.
  • Inline table editing (optional future: edit stock/price in cell; for now Edit opens the full form in a dialog).

This plan is the single reference for implementing the variants feature on the admin variant route for senior engineers. Use Convex and ShadCN MCP/skills for API and component details as needed.


10. Completed Implementation

Backend (convex/products.ts)

  • Extended addVariant with optional dimension fields (length, width, height, dimensionUnit) and SKU uniqueness check via by_sku index.
  • Extended updateVariant from 4 fields to full set: name, sku, price, compareAtPrice, stockQuantity, isActive, weight, weightUnit, attributes, dimensions. SKU uniqueness enforced on change.
  • Updated getByIdForAdmin to fetch all variants (not just active) using by_product index — admins need to see inactive variants.

Shared Component

  • components/shared/ProductSearchSection.tsx — moved from components/images/ to shared/, fixed @/ aliases to relative imports.
  • app/(dashboard)/images/page.tsx — updated import to shared location.
  • lib/constants/app.constants.ts — fixed nav link /variants/variant.

Variants Page (app/(dashboard)/variant/page.tsx)

  • Title, product search section, selectedProduct state, useQuery(getByIdForAdmin) skipped until product selected.
  • Loading skeleton (6 rows), empty state hint, product name + variant count header.
  • Create variant button in toolbar (hidden while loading).
  • All dialog open states managed in page: createOpen, editVariant, previewVariant.

components/variants/VariantsTable.tsx

  • 8 columns: Name (clickable → preview), SKU (monospace), Price (formatPrice), Compare at, Stock, Status badge, Weight, Actions.
  • VariantActionsMenu per row: Edit, Activate/Deactivate (toggles isActive via updateVariant), Delete (AlertDialog confirmation).
  • VariantsTableSkeleton — 6 skeleton rows for loading state.
  • Empty state — "No variants yet" spanning all columns.

components/variants/VariantPreviewDialog.tsx

  • Read-only dialog with ScrollArea. Sections: Core (SKU, status badge), Pricing, Inventory, Shipping (weight + dimensions if set), Attributes (size/flavor/color if set).

components/variants/VariantForm.tsx

  • Zod schema with superRefine: "On Sale" requires compareAtPrice; any dimension requires all three + dimensionUnit.
  • optionalPositiveNum preprocessor (cast to z.ZodType<number | undefined>) handles empty string → undefined for optional number inputs.
  • Required fields: name, sku, price (> 0), stockQuantity (≥ 0), isActive, weight (> 0), weightUnit.
  • Auto-SKU generation from brand + productName + variant attributes + weight using generateSku from @repo/utils. Only fires while skuManuallyEdited is false. Typing in SKU field locks it; resets to auto in create mode on defaultValues change.
  • Edit mode: skuManuallyEdited starts true — existing SKU never overwritten.
  • variantToFormValues() helper converts cents → dollars for price fields.
  • CREATE_DEFAULTS: isActive: true, weightUnit: "kg", stockQuantity: 0.

components/variants/CreateVariantDialog.tsx

  • Wraps VariantForm in Dialog + ScrollArea. Submit calls products.addVariant with dollarsToCents applied to price fields. Passes productName and brand for SKU generation.

components/variants/EditVariantDialog.tsx

  • Pre-fills form via variantToFormValues(variant). Submit calls products.updateVariant. Passes productName and brand.

packages/utils/src/index.ts

  • Added generateSku(brand, productName, attributes?, weight?, weightUnit?) — cleans each word to 4 uppercase chars, joins with -.

app/layout.tsx

  • Added <Toaster richColors position="bottom-right" /> (sonner). Installed sonner package in admin workspace.