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>
970 lines
31 KiB
TypeScript
970 lines
31 KiB
TypeScript
import { query, mutation, internalQuery, internalMutation } from "./_generated/server";
|
|
import { paginationOptsValidator } from "convex/server";
|
|
import { v } from "convex/values";
|
|
import type { Id, Doc } from "./_generated/dataModel";
|
|
import * as Users from "./model/users";
|
|
import { getProductWithRelations, enrichProducts } from "./model/products";
|
|
|
|
export const list = query({
|
|
args: {
|
|
paginationOpts: paginationOptsValidator,
|
|
status: v.optional(v.string()),
|
|
categoryId: v.optional(v.id("categories")),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
let q;
|
|
|
|
if (args.status && args.categoryId) {
|
|
q = ctx.db
|
|
.query("products")
|
|
.withIndex("by_status_and_category", (idx) =>
|
|
idx.eq("status", args.status as any).eq("categoryId", args.categoryId!),
|
|
);
|
|
} else if (args.status) {
|
|
q = ctx.db
|
|
.query("products")
|
|
.withIndex("by_status", (idx) => idx.eq("status", args.status as any));
|
|
} else if (args.categoryId) {
|
|
q = ctx.db
|
|
.query("products")
|
|
.withIndex("by_category", (idx) =>
|
|
idx.eq("categoryId", args.categoryId!),
|
|
);
|
|
} else {
|
|
q = ctx.db.query("products");
|
|
}
|
|
|
|
const result = await q.paginate(args.paginationOpts);
|
|
|
|
const enrichedPage = await enrichProducts(ctx, result.page as any);
|
|
|
|
return { ...result, page: enrichedPage };
|
|
},
|
|
});
|
|
|
|
const LIST_ALL_LIMIT = 500;
|
|
|
|
export const listAll = query({
|
|
args: {
|
|
status: v.optional(v.string()),
|
|
categoryId: v.optional(v.id("categories")),
|
|
limit: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const limit = Math.min(args.limit ?? LIST_ALL_LIMIT, 1000);
|
|
let q;
|
|
|
|
if (args.status && args.categoryId) {
|
|
q = ctx.db
|
|
.query("products")
|
|
.withIndex("by_status_and_category", (idx) =>
|
|
idx.eq("status", args.status as any).eq("categoryId", args.categoryId!),
|
|
);
|
|
} else if (args.status) {
|
|
q = ctx.db
|
|
.query("products")
|
|
.withIndex("by_status", (idx) => idx.eq("status", args.status as any));
|
|
} else if (args.categoryId) {
|
|
q = ctx.db
|
|
.query("products")
|
|
.withIndex("by_category", (idx) =>
|
|
idx.eq("categoryId", args.categoryId!),
|
|
);
|
|
} else {
|
|
q = ctx.db.query("products");
|
|
}
|
|
|
|
const page = await q.take(limit);
|
|
return enrichProducts(ctx, page as any);
|
|
},
|
|
});
|
|
|
|
const SHOP_LIST_LIMIT = 200;
|
|
|
|
// Filter args: optional brand, tags (product has any), attributes (overlap per dimension).
|
|
const shopFilterArgsValidator = {
|
|
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()),
|
|
}),
|
|
),
|
|
};
|
|
|
|
type ShopFilterArgs = {
|
|
brand?: string;
|
|
tags?: string[];
|
|
attributes?: {
|
|
petSize?: string[];
|
|
ageRange?: string[];
|
|
specialDiet?: string[];
|
|
material?: string;
|
|
flavor?: string;
|
|
};
|
|
};
|
|
|
|
function productMatchesFilters(
|
|
p: { brand?: string; tags: string[]; attributes?: { petSize?: string[]; ageRange?: string[]; specialDiet?: string[]; material?: string; flavor?: string } },
|
|
filters: ShopFilterArgs,
|
|
): boolean {
|
|
if (filters.brand != null && filters.brand !== "" && p.brand !== filters.brand) return false;
|
|
if (filters.tags != null && filters.tags.length > 0) {
|
|
const hasAny = (p.tags ?? []).some((t) => filters.tags!.includes(t));
|
|
if (!hasAny) return false;
|
|
}
|
|
const attrs = filters.attributes;
|
|
if (attrs) {
|
|
if (attrs.petSize != null && attrs.petSize.length > 0) {
|
|
const productVals = p.attributes?.petSize ?? [];
|
|
if (!attrs.petSize.some((v) => productVals.includes(v))) return false;
|
|
}
|
|
if (attrs.ageRange != null && attrs.ageRange.length > 0) {
|
|
const productVals = p.attributes?.ageRange ?? [];
|
|
if (!attrs.ageRange.some((v) => productVals.includes(v))) return false;
|
|
}
|
|
if (attrs.specialDiet != null && attrs.specialDiet.length > 0) {
|
|
const productVals = p.attributes?.specialDiet ?? [];
|
|
if (!attrs.specialDiet.some((v) => productVals.includes(v))) return false;
|
|
}
|
|
if (attrs.material != null && attrs.material !== "") {
|
|
if (p.attributes?.material !== attrs.material) return false;
|
|
}
|
|
if (attrs.flavor != null && attrs.flavor !== "") {
|
|
if (p.attributes?.flavor !== attrs.flavor) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export const listActive = query({
|
|
args: {
|
|
categoryId: v.optional(v.id("categories")),
|
|
limit: v.optional(v.number()),
|
|
...shopFilterArgsValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const limit = Math.min(args.limit ?? SHOP_LIST_LIMIT, 500);
|
|
const filters: ShopFilterArgs = {
|
|
brand: args.brand,
|
|
tags: args.tags,
|
|
attributes: args.attributes,
|
|
};
|
|
let q;
|
|
if (args.categoryId) {
|
|
q = ctx.db
|
|
.query("products")
|
|
.withIndex("by_status_and_category", (idx) =>
|
|
idx.eq("status", "active").eq("categoryId", args.categoryId!),
|
|
);
|
|
} else {
|
|
q = ctx.db
|
|
.query("products")
|
|
.withIndex("by_status", (idx) => idx.eq("status", "active"));
|
|
}
|
|
const hasFilters = filters.brand != null || (filters.tags?.length ?? 0) > 0 || filters.attributes != null;
|
|
const takeCount = hasFilters ? Math.min(2000, limit * 10) : limit;
|
|
const page = await q.take(takeCount);
|
|
const filtered = (page as any[]).filter((p: any) => productMatchesFilters(p, filters)).slice(0, limit);
|
|
return enrichProducts(ctx, filtered as any);
|
|
},
|
|
});
|
|
|
|
export const listByRootCategory = query({
|
|
args: {
|
|
rootCategoryId: v.id("categories"),
|
|
limit: v.optional(v.number()),
|
|
...shopFilterArgsValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const limit = Math.min(args.limit ?? SHOP_LIST_LIMIT, 500);
|
|
const filters: ShopFilterArgs = {
|
|
brand: args.brand,
|
|
tags: args.tags,
|
|
attributes: args.attributes,
|
|
};
|
|
const root = await ctx.db.get(args.rootCategoryId);
|
|
if (!root) return [];
|
|
const parentCategorySlug = root.slug;
|
|
const hasFilters =
|
|
filters.brand != null ||
|
|
(filters.tags?.length ?? 0) > 0 ||
|
|
filters.attributes != null;
|
|
const takeCount = hasFilters ? Math.min(2000, limit * 10) : limit;
|
|
const products = await ctx.db
|
|
.query("products")
|
|
.withIndex("by_status_and_parent_slug", (q) =>
|
|
q.eq("status", "active").eq("parentCategorySlug", parentCategorySlug),
|
|
)
|
|
.take(takeCount);
|
|
const filtered = (products as any[]).filter((p: any) => productMatchesFilters(p, filters)).slice(0, limit);
|
|
return enrichProducts(ctx, filtered as any);
|
|
},
|
|
});
|
|
|
|
export const listByTopCategory = query({
|
|
args: {
|
|
topCategorySlug: v.string(),
|
|
petCategorySlug: v.optional(v.string()),
|
|
limit: v.optional(v.number()),
|
|
...shopFilterArgsValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const limit = Math.min(args.limit ?? SHOP_LIST_LIMIT, 500);
|
|
const filters: ShopFilterArgs = {
|
|
brand: args.brand,
|
|
tags: args.tags,
|
|
attributes: args.attributes,
|
|
};
|
|
const hasFilters =
|
|
filters.brand != null ||
|
|
(filters.tags?.length ?? 0) > 0 ||
|
|
filters.attributes != null;
|
|
const takeCount = hasFilters ? Math.min(2000, limit * 10) : limit;
|
|
let products;
|
|
if (args.petCategorySlug) {
|
|
products = await ctx.db
|
|
.query("products")
|
|
.withIndex("by_status_and_top_and_parent_slug", (q) =>
|
|
q
|
|
.eq("status", "active")
|
|
.eq("topCategorySlug", args.topCategorySlug)
|
|
.eq("parentCategorySlug", args.petCategorySlug!),
|
|
)
|
|
.take(takeCount);
|
|
} else {
|
|
products = await ctx.db
|
|
.query("products")
|
|
.withIndex("by_status_and_top_category_slug", (q) =>
|
|
q.eq("status", "active").eq("topCategorySlug", args.topCategorySlug),
|
|
)
|
|
.take(takeCount);
|
|
}
|
|
const filteredProducts = (products as any[]).filter((p: any) => productMatchesFilters(p, filters)).slice(0, limit);
|
|
return enrichProducts(ctx, filteredProducts as any);
|
|
},
|
|
});
|
|
|
|
export const listByParentSlug = query({
|
|
args: {
|
|
parentCategorySlug: v.string(),
|
|
limit: v.optional(v.number()),
|
|
...shopFilterArgsValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const limit = Math.min(args.limit ?? SHOP_LIST_LIMIT, 500);
|
|
const filters: ShopFilterArgs = {
|
|
brand: args.brand,
|
|
tags: args.tags,
|
|
attributes: args.attributes,
|
|
};
|
|
const hasFilters =
|
|
filters.brand != null ||
|
|
(filters.tags?.length ?? 0) > 0 ||
|
|
filters.attributes != null;
|
|
const takeCount = hasFilters ? Math.min(2000, limit * 10) : limit;
|
|
const products = await ctx.db
|
|
.query("products")
|
|
.withIndex("by_status_and_parent_slug", (q) =>
|
|
q.eq("status", "active").eq("parentCategorySlug", args.parentCategorySlug),
|
|
)
|
|
.take(takeCount);
|
|
const filtered = (products as any[]).filter((p: any) => productMatchesFilters(p, filters)).slice(0, limit);
|
|
return enrichProducts(ctx, filtered as any);
|
|
},
|
|
});
|
|
|
|
export const listByTag = query({
|
|
args: {
|
|
tag: v.string(),
|
|
limit: v.optional(v.number()),
|
|
...shopFilterArgsValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const limit = Math.min(args.limit ?? SHOP_LIST_LIMIT, 500);
|
|
const filters: ShopFilterArgs = {
|
|
brand: args.brand,
|
|
tags: args.tags,
|
|
attributes: args.attributes,
|
|
};
|
|
const hasFilters =
|
|
filters.brand != null ||
|
|
(filters.tags?.length ?? 0) > 0 ||
|
|
filters.attributes != null;
|
|
const takeCount = hasFilters ? Math.min(2000, limit * 10) : limit * 5;
|
|
const page = await ctx.db
|
|
.query("products")
|
|
.withIndex("by_status", (q) => q.eq("status", "active"))
|
|
.take(takeCount);
|
|
const filtered = (page as any[])
|
|
.filter((p: any) => (p.tags ?? []).includes(args.tag))
|
|
.filter((p: any) => productMatchesFilters(p, filters))
|
|
.slice(0, limit);
|
|
return enrichProducts(ctx, filtered as any);
|
|
},
|
|
});
|
|
|
|
const RECENTLY_ADDED_LIMIT = 100;
|
|
const RECENTLY_ADDED_SCAN_CEILING = 500;
|
|
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
|
export const listRecentlyAdded = query({
|
|
args: {
|
|
limit: v.optional(v.number()),
|
|
...shopFilterArgsValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const limit = Math.min(args.limit ?? RECENTLY_ADDED_LIMIT, RECENTLY_ADDED_LIMIT);
|
|
const filters: ShopFilterArgs = {
|
|
brand: args.brand,
|
|
tags: args.tags,
|
|
attributes: args.attributes,
|
|
};
|
|
const cutoff = Date.now() - THIRTY_DAYS_MS;
|
|
const collected: any[] = [];
|
|
let scanned = 0;
|
|
for await (const p of ctx.db
|
|
.query("products")
|
|
.withIndex("by_status", (q: any) => q.eq("status", "active"))
|
|
.order("desc")) {
|
|
if (p._creationTime < cutoff) break;
|
|
if (scanned++ >= RECENTLY_ADDED_SCAN_CEILING) break;
|
|
if (productMatchesFilters(p as any, filters)) {
|
|
collected.push(p);
|
|
if (collected.length >= limit) break;
|
|
}
|
|
}
|
|
return enrichProducts(ctx, collected as any);
|
|
},
|
|
});
|
|
|
|
export const listByParentAndChildSlug = query({
|
|
args: {
|
|
parentCategorySlug: v.string(),
|
|
childCategorySlug: v.string(),
|
|
limit: v.optional(v.number()),
|
|
...shopFilterArgsValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const limit = Math.min(args.limit ?? SHOP_LIST_LIMIT, 500);
|
|
const filters: ShopFilterArgs = {
|
|
brand: args.brand,
|
|
tags: args.tags,
|
|
attributes: args.attributes,
|
|
};
|
|
const hasFilters =
|
|
filters.brand != null ||
|
|
(filters.tags?.length ?? 0) > 0 ||
|
|
filters.attributes != null;
|
|
const takeCount = hasFilters ? Math.min(2000, limit * 10) : limit;
|
|
const products = await ctx.db
|
|
.query("products")
|
|
.withIndex("by_status_and_parent_and_child_slug", (q) =>
|
|
q
|
|
.eq("status", "active")
|
|
.eq("parentCategorySlug", args.parentCategorySlug)
|
|
.eq("childCategorySlug", args.childCategorySlug),
|
|
)
|
|
.take(takeCount);
|
|
const filtered = (products as any[]).filter((p: any) => productMatchesFilters(p, filters)).slice(0, limit);
|
|
return enrichProducts(ctx, filtered as any);
|
|
},
|
|
});
|
|
|
|
// ─── Filter options (derived from products in scope) ───────────────────────
|
|
|
|
const FILTER_OPTIONS_PRODUCT_LIMIT = 2000;
|
|
|
|
async function getProductsInScope(
|
|
ctx: { db: any },
|
|
scope: {
|
|
categoryId?: Id<"categories">;
|
|
rootCategoryId?: Id<"categories">;
|
|
parentCategorySlug?: string;
|
|
childCategorySlug?: string;
|
|
topCategorySlug?: string;
|
|
petCategorySlug?: string;
|
|
tag?: string;
|
|
recentlyAdded?: boolean;
|
|
},
|
|
): Promise<Array<{ brand?: string; tags: string[]; attributes?: { petSize?: string[]; ageRange?: string[]; specialDiet?: string[]; material?: string; flavor?: string } }>> {
|
|
// Resolve which scope is used and fetch raw products (no enrichment needed for aggregation).
|
|
if (scope.categoryId !== undefined) {
|
|
return ctx.db
|
|
.query("products")
|
|
.withIndex("by_status_and_category", (q: any) =>
|
|
q.eq("status", "active").eq("categoryId", scope.categoryId),
|
|
)
|
|
.take(FILTER_OPTIONS_PRODUCT_LIMIT);
|
|
}
|
|
if (scope.rootCategoryId !== undefined) {
|
|
const root = await ctx.db.get(scope.rootCategoryId);
|
|
if (!root) return [];
|
|
return ctx.db
|
|
.query("products")
|
|
.withIndex("by_status_and_parent_slug", (q: any) =>
|
|
q.eq("status", "active").eq("parentCategorySlug", root.slug),
|
|
)
|
|
.take(FILTER_OPTIONS_PRODUCT_LIMIT);
|
|
}
|
|
if (scope.parentCategorySlug !== undefined && scope.childCategorySlug !== undefined) {
|
|
return ctx.db
|
|
.query("products")
|
|
.withIndex("by_status_and_parent_and_child_slug", (q: any) =>
|
|
q
|
|
.eq("status", "active")
|
|
.eq("parentCategorySlug", scope.parentCategorySlug)
|
|
.eq("childCategorySlug", scope.childCategorySlug),
|
|
)
|
|
.take(FILTER_OPTIONS_PRODUCT_LIMIT);
|
|
}
|
|
if (scope.parentCategorySlug !== undefined) {
|
|
return ctx.db
|
|
.query("products")
|
|
.withIndex("by_status_and_parent_slug", (q: any) =>
|
|
q.eq("status", "active").eq("parentCategorySlug", scope.parentCategorySlug),
|
|
)
|
|
.take(FILTER_OPTIONS_PRODUCT_LIMIT);
|
|
}
|
|
if (scope.topCategorySlug !== undefined) {
|
|
if (scope.petCategorySlug) {
|
|
return ctx.db
|
|
.query("products")
|
|
.withIndex("by_status_and_top_and_parent_slug", (q: any) =>
|
|
q
|
|
.eq("status", "active")
|
|
.eq("topCategorySlug", scope.topCategorySlug)
|
|
.eq("parentCategorySlug", scope.petCategorySlug),
|
|
)
|
|
.take(FILTER_OPTIONS_PRODUCT_LIMIT);
|
|
}
|
|
return ctx.db
|
|
.query("products")
|
|
.withIndex("by_status_and_top_category_slug", (q: any) =>
|
|
q.eq("status", "active").eq("topCategorySlug", scope.topCategorySlug),
|
|
)
|
|
.take(FILTER_OPTIONS_PRODUCT_LIMIT);
|
|
}
|
|
if (scope.tag !== undefined) {
|
|
const all = await ctx.db
|
|
.query("products")
|
|
.withIndex("by_status", (q: any) => q.eq("status", "active"))
|
|
.take(FILTER_OPTIONS_PRODUCT_LIMIT);
|
|
return all.filter((p: any) => (p.tags ?? []).includes(scope.tag!));
|
|
}
|
|
if (scope.recentlyAdded === true) {
|
|
const cutoff = Date.now() - THIRTY_DAYS_MS;
|
|
const results: any[] = [];
|
|
for await (const p of ctx.db
|
|
.query("products")
|
|
.withIndex("by_status", (q: any) => q.eq("status", "active"))
|
|
.order("desc")) {
|
|
if (p._creationTime < cutoff) break;
|
|
results.push(p);
|
|
if (results.length >= FILTER_OPTIONS_PRODUCT_LIMIT) break;
|
|
}
|
|
return results;
|
|
}
|
|
// No scope: entire catalog
|
|
return ctx.db
|
|
.query("products")
|
|
.withIndex("by_status", (q: any) => q.eq("status", "active"))
|
|
.take(FILTER_OPTIONS_PRODUCT_LIMIT);
|
|
}
|
|
|
|
function aggregateFilterOptions(
|
|
products: Array<{ brand?: string; tags: string[]; attributes?: { petSize?: string[]; ageRange?: string[]; specialDiet?: string[]; material?: string; flavor?: string } }>,
|
|
): {
|
|
brands: string[];
|
|
tags: string[];
|
|
attributes: { petSize: string[]; ageRange: string[]; specialDiet: string[]; material: string[]; flavor: string[] };
|
|
} {
|
|
const brandsSet = new Set<string>();
|
|
const tagsSet = new Set<string>();
|
|
const petSizeSet = new Set<string>();
|
|
const ageRangeSet = new Set<string>();
|
|
const specialDietSet = new Set<string>();
|
|
const materialSet = new Set<string>();
|
|
const flavorSet = new Set<string>();
|
|
|
|
for (const p of products) {
|
|
if (p.brand != null && p.brand.trim() !== "") brandsSet.add(p.brand);
|
|
for (const t of p.tags ?? []) if (t != null && String(t).trim() !== "") tagsSet.add(String(t));
|
|
const attrs = p.attributes;
|
|
if (attrs) {
|
|
for (const v of attrs.petSize ?? []) if (v != null && String(v).trim() !== "") petSizeSet.add(String(v));
|
|
for (const v of attrs.ageRange ?? []) if (v != null && String(v).trim() !== "") ageRangeSet.add(String(v));
|
|
for (const v of attrs.specialDiet ?? []) if (v != null && String(v).trim() !== "") specialDietSet.add(String(v));
|
|
if (attrs.material != null && String(attrs.material).trim() !== "") materialSet.add(String(attrs.material));
|
|
if (attrs.flavor != null && String(attrs.flavor).trim() !== "") flavorSet.add(String(attrs.flavor));
|
|
}
|
|
}
|
|
|
|
const sort = (a: string, b: string) => a.localeCompare(b, "en");
|
|
return {
|
|
brands: [...brandsSet].sort(sort),
|
|
tags: [...tagsSet].sort(sort),
|
|
attributes: {
|
|
petSize: [...petSizeSet].sort(sort),
|
|
ageRange: [...ageRangeSet].sort(sort),
|
|
specialDiet: [...specialDietSet].sort(sort),
|
|
material: [...materialSet].sort(sort),
|
|
flavor: [...flavorSet].sort(sort),
|
|
},
|
|
};
|
|
}
|
|
|
|
export const getFilterOptions = query({
|
|
args: {
|
|
categoryId: v.optional(v.id("categories")),
|
|
rootCategoryId: v.optional(v.id("categories")),
|
|
parentCategorySlug: v.optional(v.string()),
|
|
childCategorySlug: v.optional(v.string()),
|
|
topCategorySlug: v.optional(v.string()),
|
|
petCategorySlug: v.optional(v.string()),
|
|
tag: v.optional(v.string()),
|
|
recentlyAdded: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const products = await getProductsInScope(ctx, args);
|
|
const aggregated = aggregateFilterOptions(products as any);
|
|
return aggregated;
|
|
},
|
|
});
|
|
|
|
export const getBySlug = query({
|
|
args: { slug: v.string() },
|
|
handler: async (ctx, { slug }) => {
|
|
const product = await ctx.db
|
|
.query("products")
|
|
.withIndex("by_slug", (q) => q.eq("slug", slug))
|
|
.unique();
|
|
|
|
if (!product || product.status !== "active") return null;
|
|
|
|
return getProductWithRelations(ctx, product._id);
|
|
},
|
|
});
|
|
|
|
export const getById = internalQuery({
|
|
args: { id: v.id("products") },
|
|
handler: async (ctx, { id }) => {
|
|
return getProductWithRelations(ctx, id);
|
|
},
|
|
});
|
|
|
|
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.
|
|
* Assumes every product's category has parentId set.
|
|
*/
|
|
export const backfillProductCategorySlugs = internalMutation({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const products = await ctx.db.query("products").collect();
|
|
let patched = 0;
|
|
let skipped = 0;
|
|
for (const product of products) {
|
|
const category = await ctx.db.get(product.categoryId);
|
|
if (!category) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
if (!category.parentId) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
const parent = await ctx.db.get(category.parentId);
|
|
if (!parent) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
const parentCategorySlug = parent.slug;
|
|
const childCategorySlug = category.slug;
|
|
const topCategorySlug =
|
|
category.topCategorySlug !== undefined && category.topCategorySlug !== null
|
|
? category.topCategorySlug
|
|
: undefined;
|
|
await ctx.db.patch(product._id, {
|
|
parentCategorySlug,
|
|
childCategorySlug,
|
|
...(topCategorySlug !== undefined && { topCategorySlug }),
|
|
});
|
|
patched += 1;
|
|
}
|
|
return { patched, skipped };
|
|
},
|
|
});
|
|
|
|
const productStatusValidator = v.union(
|
|
v.literal("active"),
|
|
v.literal("draft"),
|
|
v.literal("archived"),
|
|
);
|
|
|
|
export const search = query({
|
|
args: {
|
|
query: v.string(),
|
|
status: v.optional(productStatusValidator),
|
|
categoryId: v.optional(v.id("categories")),
|
|
brand: v.optional(v.string()),
|
|
limit: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const trimmed = args.query.trim();
|
|
if (!trimmed) return [];
|
|
|
|
let searchFilter = (q: any) => {
|
|
if (args.status !== undefined) q = q.eq("status", args.status);
|
|
if (args.categoryId !== undefined)
|
|
q = q.eq("categoryId", args.categoryId);
|
|
if (args.brand !== undefined) q = q.eq("brand", args.brand);
|
|
return q.search("name", trimmed);
|
|
};
|
|
|
|
const limit = args.limit ?? 24;
|
|
const results = await ctx.db
|
|
.query("products")
|
|
.withSearchIndex("search_products", searchFilter)
|
|
.take(limit);
|
|
|
|
return enrichProducts(ctx, results as any);
|
|
},
|
|
});
|
|
|
|
export const searchTypeahead = query({
|
|
args: {
|
|
query: v.string(),
|
|
parentCategorySlug: v.optional(v.string()),
|
|
limit: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const trimmed = args.query.trim();
|
|
if (trimmed.length < 3) return [];
|
|
|
|
const limit = Math.min(args.limit ?? 8, 20);
|
|
|
|
const searchFilter = (q: any) => {
|
|
q = q.eq("status", "active");
|
|
if (args.parentCategorySlug !== undefined) {
|
|
q = q.eq("parentCategorySlug", args.parentCategorySlug);
|
|
}
|
|
return q.search("name", trimmed);
|
|
};
|
|
|
|
const results = await ctx.db
|
|
.query("products")
|
|
.withSearchIndex("search_products", searchFilter)
|
|
.take(limit);
|
|
|
|
return Promise.all(
|
|
results.map(async (product) => {
|
|
const firstImage = await ctx.db
|
|
.query("productImages")
|
|
.withIndex("by_product", (q) => q.eq("productId", product._id))
|
|
.first();
|
|
|
|
const variants = await ctx.db
|
|
.query("productVariants")
|
|
.withIndex("by_product_and_active", (q) =>
|
|
q.eq("productId", product._id).eq("isActive", true),
|
|
)
|
|
.collect();
|
|
|
|
const minPriceVariant =
|
|
variants.length > 0
|
|
? variants.reduce((min, v) => (v.price < min.price ? v : min))
|
|
: null;
|
|
|
|
return {
|
|
_id: product._id,
|
|
name: product.name,
|
|
slug: product.slug,
|
|
parentCategorySlug: product.parentCategorySlug,
|
|
childCategorySlug: product.childCategorySlug,
|
|
brand: product.brand,
|
|
imageUrl: firstImage?.url,
|
|
imageAlt: firstImage?.alt,
|
|
minPrice: minPriceVariant?.price ?? 0,
|
|
compareAtPrice: minPriceVariant?.compareAtPrice,
|
|
averageRating: product.averageRating,
|
|
reviewCount: product.reviewCount,
|
|
};
|
|
}),
|
|
);
|
|
},
|
|
});
|
|
|
|
export const create = mutation({
|
|
args: {
|
|
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);
|
|
const category = await ctx.db.get(args.categoryId) as Doc<"categories"> | null;
|
|
if (!category) throw new Error("Category not found");
|
|
const parentCategorySlug = category.parentId
|
|
? (await ctx.db.get(category.parentId) as Doc<"categories"> | null)?.slug ?? category.slug
|
|
: category.slug;
|
|
const childCategorySlug = category.slug;
|
|
const topCategorySlug = category.topCategorySlug ?? undefined;
|
|
return await ctx.db.insert("products", {
|
|
...args,
|
|
parentCategorySlug,
|
|
childCategorySlug,
|
|
...(topCategorySlug !== undefined && { topCategorySlug }),
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
});
|
|
},
|
|
});
|
|
|
|
export const update = mutation({
|
|
args: {
|
|
id: v.id("products"),
|
|
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"),
|
|
v.literal("draft"),
|
|
v.literal("archived"),
|
|
),
|
|
),
|
|
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);
|
|
const existing = await ctx.db.get(id);
|
|
if (!existing) throw new Error("Product not found");
|
|
|
|
const fields: Record<string, any> = {};
|
|
for (const [key, value] of Object.entries(updates)) {
|
|
if (value !== undefined) fields[key] = value;
|
|
}
|
|
|
|
const categoryId = fields.categoryId ?? existing.categoryId;
|
|
const category = await ctx.db.get(categoryId) as Doc<"categories"> | null;
|
|
if (category) {
|
|
const parentCategorySlug = category.parentId
|
|
? (await ctx.db.get(category.parentId) as Doc<"categories"> | null)?.slug ?? category.slug
|
|
: category.slug;
|
|
fields.parentCategorySlug = parentCategorySlug;
|
|
fields.childCategorySlug = category.slug;
|
|
if (category.topCategorySlug !== undefined && category.topCategorySlug !== null) {
|
|
fields.topCategorySlug = category.topCategorySlug;
|
|
}
|
|
}
|
|
|
|
fields.updatedAt = Date.now();
|
|
|
|
await ctx.db.patch(id, fields);
|
|
return id;
|
|
},
|
|
});
|
|
|
|
export const archive = mutation({
|
|
args: { id: v.id("products") },
|
|
handler: async (ctx, { id }) => {
|
|
await Users.requireAdmin(ctx);
|
|
const existing = await ctx.db.get(id);
|
|
if (!existing) throw new Error("Product not found");
|
|
await ctx.db.patch(id, { status: "archived" });
|
|
return id;
|
|
},
|
|
});
|
|
|
|
// ─── Product images ───────────────────────────────────────────────────────
|
|
|
|
export const addImage = mutation({
|
|
args: {
|
|
productId: v.id("products"),
|
|
url: v.string(),
|
|
alt: v.optional(v.string()),
|
|
position: v.number(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await Users.requireAdmin(ctx);
|
|
const product = await ctx.db.get(args.productId);
|
|
if (!product) throw new Error("Product not found");
|
|
return await ctx.db.insert("productImages", {
|
|
productId: args.productId,
|
|
url: args.url,
|
|
alt: args.alt,
|
|
position: args.position,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const deleteImage = mutation({
|
|
args: { id: v.id("productImages") },
|
|
handler: async (ctx, { id }) => {
|
|
await Users.requireAdmin(ctx);
|
|
const image = await ctx.db.get(id);
|
|
if (!image) throw new Error("Image not found");
|
|
await ctx.db.delete(id);
|
|
return id;
|
|
},
|
|
});
|
|
|
|
export const reorderImages = mutation({
|
|
args: {
|
|
updates: v.array(
|
|
v.object({
|
|
id: v.id("productImages"),
|
|
position: v.number(),
|
|
}),
|
|
),
|
|
},
|
|
handler: async (ctx, { updates }) => {
|
|
await Users.requireAdmin(ctx);
|
|
for (const { id, position } of updates) {
|
|
const image = await ctx.db.get(id);
|
|
if (!image) throw new Error(`Image not found: ${id}`);
|
|
await ctx.db.patch(id, { position });
|
|
}
|
|
},
|
|
});
|
|
|
|
// ─── Product variants ──────────────────────────────────────────────────────
|
|
|
|
const variantAttributesValidator = v.optional(
|
|
v.object({
|
|
size: v.optional(v.string()),
|
|
flavor: v.optional(v.string()),
|
|
color: v.optional(v.string()),
|
|
}),
|
|
);
|
|
|
|
export const addVariant = mutation({
|
|
args: {
|
|
productId: v.id("products"),
|
|
name: v.string(),
|
|
sku: v.string(),
|
|
price: v.number(),
|
|
compareAtPrice: v.optional(v.number()),
|
|
stockQuantity: v.number(),
|
|
attributes: variantAttributesValidator,
|
|
isActive: v.boolean(),
|
|
weight: v.optional(v.number()),
|
|
weightUnit: v.optional(
|
|
v.union(
|
|
v.literal("g"),
|
|
v.literal("kg"),
|
|
v.literal("lb"),
|
|
v.literal("oz"),
|
|
),
|
|
),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await Users.requireAdmin(ctx);
|
|
const product = await ctx.db.get(args.productId);
|
|
if (!product) throw new Error("Product not found");
|
|
return await ctx.db.insert("productVariants", {
|
|
productId: args.productId,
|
|
name: args.name,
|
|
sku: args.sku,
|
|
price: args.price,
|
|
compareAtPrice: args.compareAtPrice,
|
|
stockQuantity: args.stockQuantity,
|
|
attributes: args.attributes,
|
|
isActive: args.isActive,
|
|
weight: args.weight ?? 0,
|
|
weightUnit: args.weightUnit ?? "g",
|
|
});
|
|
},
|
|
});
|
|
|
|
export const updateVariant = mutation({
|
|
args: {
|
|
id: v.id("productVariants"),
|
|
price: v.optional(v.number()),
|
|
compareAtPrice: v.optional(v.number()),
|
|
stockQuantity: v.optional(v.number()),
|
|
isActive: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, { id, ...updates }) => {
|
|
await Users.requireAdmin(ctx);
|
|
const variant = await ctx.db.get(id);
|
|
if (!variant) throw new Error("Variant not found");
|
|
const fields: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(updates)) {
|
|
if (value !== undefined) fields[key] = value;
|
|
}
|
|
if (Object.keys(fields).length > 0) {
|
|
await ctx.db.patch(id, fields);
|
|
}
|
|
return id;
|
|
},
|
|
});
|
|
|
|
export const deleteVariant = mutation({
|
|
args: { id: v.id("productVariants") },
|
|
handler: async (ctx, { id }) => {
|
|
await Users.requireAdmin(ctx);
|
|
const variant = await ctx.db.get(id);
|
|
if (!variant) throw new Error("Variant not found");
|
|
const orderItemsWithVariant = await ctx.db
|
|
.query("orderItems")
|
|
.filter((q) => q.eq(q.field("variantId"), id))
|
|
.collect();
|
|
if (orderItemsWithVariant.length > 0) {
|
|
await ctx.db.patch(id, { isActive: false });
|
|
} else {
|
|
await ctx.db.delete(id);
|
|
}
|
|
return id;
|
|
},
|
|
});
|