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:
2026-03-04 09:31:18 +03:00
commit cc15338ad9
361 changed files with 45005 additions and 0 deletions

80
convex/model/products.ts Normal file
View 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 };
}),
);
}