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:
183
convex/wishlists.ts
Normal file
183
convex/wishlists.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { query, mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import * as Users from "./model/users";
|
||||
import { enrichProducts } from "./model/products";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
|
||||
export const list = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const user = await Users.getCurrentUserOrThrow(ctx);
|
||||
const rows = await ctx.db
|
||||
.query("wishlists")
|
||||
.withIndex("by_user", (q) => q.eq("userId", user._id))
|
||||
.collect();
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const productIds = [...new Set(rows.map((r) => r.productId))];
|
||||
const products = (
|
||||
await Promise.all(productIds.map((id) => ctx.db.get(id)))
|
||||
).filter(Boolean) as Awaited<ReturnType<typeof ctx.db.get>>[];
|
||||
|
||||
const enriched = await enrichProducts(ctx, products);
|
||||
const productMap = new Map(
|
||||
enriched.map((p) => [p._id, p]),
|
||||
);
|
||||
|
||||
return rows.map((row) => {
|
||||
const product = productMap.get(row.productId);
|
||||
const variant = row.variantId && product?.variants
|
||||
? product.variants.find((v: { _id: Id<"productVariants"> }) => v._id === row.variantId)
|
||||
: undefined;
|
||||
return { ...row, product, variant };
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function findExistingEntry(
|
||||
rows: { variantId?: Id<"productVariants"> }[],
|
||||
variantId?: Id<"productVariants">,
|
||||
) {
|
||||
return rows.find((r) => {
|
||||
if (variantId === undefined && r.variantId === undefined) return true;
|
||||
return r.variantId === variantId;
|
||||
});
|
||||
}
|
||||
|
||||
export const add = mutation({
|
||||
args: {
|
||||
productId: v.id("products"),
|
||||
variantId: v.optional(v.id("productVariants")),
|
||||
notifyOnPriceDrop: v.optional(v.boolean()),
|
||||
notifyOnBackInStock: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const user = await Users.getCurrentUserOrThrow(ctx);
|
||||
const existing = await ctx.db
|
||||
.query("wishlists")
|
||||
.withIndex("by_user_and_product", (q) =>
|
||||
q.eq("userId", user._id).eq("productId", args.productId),
|
||||
)
|
||||
.collect();
|
||||
|
||||
const found = findExistingEntry(existing, args.variantId);
|
||||
if (found)
|
||||
return { id: (found as { _id: Id<"wishlists"> })._id, alreadyExisted: true };
|
||||
|
||||
let priceWhenAdded = 0;
|
||||
if (args.variantId) {
|
||||
const variant = await ctx.db.get(args.variantId);
|
||||
if (variant && variant.productId === args.productId) {
|
||||
priceWhenAdded = variant.price;
|
||||
}
|
||||
} else {
|
||||
const variants = await ctx.db
|
||||
.query("productVariants")
|
||||
.withIndex("by_product_and_active", (q) =>
|
||||
q.eq("productId", args.productId).eq("isActive", true),
|
||||
)
|
||||
.first();
|
||||
if (variants) priceWhenAdded = variants.price;
|
||||
}
|
||||
|
||||
const id = await ctx.db.insert("wishlists", {
|
||||
userId: user._id,
|
||||
productId: args.productId,
|
||||
variantId: args.variantId,
|
||||
addedAt: Date.now(),
|
||||
notifyOnPriceDrop: args.notifyOnPriceDrop ?? false,
|
||||
notifyOnBackInStock: args.notifyOnBackInStock ?? false,
|
||||
priceWhenAdded,
|
||||
});
|
||||
return { id, alreadyExisted: false };
|
||||
},
|
||||
});
|
||||
|
||||
export const remove = mutation({
|
||||
args: { id: v.id("wishlists") },
|
||||
handler: async (ctx, { id }) => {
|
||||
const doc = await ctx.db.get(id);
|
||||
if (!doc) throw new Error("Wishlist item not found");
|
||||
await Users.requireOwnership(ctx, doc.userId);
|
||||
await ctx.db.delete(id);
|
||||
},
|
||||
});
|
||||
|
||||
export const toggle = mutation({
|
||||
args: {
|
||||
productId: v.id("products"),
|
||||
variantId: v.optional(v.id("productVariants")),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const user = await Users.getCurrentUserOrThrow(ctx);
|
||||
const existing = await ctx.db
|
||||
.query("wishlists")
|
||||
.withIndex("by_user_and_product", (q) =>
|
||||
q.eq("userId", user._id).eq("productId", args.productId),
|
||||
)
|
||||
.collect();
|
||||
|
||||
const found = findExistingEntry(existing, args.variantId) as
|
||||
| { _id: Id<"wishlists"> }
|
||||
| undefined;
|
||||
if (found) {
|
||||
await ctx.db.delete(found._id);
|
||||
return { removed: true };
|
||||
}
|
||||
|
||||
const id = await ctx.db.insert("wishlists", {
|
||||
userId: user._id,
|
||||
productId: args.productId,
|
||||
variantId: args.variantId,
|
||||
addedAt: Date.now(),
|
||||
notifyOnPriceDrop: false,
|
||||
notifyOnBackInStock: false,
|
||||
priceWhenAdded: await (async () => {
|
||||
if (args.variantId) {
|
||||
const v = await ctx.db.get(args.variantId);
|
||||
return v && v.productId === args.productId ? v.price : 0;
|
||||
}
|
||||
const first = await ctx.db
|
||||
.query("productVariants")
|
||||
.withIndex("by_product_and_active", (q) =>
|
||||
q.eq("productId", args.productId).eq("isActive", true),
|
||||
)
|
||||
.first();
|
||||
return first?.price ?? 0;
|
||||
})(),
|
||||
});
|
||||
return { added: true, id };
|
||||
},
|
||||
});
|
||||
|
||||
export const count = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const user = await Users.getCurrentUser(ctx);
|
||||
if (!user) return 0;
|
||||
|
||||
const rows = await ctx.db
|
||||
.query("wishlists")
|
||||
.withIndex("by_user", (q) => q.eq("userId", user._id))
|
||||
.collect();
|
||||
return rows.length;
|
||||
},
|
||||
});
|
||||
|
||||
export const isWishlisted = query({
|
||||
args: {
|
||||
productId: v.id("products"),
|
||||
variantId: v.optional(v.id("productVariants")),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const user = await Users.getCurrentUserOrThrow(ctx);
|
||||
const rows = await ctx.db
|
||||
.query("wishlists")
|
||||
.withIndex("by_user_and_product", (q) =>
|
||||
q.eq("userId", user._id).eq("productId", args.productId),
|
||||
)
|
||||
.collect();
|
||||
return !!findExistingEntry(rows, args.variantId);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user