feat: initial commit — storefront, convex backend, and shared packages
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>
This commit is contained in:
237
convex/carts.ts
Normal file
237
convex/carts.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
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;
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user