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

85
convex/model/orders.ts Normal file
View File

@@ -0,0 +1,85 @@
import { QueryCtx } from "../_generated/server";
import { Id, Doc } from "../_generated/dataModel";
export async function getOrderWithItems(
ctx: QueryCtx,
orderId: Id<"orders">,
) {
const order = await ctx.db.get(orderId);
if (!order) return null;
const items = await ctx.db
.query("orderItems")
.withIndex("by_order", (q) => q.eq("orderId", orderId))
.collect();
return { ...order, items };
}
/**
* Determines whether a customer is allowed to cancel a given order.
*
* NOTE: Cancellation only updates order status and restores stock.
* Stripe refund processing is a separate concern handled via the admin
* dashboard or a future automated flow. This helper does NOT trigger a refund.
*/
export function canCustomerCancel(order: Doc<"orders">): {
allowed: boolean;
reason?: string;
} {
switch (order.status) {
case "confirmed":
return { allowed: true };
case "pending":
return {
allowed: false,
reason: "Order is still awaiting payment confirmation.",
};
case "cancelled":
return { allowed: false, reason: "Order is already cancelled." };
case "refunded":
return { allowed: false, reason: "Order has already been refunded." };
default:
return {
allowed: false,
reason:
"Order has progressed past the cancellation window. Please contact support.",
};
}
}
export interface OutOfStockItem {
variantId: Id<"productVariants">;
requested: number;
available: number;
}
/**
* Check each cart item for sufficient stock. Returns list of out-of-stock entries.
*/
export async function validateCartItems(
ctx: Pick<QueryCtx, "db">,
items: { variantId?: Id<"productVariants">; quantity: number }[]
): Promise<OutOfStockItem[]> {
const outOfStock: OutOfStockItem[] = [];
for (const item of items) {
if (!item.variantId) continue;
const variant = await ctx.db.get(item.variantId);
if (!variant) {
outOfStock.push({
variantId: item.variantId,
requested: item.quantity,
available: 0,
});
continue;
}
if (variant.stockQuantity < item.quantity) {
outOfStock.push({
variantId: item.variantId,
requested: item.quantity,
available: variant.stockQuantity,
});
}
}
return outOfStock;
}