feat(admin): implement product management — list, create, edit, archive (Plan 03)

Covers checklist items 3.1–3.4, 3.10–3.11 (product list, create, edit,
archive/restore, SEO fields, admin search).

Backend (convex/products.ts):
- Extended create/update with shortDescription, brand, attributes,
  seoTitle, seoDescription, canonicalSlug
- Both mutations now set createdAt/updatedAt timestamps
- Added getByIdForAdmin (admin-only, returns full product with relations)

UI — new pages:
- products/page.tsx: table with debounced search, column visibility
  dropdown, client-side sort, 10-row skeleton, load-more pagination,
  row preview dialog, per-row actions menu
- products/new/page.tsx: create product page
- products/[id]/edit/page.tsx: pre-populated edit page with archive button

UI — new components:
- ProductForm: shared form (create + edit); zod + react-hook-form,
  auto-slug, collapsible Attributes + SEO sections, submit spinner
- ProductPreviewDialog: read-only full-product dialog
- ProductActionsMenu: kebab menu (Edit link + Archive AlertDialog)

ShadCN components installed: table, badge, alert-dialog, dialog,
scroll-area, form, select, label, checkbox, textarea

Also:
- Updated CLAUDE.md: form submit buttons must use inline SVG spinner
  with data-icon="inline-start"; link-styled buttons use buttonVariants
  on <Link> (Button render prop not in TS types)
- Updated docs: checklist and plan marked complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 17:38:13 +03:00
parent 2dc8878db7
commit 5168553bae
39 changed files with 5209 additions and 498 deletions

View File

@@ -557,6 +557,14 @@ export const getById = internalQuery({
},
});
export const getByIdForAdmin = query({
args: { id: v.id("products") },
handler: async (ctx, { id }) => {
await Users.requireAdmin(ctx);
return getProductWithRelations(ctx, id);
},
});
/**
* One-time migration: backfill parentCategorySlug, childCategorySlug, topCategorySlug
* from categories. Run once after deploying Phase 1 schema.
@@ -704,13 +712,27 @@ export const create = mutation({
name: v.string(),
slug: v.string(),
description: v.optional(v.string()),
shortDescription: v.optional(v.string()),
status: v.union(
v.literal("active"),
v.literal("draft"),
v.literal("archived"),
),
categoryId: v.id("categories"),
brand: v.optional(v.string()),
tags: v.array(v.string()),
attributes: v.optional(
v.object({
petSize: v.optional(v.array(v.string())),
ageRange: v.optional(v.array(v.string())),
specialDiet: v.optional(v.array(v.string())),
material: v.optional(v.string()),
flavor: v.optional(v.string()),
}),
),
seoTitle: v.optional(v.string()),
seoDescription: v.optional(v.string()),
canonicalSlug: v.optional(v.string()),
},
handler: async (ctx, args) => {
await Users.requireAdmin(ctx);
@@ -726,6 +748,8 @@ export const create = mutation({
parentCategorySlug,
childCategorySlug,
...(topCategorySlug !== undefined && { topCategorySlug }),
createdAt: Date.now(),
updatedAt: Date.now(),
});
},
});
@@ -736,6 +760,7 @@ export const update = mutation({
name: v.optional(v.string()),
slug: v.optional(v.string()),
description: v.optional(v.string()),
shortDescription: v.optional(v.string()),
status: v.optional(
v.union(
v.literal("active"),
@@ -744,7 +769,20 @@ export const update = mutation({
),
),
categoryId: v.optional(v.id("categories")),
brand: v.optional(v.string()),
tags: v.optional(v.array(v.string())),
attributes: v.optional(
v.object({
petSize: v.optional(v.array(v.string())),
ageRange: v.optional(v.array(v.string())),
specialDiet: v.optional(v.array(v.string())),
material: v.optional(v.string()),
flavor: v.optional(v.string()),
}),
),
seoTitle: v.optional(v.string()),
seoDescription: v.optional(v.string()),
canonicalSlug: v.optional(v.string()),
},
handler: async (ctx, { id, ...updates }) => {
await Users.requireAdmin(ctx);
@@ -769,6 +807,8 @@ export const update = mutation({
}
}
fields.updatedAt = Date.now();
await ctx.db.patch(id, fields);
return id;
},