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>
This commit is contained in:
@@ -60,9 +60,9 @@ Features are grouped into **MVP** (required for launch) and **Post-MVP** (phased
|
||||
| 3.4 | Archive/restore product | `[x]` UI | Backend: `products.archive`. Confirmation dialog. Restore via edit status field. |
|
||||
| 3.5 | Product image upload | `[x]` BE+UI | Cloudinary server-side upload via Next.js API route (`/api/upload-image`). Background removal (Image Processing API) with dual upload: processed or original. Structured `public_id` + `asset_folder` per product. |
|
||||
| 3.6 | Image gallery management | `[x]` UI | Drag-and-drop reorder (`@dnd-kit`) with `reorderImages` mutation. Per-image delete with AlertDialog. "Add more" tile. Search-driven product selection with auto-clear. |
|
||||
| 3.7 | Variant management | `[~]` UI | Backend: `addVariant`, `updateVariant`, `deleteVariant`. Inline table or modal for CRUD. |
|
||||
| 3.8 | Stock quantity editing | `[~]` UI | Backend: `updateVariant` accepts `stockQuantity`. Inline edit in variant table. |
|
||||
| 3.9 | Price and compare-at-price editing | `[~]` UI | Backend: `updateVariant` accepts `price`, `compareAtPrice`. |
|
||||
| 3.7 | Variant management | `[x]` UI | Variants page with table, create/edit/preview dialogs, activate/deactivate, delete with AlertDialog. |
|
||||
| 3.8 | Stock quantity editing | `[x]` UI | `stockQuantity` editable in full variant edit dialog. |
|
||||
| 3.9 | Price and compare-at-price editing | `[x]` UI | `price` and `compareAtPrice` (behind On Sale toggle) editable in variant form. |
|
||||
| 3.10 | Product SEO fields (title, description) | `[x]` UI | `seoTitle`, `seoDescription`, `canonicalSlug` in collapsible Advanced/SEO section. |
|
||||
| 3.11 | Product search within admin | `[x]` UI | Debounced search bar on list page; switches between `products.list` and `products.search`. |
|
||||
| 3.12 | Bulk status change (draft → active, etc.) | `[ ]` BE+UI | New mutation: `products.bulkUpdateStatus`. Multi-select in table. |
|
||||
|
||||
276
apps/admin/docs/05-variants-feature-implementation-plan.md
Normal file
276
apps/admin/docs/05-variants-feature-implementation-plan.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# 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. 5–10 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.
|
||||
|
||||
```bash
|
||||
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.
|
||||
@@ -28,6 +28,7 @@
|
||||
"lucide-react": "^0.400.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.6.1",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { api } from "../../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../../convex/_generated/dataModel"
|
||||
import { ProductSearchSection } from "../../../components/images/ProductSearchSection"
|
||||
import { ProductSearchSection } from "../../../components/shared/ProductSearchSection"
|
||||
import { ProductImageCarousel } from "../../../components/images/ProductImageCarousel"
|
||||
import { ImageUploadSection } from "../../../components/images/ImageUploadSection"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
144
apps/admin/src/app/(dashboard)/variant/page.tsx
Normal file
144
apps/admin/src/app/(dashboard)/variant/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { api } from "../../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../../convex/_generated/dataModel"
|
||||
import { ProductSearchSection } from "../../../components/shared/ProductSearchSection"
|
||||
import { VariantsTable, VariantsTableSkeleton, type Variant } from "../../../components/variants/VariantsTable"
|
||||
import { VariantPreviewDialog } from "../../../components/variants/VariantPreviewDialog"
|
||||
import { CreateVariantDialog } from "../../../components/variants/CreateVariantDialog"
|
||||
import { EditVariantDialog } from "../../../components/variants/EditVariantDialog"
|
||||
import { Separator } from "../../../components/ui/separator"
|
||||
import { Button } from "../../../components/ui/button"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
} from "../../../components/ui/table"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { AddCircleIcon } from "@hugeicons/core-free-icons"
|
||||
|
||||
interface SelectedProduct {
|
||||
_id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export default function VariantsPage() {
|
||||
const [selectedProduct, setSelectedProduct] = useState<SelectedProduct | null>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editVariant, setEditVariant] = useState<Variant | null>(null)
|
||||
const [previewVariant, setPreviewVariant] = useState<Variant | null>(null)
|
||||
|
||||
const productData = useQuery(
|
||||
api.products.getByIdForAdmin,
|
||||
selectedProduct ? { id: selectedProduct._id as Id<"products"> } : "skip",
|
||||
)
|
||||
|
||||
function handleProductSelect(product: SelectedProduct) {
|
||||
setSelectedProduct(product)
|
||||
}
|
||||
|
||||
function handleSearchClear() {
|
||||
setSelectedProduct(null)
|
||||
}
|
||||
|
||||
const variants = (productData?.variants ?? []) as Variant[]
|
||||
const isLoading = selectedProduct !== null && productData === undefined
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
|
||||
<h1 className="text-xl font-semibold">Variants</h1>
|
||||
|
||||
{/* Product search */}
|
||||
<section className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Select a product</p>
|
||||
<ProductSearchSection
|
||||
onSelect={handleProductSelect}
|
||||
onClear={handleSearchClear}
|
||||
selectedId={selectedProduct?._id}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* No product selected */}
|
||||
{!selectedProduct && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Search and select a product to manage its variants.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Product selected */}
|
||||
{selectedProduct && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<section className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">{selectedProduct.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isLoading
|
||||
? "Loading variants…"
|
||||
: `${variants.length} variant${variants.length !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
{!isLoading && (
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<HugeiconsIcon icon={AddCircleIcon} strokeWidth={2} />
|
||||
Create variant
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{isLoading && (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableBody>
|
||||
<VariantsTableSkeleton />
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Variants table */}
|
||||
{!isLoading && productData && (
|
||||
<VariantsTable
|
||||
variants={variants}
|
||||
onPreview={(v) => setPreviewVariant(v)}
|
||||
onEdit={(v) => setEditVariant(v)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Preview dialog */}
|
||||
<VariantPreviewDialog
|
||||
variant={previewVariant}
|
||||
open={previewVariant !== null}
|
||||
onOpenChange={(open) => { if (!open) setPreviewVariant(null) }}
|
||||
/>
|
||||
|
||||
{/* Create dialog */}
|
||||
{selectedProduct && (
|
||||
<CreateVariantDialog
|
||||
productId={selectedProduct._id as Id<"products">}
|
||||
productName={selectedProduct.name}
|
||||
brand={productData?.brand}
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit dialog */}
|
||||
<EditVariantDialog
|
||||
variant={editVariant}
|
||||
productName={selectedProduct?.name ?? ""}
|
||||
brand={productData?.brand}
|
||||
open={editVariant !== null}
|
||||
onOpenChange={(open) => { if (!open) setEditVariant(null) }}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { DM_Sans, Geist, Geist_Mono } from "next/font/google";
|
||||
import { ClerkProvider } from "@clerk/nextjs";
|
||||
import { ConvexClientProvider } from "@repo/convex";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const dmSans = DM_Sans({subsets:['latin'],variable:'--font-sans'});
|
||||
@@ -35,6 +36,7 @@ export default function RootLayout({
|
||||
<ClerkProvider>
|
||||
<ConvexClientProvider>
|
||||
{children}
|
||||
<Toaster richColors position="bottom-right" />
|
||||
</ConvexClientProvider>
|
||||
</ClerkProvider>
|
||||
</body>
|
||||
|
||||
104
apps/admin/src/components/shared/ProductSearchSection.tsx
Normal file
104
apps/admin/src/components/shared/ProductSearchSection.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { api } from "../../../../../convex/_generated/api"
|
||||
import { Input } from "../ui/input"
|
||||
import { Skeleton } from "../ui/skeleton"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { Search01Icon, Cancel01Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
interface SearchProduct {
|
||||
_id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface ProductSearchSectionProps {
|
||||
onSelect: (product: SearchProduct) => void
|
||||
onClear: () => void
|
||||
selectedId?: string
|
||||
}
|
||||
|
||||
export function ProductSearchSection({ onSelect, onClear, selectedId }: ProductSearchSectionProps) {
|
||||
const [input, setInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setQuery(input), 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [input])
|
||||
|
||||
const isSearching = query.trim().length > 0
|
||||
|
||||
const results = useQuery(
|
||||
api.products.search,
|
||||
isSearching ? { query, limit: 3 } : "skip",
|
||||
)
|
||||
|
||||
const isLoading = isSearching && results === undefined
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="relative max-w-sm">
|
||||
<HugeiconsIcon
|
||||
icon={Search01Icon}
|
||||
strokeWidth={2}
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search products…"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value)
|
||||
if (e.target.value === "") onClear()
|
||||
}}
|
||||
className="pl-8 pr-8"
|
||||
/>
|
||||
{input && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setInput("")
|
||||
setQuery("")
|
||||
onClear()
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="max-w-sm space-y-1">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-9 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && isSearching && results && results.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No products match “{query}”.</p>
|
||||
)}
|
||||
|
||||
{!isLoading && results && results.length > 0 && (
|
||||
<ul className="max-w-sm divide-y rounded-md border">
|
||||
{results.map((product: any) => (
|
||||
<li key={product._id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect({ _id: product._id, name: product.name })}
|
||||
className={`w-full px-3 py-2 text-left text-sm transition-colors hover:bg-muted ${
|
||||
selectedId === product._id ? "bg-muted font-medium" : ""
|
||||
}`}
|
||||
>
|
||||
{product.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
apps/admin/src/components/ui/switch.tsx
Normal file
32
apps/admin/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: SwitchPrimitive.Root.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
126
apps/admin/src/components/variants/CreateVariantDialog.tsx
Normal file
126
apps/admin/src/components/variants/CreateVariantDialog.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { api } from "../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../convex/_generated/dataModel"
|
||||
import { dollarsToCents } from "@repo/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog"
|
||||
import { Button } from "../ui/button"
|
||||
import { ScrollArea } from "../ui/scroll-area"
|
||||
import { VariantForm, CREATE_DEFAULTS, type VariantFormValues } from "./VariantForm"
|
||||
|
||||
const FORM_ID = "create-variant-form"
|
||||
|
||||
interface CreateVariantDialogProps {
|
||||
productId: Id<"products">
|
||||
productName: string
|
||||
brand?: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function CreateVariantDialog({
|
||||
productId,
|
||||
productName,
|
||||
brand,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CreateVariantDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const addVariant = useMutation(api.products.addVariant)
|
||||
|
||||
async function handleSubmit(values: VariantFormValues) {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const attrSize = values.attrSize?.trim() || undefined
|
||||
const attrFlavor = values.attrFlavor?.trim() || undefined
|
||||
const attrColor = values.attrColor?.trim() || undefined
|
||||
const hasAttrs = attrSize || attrFlavor || attrColor
|
||||
|
||||
await addVariant({
|
||||
productId,
|
||||
name: values.name,
|
||||
sku: values.sku,
|
||||
price: dollarsToCents(values.price),
|
||||
compareAtPrice: values.onSale && values.compareAtPrice != null
|
||||
? dollarsToCents(values.compareAtPrice)
|
||||
: undefined,
|
||||
stockQuantity: values.stockQuantity,
|
||||
isActive: values.isActive,
|
||||
weight: values.weight,
|
||||
weightUnit: values.weightUnit,
|
||||
attributes: hasAttrs
|
||||
? { size: attrSize, flavor: attrFlavor, color: attrColor }
|
||||
: undefined,
|
||||
length: values.length,
|
||||
width: values.width,
|
||||
height: values.height,
|
||||
dimensionUnit: values.dimensionUnit,
|
||||
})
|
||||
|
||||
toast.success(`Variant "${values.name}" created`)
|
||||
onOpenChange(false)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to create variant")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create variant</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[65vh] pr-1">
|
||||
<div className="pb-2">
|
||||
<VariantForm
|
||||
formId={FORM_ID}
|
||||
defaultValues={CREATE_DEFAULTS}
|
||||
onSubmit={handleSubmit}
|
||||
productName={productName}
|
||||
brand={brand}
|
||||
mode="create"
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={FORM_ID} disabled={isSubmitting}>
|
||||
{isSubmitting && (
|
||||
<svg
|
||||
data-icon="inline-start"
|
||||
className="animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
{isSubmitting ? "Creating…" : "Create variant"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
129
apps/admin/src/components/variants/EditVariantDialog.tsx
Normal file
129
apps/admin/src/components/variants/EditVariantDialog.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { api } from "../../../../../convex/_generated/api"
|
||||
import { dollarsToCents } from "@repo/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog"
|
||||
import { Button } from "../ui/button"
|
||||
import { ScrollArea } from "../ui/scroll-area"
|
||||
import { VariantForm, variantToFormValues, type VariantFormValues } from "./VariantForm"
|
||||
import type { Variant } from "./VariantsTable"
|
||||
|
||||
const FORM_ID = "edit-variant-form"
|
||||
|
||||
interface EditVariantDialogProps {
|
||||
variant: Variant | null
|
||||
productName: string
|
||||
brand?: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function EditVariantDialog({
|
||||
variant,
|
||||
productName,
|
||||
brand,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: EditVariantDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const updateVariant = useMutation(api.products.updateVariant)
|
||||
|
||||
async function handleSubmit(values: VariantFormValues) {
|
||||
if (!variant) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const attrSize = values.attrSize?.trim() || undefined
|
||||
const attrFlavor = values.attrFlavor?.trim() || undefined
|
||||
const attrColor = values.attrColor?.trim() || undefined
|
||||
const hasAttrs = attrSize || attrFlavor || attrColor
|
||||
|
||||
await updateVariant({
|
||||
id: variant._id,
|
||||
name: values.name,
|
||||
sku: values.sku,
|
||||
price: dollarsToCents(values.price),
|
||||
compareAtPrice: values.onSale && values.compareAtPrice != null
|
||||
? dollarsToCents(values.compareAtPrice)
|
||||
: undefined,
|
||||
stockQuantity: values.stockQuantity,
|
||||
isActive: values.isActive,
|
||||
weight: values.weight,
|
||||
weightUnit: values.weightUnit,
|
||||
attributes: hasAttrs
|
||||
? { size: attrSize, flavor: attrFlavor, color: attrColor }
|
||||
: undefined,
|
||||
length: values.length,
|
||||
width: values.width,
|
||||
height: values.height,
|
||||
dimensionUnit: values.dimensionUnit,
|
||||
})
|
||||
|
||||
toast.success(`"${values.name}" updated`)
|
||||
onOpenChange(false)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to update variant")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!variant) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit variant</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[65vh] pr-1">
|
||||
<div className="pb-2">
|
||||
<VariantForm
|
||||
formId={FORM_ID}
|
||||
defaultValues={variantToFormValues(variant)}
|
||||
onSubmit={handleSubmit}
|
||||
productName={productName}
|
||||
brand={brand}
|
||||
mode="edit"
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={FORM_ID} disabled={isSubmitting}>
|
||||
{isSubmitting && (
|
||||
<svg
|
||||
data-icon="inline-start"
|
||||
className="animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
{isSubmitting ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
486
apps/admin/src/components/variants/VariantForm.tsx
Normal file
486
apps/admin/src/components/variants/VariantForm.tsx
Normal file
@@ -0,0 +1,486 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { centsToDollars, generateSku } from "@repo/utils"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "../ui/form"
|
||||
import { Input } from "../ui/input"
|
||||
import { Switch } from "../ui/switch"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select"
|
||||
import { Separator } from "../ui/separator"
|
||||
import type { Variant } from "./VariantsTable"
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const optionalPositiveNum = z.preprocess(
|
||||
(v) => (v === "" || v == null ? undefined : Number(v)),
|
||||
z.number().positive().optional(),
|
||||
) as z.ZodType<number | undefined>
|
||||
|
||||
export const variantFormSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
sku: z.string().min(1, "SKU is required"),
|
||||
price: z.coerce.number({ invalid_type_error: "Enter a valid price" }).positive("Price must be greater than 0"),
|
||||
stockQuantity: z.coerce.number({ invalid_type_error: "Enter a valid number" }).int().min(0, "Stock must be 0 or more"),
|
||||
isActive: z.boolean(),
|
||||
weight: z.coerce.number({ invalid_type_error: "Enter a valid weight" }).positive("Weight must be greater than 0"),
|
||||
weightUnit: z.enum(["g", "kg", "lb", "oz"]),
|
||||
onSale: z.boolean(),
|
||||
compareAtPrice: optionalPositiveNum,
|
||||
attrSize: z.string().optional(),
|
||||
attrFlavor: z.string().optional(),
|
||||
attrColor: z.string().optional(),
|
||||
length: optionalPositiveNum,
|
||||
width: optionalPositiveNum,
|
||||
height: optionalPositiveNum,
|
||||
dimensionUnit: z.enum(["cm", "in"]).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.onSale && !data.compareAtPrice) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["compareAtPrice"],
|
||||
message: "Compare at price is required when on sale",
|
||||
})
|
||||
}
|
||||
const hasDim = data.length != null || data.width != null || data.height != null
|
||||
if (hasDim) {
|
||||
if (data.length == null)
|
||||
ctx.addIssue({ code: "custom", path: ["length"], message: "Required when any dimension is set" })
|
||||
if (data.width == null)
|
||||
ctx.addIssue({ code: "custom", path: ["width"], message: "Required when any dimension is set" })
|
||||
if (data.height == null)
|
||||
ctx.addIssue({ code: "custom", path: ["height"], message: "Required when any dimension is set" })
|
||||
if (!data.dimensionUnit)
|
||||
ctx.addIssue({ code: "custom", path: ["dimensionUnit"], message: "Required when dimensions are set" })
|
||||
}
|
||||
})
|
||||
|
||||
export type VariantFormValues = z.infer<typeof variantFormSchema>
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function variantToFormValues(variant: Variant): VariantFormValues {
|
||||
return {
|
||||
name: variant.name,
|
||||
sku: variant.sku,
|
||||
price: centsToDollars(variant.price),
|
||||
stockQuantity: variant.stockQuantity,
|
||||
isActive: variant.isActive,
|
||||
weight: variant.weight,
|
||||
weightUnit: variant.weightUnit,
|
||||
onSale: variant.compareAtPrice != null,
|
||||
compareAtPrice: variant.compareAtPrice != null ? centsToDollars(variant.compareAtPrice) : undefined,
|
||||
attrSize: variant.attributes?.size ?? "",
|
||||
attrFlavor: variant.attributes?.flavor ?? "",
|
||||
attrColor: variant.attributes?.color ?? "",
|
||||
length: variant.length,
|
||||
width: variant.width,
|
||||
height: variant.height,
|
||||
dimensionUnit: variant.dimensionUnit ?? "cm",
|
||||
}
|
||||
}
|
||||
|
||||
export const CREATE_DEFAULTS: VariantFormValues = {
|
||||
name: "",
|
||||
sku: "",
|
||||
price: 0,
|
||||
stockQuantity: 0,
|
||||
isActive: true,
|
||||
weight: 0,
|
||||
weightUnit: "kg",
|
||||
onSale: false,
|
||||
compareAtPrice: undefined,
|
||||
attrSize: "",
|
||||
attrFlavor: "",
|
||||
attrColor: "",
|
||||
length: undefined,
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
dimensionUnit: "cm",
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface VariantFormProps {
|
||||
formId: string
|
||||
defaultValues: VariantFormValues
|
||||
onSubmit: (values: VariantFormValues) => Promise<void>
|
||||
productName?: string
|
||||
brand?: string
|
||||
/** In edit mode the SKU starts as manually set — auto-gen is off by default */
|
||||
mode?: "create" | "edit"
|
||||
}
|
||||
|
||||
export function VariantForm({
|
||||
formId,
|
||||
defaultValues,
|
||||
onSubmit,
|
||||
productName = "",
|
||||
brand = "",
|
||||
mode = "create",
|
||||
}: VariantFormProps) {
|
||||
const [skuManuallyEdited, setSkuManuallyEdited] = useState(mode === "edit")
|
||||
|
||||
const form = useForm<VariantFormValues>({
|
||||
resolver: zodResolver(variantFormSchema),
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
// Reset when defaultValues change (e.g. switching from one variant to another)
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues)
|
||||
setSkuManuallyEdited(mode === "edit")
|
||||
}, [defaultValues]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Auto-generate SKU from variant-level details only
|
||||
const [attrFlavor, attrSize, attrColor, weight, weightUnit] = form.watch([
|
||||
"attrFlavor",
|
||||
"attrSize",
|
||||
"attrColor",
|
||||
"weight",
|
||||
"weightUnit",
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (skuManuallyEdited) return
|
||||
const suggested = generateSku(
|
||||
brand,
|
||||
productName,
|
||||
{ flavor: attrFlavor, size: attrSize, color: attrColor },
|
||||
weight || undefined,
|
||||
weightUnit,
|
||||
)
|
||||
if (suggested) form.setValue("sku", suggested)
|
||||
}, [brand, productName, attrFlavor, attrSize, attrColor, weight, weightUnit, skuManuallyEdited]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const onSale = form.watch("onSale")
|
||||
const length = form.watch("length")
|
||||
const width = form.watch("width")
|
||||
const height = form.watch("height")
|
||||
const showDimensionUnit = length != null || width != null || height != null
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
{/* ── Identity ─────────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 items-start gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="500g bag" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sku"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SKU</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="RC-ADG-5KG"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
setSkuManuallyEdited(true)
|
||||
field.onChange(e.target.value.toUpperCase())
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{skuManuallyEdited ? "Manually set." : "Auto-generated from details."}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Pricing ──────────────────────────────────────────────── */}
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Pricing</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Price (£)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.01" min="0" placeholder="19.99" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="onSale"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col justify-end pb-1">
|
||||
<FormLabel>On sale</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{field.value ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{onSale && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="compareAtPrice"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Compare at price (£)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="24.99"
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Original price shown as strikethrough.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Inventory ────────────────────────────────────────────── */}
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Inventory</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="stockQuantity"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Stock quantity</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" min="0" step="1" placeholder="0" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col justify-end pb-1">
|
||||
<FormLabel>Active</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{field.value ? "Visible" : "Hidden"}
|
||||
</span>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Shipping ─────────────────────────────────────────────── */}
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Shipping</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="weight"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Weight</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.001" min="0" placeholder="0.5" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="weightUnit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Unit</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="kg">kg</SelectItem>
|
||||
<SelectItem value="g">g</SelectItem>
|
||||
<SelectItem value="lb">lb</SelectItem>
|
||||
<SelectItem value="oz">oz</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dimensions */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(["length", "width", "height"] as const).map((dim) => (
|
||||
<FormField
|
||||
key={dim}
|
||||
control={form.control}
|
||||
name={dim}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="capitalize">{dim}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
placeholder="—"
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showDimensionUnit && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dimensionUnit"
|
||||
render={({ field }) => (
|
||||
<FormItem className="max-w-[120px]">
|
||||
<FormLabel>Dimension unit</FormLabel>
|
||||
<Select value={field.value ?? "cm"} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="cm">cm</SelectItem>
|
||||
<SelectItem value="in">in</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Attributes (optional) ────────────────────────────────── */}
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Attributes <span className="normal-case font-normal">(optional)</span>
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="attrSize"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Size</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="500g" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="attrFlavor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Flavor</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Chicken" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="attrColor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Color</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Red" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
143
apps/admin/src/components/variants/VariantPreviewDialog.tsx
Normal file
143
apps/admin/src/components/variants/VariantPreviewDialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import { formatPrice } from "@repo/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog"
|
||||
import { Badge } from "../ui/badge"
|
||||
import { ScrollArea } from "../ui/scroll-area"
|
||||
import { Separator } from "../ui/separator"
|
||||
import type { Variant } from "./VariantsTable"
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
if (children == null || children === "") return null
|
||||
return (
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2 py-0.5">
|
||||
<span className="pt-0.5 text-xs text-muted-foreground">{label}</span>
|
||||
<span className="break-words text-sm">{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface VariantPreviewDialogProps {
|
||||
variant: Variant | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function VariantPreviewDialog({
|
||||
variant,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: VariantPreviewDialogProps) {
|
||||
if (!variant) return null
|
||||
|
||||
const hasDimensions =
|
||||
variant.length != null || variant.width != null || variant.height != null
|
||||
|
||||
const hasAttributes =
|
||||
variant.attributes?.size ||
|
||||
variant.attributes?.flavor ||
|
||||
variant.attributes?.color
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{variant.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="space-y-3 py-1">
|
||||
{/* Core */}
|
||||
<div className="space-y-0.5">
|
||||
<InfoRow label="SKU">
|
||||
<span className="font-mono text-xs">{variant.sku}</span>
|
||||
</InfoRow>
|
||||
<InfoRow label="Status">
|
||||
<Badge variant={variant.isActive ? "default" : "secondary"}>
|
||||
{variant.isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</InfoRow>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="space-y-0.5">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Pricing
|
||||
</p>
|
||||
<InfoRow label="Price">{formatPrice(variant.price)}</InfoRow>
|
||||
<InfoRow label="Compare at">
|
||||
{variant.compareAtPrice ? formatPrice(variant.compareAtPrice) : "—"}
|
||||
</InfoRow>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Inventory */}
|
||||
<div className="space-y-0.5">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Inventory
|
||||
</p>
|
||||
<InfoRow label="Stock quantity">{variant.stockQuantity}</InfoRow>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Shipping */}
|
||||
<div className="space-y-0.5">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Shipping
|
||||
</p>
|
||||
<InfoRow label="Weight">
|
||||
{variant.weight} {variant.weightUnit}
|
||||
</InfoRow>
|
||||
{hasDimensions && (
|
||||
<InfoRow label="Dimensions">
|
||||
{[variant.length, variant.width, variant.height]
|
||||
.map((d) => d ?? "?")
|
||||
.join(" × ")}{" "}
|
||||
{variant.dimensionUnit ?? "cm"}
|
||||
</InfoRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasAttributes && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-0.5">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Attributes
|
||||
</p>
|
||||
{variant.attributes?.size && (
|
||||
<InfoRow label="Size">{variant.attributes.size}</InfoRow>
|
||||
)}
|
||||
{variant.attributes?.flavor && (
|
||||
<InfoRow label="Flavor">{variant.attributes.flavor}</InfoRow>
|
||||
)}
|
||||
{variant.attributes?.color && (
|
||||
<InfoRow label="Color">{variant.attributes.color}</InfoRow>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter showCloseButton />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
259
apps/admin/src/components/variants/VariantsTable.tsx
Normal file
259
apps/admin/src/components/variants/VariantsTable.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { api } from "../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../convex/_generated/dataModel"
|
||||
import { formatPrice } from "@repo/utils"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../ui/table"
|
||||
import { Badge } from "../ui/badge"
|
||||
import { Button } from "../ui/button"
|
||||
import { Skeleton } from "../ui/skeleton"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "../ui/alert-dialog"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import {
|
||||
MoreVerticalIcon,
|
||||
PencilEdit01Icon,
|
||||
Delete01Icon,
|
||||
CheckmarkCircle01Icon,
|
||||
Cancel01Icon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
|
||||
export interface Variant {
|
||||
_id: Id<"productVariants">
|
||||
name: string
|
||||
sku: string
|
||||
price: number
|
||||
compareAtPrice?: number
|
||||
stockQuantity: number
|
||||
isActive: boolean
|
||||
weight: number
|
||||
weightUnit: "g" | "kg" | "lb" | "oz"
|
||||
attributes?: { size?: string; flavor?: string; color?: string }
|
||||
length?: number
|
||||
width?: number
|
||||
height?: number
|
||||
dimensionUnit?: "cm" | "in"
|
||||
}
|
||||
|
||||
interface VariantsTableProps {
|
||||
variants: Variant[]
|
||||
onPreview: (variant: Variant) => void
|
||||
onEdit: (variant: Variant) => void
|
||||
}
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-10" /></TableCell>
|
||||
<TableCell><Skeleton className="h-5 w-16 rounded-full" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
||||
<TableCell><Skeleton className="size-7 rounded-lg" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function VariantActionsMenu({
|
||||
variant,
|
||||
onEdit,
|
||||
}: {
|
||||
variant: Variant
|
||||
onEdit: (variant: Variant) => void
|
||||
}) {
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isToggling, setIsToggling] = useState(false)
|
||||
const deleteVariant = useMutation(api.products.deleteVariant)
|
||||
const updateVariant = useMutation(api.products.updateVariant)
|
||||
|
||||
async function handleToggleActive() {
|
||||
setIsToggling(true)
|
||||
try {
|
||||
await updateVariant({ id: variant._id, isActive: !variant.isActive })
|
||||
toast.success(`"${variant.name}" ${variant.isActive ? "deactivated" : "activated"}`)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to update variant")
|
||||
} finally {
|
||||
setIsToggling(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteVariant({ id: variant._id })
|
||||
toast.success(`"${variant.name}" deleted`)
|
||||
setDeleteOpen(false)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to delete variant")
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Actions for ${variant.name}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon icon={MoreVerticalIcon} strokeWidth={2} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(variant)}>
|
||||
<HugeiconsIcon icon={PencilEdit01Icon} strokeWidth={2} />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleToggleActive}
|
||||
disabled={isToggling}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={variant.isActive ? Cancel01Icon : CheckmarkCircle01Icon}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{variant.isActive ? "Deactivate" : "Activate"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete01Icon} strokeWidth={2} />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete “{variant.name}”?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{variant.isActive
|
||||
? "This variant will be permanently deleted. If it appears in existing orders, it will be deactivated instead."
|
||||
: "This variant will be permanently deleted."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting…" : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function VariantsTable({ variants, onPreview, onEdit }: VariantsTableProps) {
|
||||
return (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">Name</TableHead>
|
||||
<TableHead scope="col">SKU</TableHead>
|
||||
<TableHead scope="col">Price</TableHead>
|
||||
<TableHead scope="col">Compare at</TableHead>
|
||||
<TableHead scope="col">Stock</TableHead>
|
||||
<TableHead scope="col">Status</TableHead>
|
||||
<TableHead scope="col">Weight</TableHead>
|
||||
<TableHead scope="col" className="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{variants.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={8}
|
||||
className="py-16 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
No variants yet. Create the first variant for this product.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
variants.map((variant) => (
|
||||
<TableRow key={variant._id}>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium hover:underline text-left"
|
||||
onClick={() => onPreview(variant)}
|
||||
>
|
||||
{variant.name}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{variant.sku}
|
||||
</TableCell>
|
||||
<TableCell>{formatPrice(variant.price)}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{variant.compareAtPrice ? formatPrice(variant.compareAtPrice) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>{variant.stockQuantity}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={variant.isActive ? "default" : "secondary"}>
|
||||
{variant.isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{variant.weight} {variant.weightUnit}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<VariantActionsMenu variant={variant} onEdit={onEdit} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { TableSkeleton as VariantsTableSkeleton }
|
||||
@@ -59,11 +59,11 @@ export const NAV_LINKS = {
|
||||
},
|
||||
{
|
||||
title: "Images",
|
||||
url: "/products/images",
|
||||
url: "/images",
|
||||
},
|
||||
{
|
||||
title: "Variants",
|
||||
url: "/products/variants",
|
||||
url: "/variant",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user