Completes the first milestone of The Pet Loft ecommerce platform: - apps/storefront: full customer-facing Next.js app with HeroUI (cart, checkout, orders, wishlist, product detail, shop, search, auth) - convex/: serverless backend with schema, queries, mutations, actions, HTTP routes, Stripe/Shippo integrations, and co-located tests - packages/types, packages/utils, packages/convex: shared workspace packages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
238 lines
7.5 KiB
TypeScript
238 lines
7.5 KiB
TypeScript
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<EnrichedItem[]> {
|
|
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;
|
|
},
|
|
});
|