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:
80
convex/model/products.ts
Normal file
80
convex/model/products.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { QueryCtx, MutationCtx } from "../_generated/server";
|
||||
import { Id } from "../_generated/dataModel";
|
||||
|
||||
/**
|
||||
* Recalculate product averageRating and reviewCount from approved reviews.
|
||||
* Call after review approve/delete.
|
||||
*/
|
||||
export async function recalculateProductRating(
|
||||
ctx: MutationCtx,
|
||||
productId: Id<"products">,
|
||||
) {
|
||||
const approved = await ctx.db
|
||||
.query("reviews")
|
||||
.withIndex("by_product_approved", (q) =>
|
||||
q.eq("productId", productId).eq("isApproved", true),
|
||||
)
|
||||
.collect();
|
||||
|
||||
const count = approved.length;
|
||||
const averageRating =
|
||||
count > 0
|
||||
? approved.reduce((sum, r) => sum + r.rating, 0) / count
|
||||
: undefined;
|
||||
const reviewCount = count > 0 ? count : undefined;
|
||||
|
||||
await ctx.db.patch(productId, {
|
||||
averageRating,
|
||||
reviewCount,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProductWithRelations(
|
||||
ctx: QueryCtx,
|
||||
productId: Id<"products">,
|
||||
) {
|
||||
const product = await ctx.db.get(productId);
|
||||
if (!product) return null;
|
||||
|
||||
const [imagesRaw, variants, category] = await Promise.all([
|
||||
ctx.db
|
||||
.query("productImages")
|
||||
.withIndex("by_product", (q) => q.eq("productId", productId))
|
||||
.collect(),
|
||||
ctx.db
|
||||
.query("productVariants")
|
||||
.withIndex("by_product_and_active", (q) =>
|
||||
q.eq("productId", productId).eq("isActive", true),
|
||||
)
|
||||
.collect(),
|
||||
ctx.db.get(product.categoryId),
|
||||
]);
|
||||
const images = imagesRaw.sort((a, b) => a.position - b.position);
|
||||
|
||||
return { ...product, images, variants, category };
|
||||
}
|
||||
|
||||
export async function enrichProducts(
|
||||
ctx: QueryCtx,
|
||||
products: Awaited<ReturnType<typeof ctx.db.query>>[],
|
||||
) {
|
||||
return Promise.all(
|
||||
products.map(async (product: any) => {
|
||||
const [imagesRaw, variants] = await Promise.all([
|
||||
ctx.db
|
||||
.query("productImages")
|
||||
.withIndex("by_product", (q) => q.eq("productId", product._id))
|
||||
.collect(),
|
||||
ctx.db
|
||||
.query("productVariants")
|
||||
.withIndex("by_product_and_active", (q) =>
|
||||
q.eq("productId", product._id).eq("isActive", true),
|
||||
)
|
||||
.collect(),
|
||||
]);
|
||||
const images = imagesRaw.sort((a, b) => a.position - b.position);
|
||||
return { ...product, images, variants };
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user