import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; import type { Id } from "./_generated/dataModel"; import * as Users from "./model/users"; import * as CartsModel from "./model/carts"; type EnrichedItem = { variantId: string; productId: string; quantity: number; priceSnapshot: number; productName: string; variantName: string; imageUrl?: string; stockQuantity?: number; /** For PDP link: /shop/{parentCategorySlug}/{childCategorySlug}/{slug} */ productSlug?: string; parentCategorySlug?: string; childCategorySlug?: string; }; async function enrichCartItems( ctx: { db: import("./_generated/server").QueryCtx["db"] }, items: { productId: Id<"products">; variantId?: Id<"productVariants">; quantity: number; price: number }[] ): Promise { const enriched: EnrichedItem[] = []; for (const item of items) { const product = await ctx.db.get(item.productId); const variant = item.variantId ? await ctx.db.get(item.variantId) : null; if (!product || !variant) continue; const images = await ctx.db .query("productImages") .withIndex("by_product", (q) => q.eq("productId", item.productId)) .collect(); images.sort((a, b) => a.position - b.position); const imageUrl = images[0]?.url; enriched.push({ variantId: item.variantId!, productId: item.productId, quantity: item.quantity, priceSnapshot: item.price, productName: product.name, variantName: variant.name, imageUrl, stockQuantity: variant.stockQuantity, productSlug: product.slug, parentCategorySlug: product.parentCategorySlug, childCategorySlug: product.childCategorySlug, }); } return enriched; } export const get = query({ args: { sessionId: v.optional(v.string()) }, handler: async (ctx, args) => { const user = await Users.getCurrentUser(ctx); const userId = user?._id ?? null; const sessionId = args.sessionId; if (!userId && !sessionId) return null; const cart = await CartsModel.getCart(ctx, userId ?? undefined, sessionId); if (!cart || cart.items.length === 0) { return cart ? { ...cart, items: [] } : null; } const items = await enrichCartItems(ctx, cart.items); return { ...cart, items }; }, }); export const addItem = mutation({ args: { variantId: v.id("productVariants"), quantity: v.number(), sessionId: v.optional(v.string()), }, handler: async (ctx, args) => { const user = await Users.getCurrentUser(ctx); const userId = user?._id; if (!userId && !args.sessionId) { throw new Error("Must be authenticated or provide sessionId"); } if (args.quantity < 1) throw new Error("Quantity must be at least 1"); const cart = await CartsModel.getOrCreateCart(ctx, userId, args.sessionId); const variant = await ctx.db.get(args.variantId); if (!variant) throw new Error("Variant not found"); if (!variant.isActive) throw new Error("Variant is not available"); const existingQty = cart.items.find((i) => i.variantId === args.variantId)?.quantity ?? 0; const newQty = existingQty + args.quantity; if (variant.stockQuantity < newQty) { throw new Error( `Insufficient stock: only ${variant.stockQuantity} available` ); } const productId = variant.productId; const newItems = [...cart.items]; const idx = newItems.findIndex((i) => i.variantId === args.variantId); if (idx >= 0) { newItems[idx] = { ...newItems[idx], quantity: newItems[idx].quantity + args.quantity, }; } else { newItems.push({ productId, variantId: args.variantId, quantity: args.quantity, price: variant.price, }); } await ctx.db.patch(cart._id, { items: newItems, updatedAt: Date.now(), }); return cart._id; }, }); export const updateItem = mutation({ args: { variantId: v.id("productVariants"), quantity: v.number(), sessionId: v.optional(v.string()), }, handler: async (ctx, args) => { const user = await Users.getCurrentUser(ctx); const userId = user?._id; if (!userId && !args.sessionId) { throw new Error("Must be authenticated or provide sessionId"); } const cart = await CartsModel.getOrCreateCart(ctx, userId, args.sessionId); const idx = cart.items.findIndex((i) => i.variantId === args.variantId); if (idx < 0) return cart._id; if (args.quantity === 0) { const newItems = cart.items.filter((i) => i.variantId !== args.variantId); await ctx.db.patch(cart._id, { items: newItems, updatedAt: Date.now() }); return cart._id; } const variant = await ctx.db.get(args.variantId); if (!variant) throw new Error("Variant not found"); if (variant.stockQuantity < args.quantity) { throw new Error( `Insufficient stock: only ${variant.stockQuantity} available` ); } const newItems = [...cart.items]; newItems[idx] = { ...newItems[idx], quantity: args.quantity }; await ctx.db.patch(cart._id, { items: newItems, updatedAt: Date.now() }); return cart._id; }, }); export const removeItem = mutation({ args: { variantId: v.id("productVariants"), sessionId: v.optional(v.string()), }, handler: async (ctx, args) => { const user = await Users.getCurrentUser(ctx); const userId = user?._id; if (!userId && !args.sessionId) { throw new Error("Must be authenticated or provide sessionId"); } const cart = await CartsModel.getOrCreateCart(ctx, userId, args.sessionId); const newItems = cart.items.filter((i) => i.variantId !== args.variantId); await ctx.db.patch(cart._id, { items: newItems, updatedAt: Date.now() }); return cart._id; }, }); export const clear = mutation({ args: { sessionId: v.optional(v.string()) }, handler: async (ctx, args) => { const user = await Users.getCurrentUser(ctx); const userId = user?._id; if (!userId && !args.sessionId) { throw new Error("Must be authenticated or provide sessionId"); } const cart = await CartsModel.getOrCreateCart(ctx, userId, args.sessionId); await ctx.db.patch(cart._id, { items: [], updatedAt: Date.now() }); return cart._id; }, }); export const merge = mutation({ args: { sessionId: v.optional(v.string()) }, handler: async (ctx, args) => { const user = await Users.getCurrentUserOrThrow(ctx); const userCart = await CartsModel.getOrCreateCart(ctx, user._id, undefined); if (!args.sessionId) return userCart._id; const guestCart = await ctx.db .query("carts") .withIndex("by_session", (q) => q.eq("sessionId", args.sessionId!)) .unique(); if (!guestCart || guestCart.items.length === 0) return userCart._id; const mergedItems = [...userCart.items]; for (const guestItem of guestCart.items) { const variantId = guestItem.variantId; if (!variantId) continue; const existing = mergedItems.find((i) => i.variantId === variantId); if (existing) { const variant = await ctx.db.get(variantId); if (variant) { const newQty = existing.quantity + guestItem.quantity; const cap = Math.min(newQty, variant.stockQuantity); existing.quantity = cap; existing.price = variant.price; } } else { mergedItems.push({ ...guestItem, variantId }); } } await ctx.db.patch(userCart._id, { items: mergedItems, updatedAt: Date.now(), }); await ctx.db.patch(guestCart._id, { items: [], updatedAt: Date.now() }); return userCart._id; }, });