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:
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user