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

291
convex/schema.ts Normal file
View File

@@ -0,0 +1,291 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
// ─── Customers ─────────────────────────────────────────────────────────
users: defineTable({
externalId: v.string(),
email: v.string(),
name: v.string(),
firstName: v.optional(v.string()),
lastName: v.optional(v.string()),
role: v.union(
v.literal("customer"),
v.literal("admin"),
v.literal("super_admin"),
),
avatarUrl: v.optional(v.string()),
phone: v.optional(v.string()),
stripeCustomerId: v.optional(v.string()),
createdAt: v.optional(v.number()),
lastLoginAt: v.optional(v.number()),
})
.index("by_external_id", ["externalId"])
.index("by_email", ["email"])
.index("by_role", ["role"]),
addresses: defineTable({
userId: v.id("users"),
type: v.union(v.literal("shipping"), v.literal("billing")),
fullName: v.string(),
firstName: v.string(),
lastName: v.string(),
phone: v.string(),
addressLine1: v.string(),
additionalInformation: v.optional(v.string()),
city: v.string(),
postalCode: v.string(),
country: v.string(),
isDefault: v.boolean(),
isValidated: v.optional(v.boolean()),
})
.index("by_user", ["userId"])
.index("by_user_and_type", ["userId", "type"]),
// ─── Catalog (Categories & Products) ────────────────────────────────────
categories: defineTable({
name: v.string(),
slug: v.string(),
description: v.optional(v.string()),
imageUrl: v.optional(v.string()),
parentId: v.optional(v.id("categories")),
topCategorySlug: v.optional(v.string()),
seoTitle: v.optional(v.string()),
seoDescription: v.optional(v.string()),
})
.index("by_slug", ["slug"])
.index("by_parent", ["parentId"])
.index("by_parent_slug", ["parentId", "slug"])
.index("by_top_category_slug", ["topCategorySlug"]),
products: defineTable({
name: v.string(),
slug: v.string(),
description: v.optional(v.string()),
shortDescription: v.optional(v.string()),
status: v.union(
v.literal("active"),
v.literal("draft"),
v.literal("archived"),
),
categoryId: v.id("categories"),
brand: v.optional(v.string()),
tags: v.array(v.string()),
attributes: v.optional(
v.object({
petSize: v.optional(v.array(v.string())),
ageRange: v.optional(v.array(v.string())),
specialDiet: v.optional(v.array(v.string())),
material: v.optional(v.string()),
flavor: v.optional(v.string()),
}),
),
seoTitle: v.optional(v.string()),
seoDescription: v.optional(v.string()),
canonicalSlug: v.optional(v.string()),
averageRating: v.optional(v.number()),
reviewCount: v.optional(v.number()),
createdAt: v.optional(v.number()),
updatedAt: v.optional(v.number()),
parentCategorySlug: v.string(),
childCategorySlug: v.string(),
topCategorySlug: v.optional(v.string()),
})
.index("by_slug", ["slug"])
.index("by_status", ["status"])
.index("by_category", ["categoryId"])
.index("by_status_and_category", ["status", "categoryId"])
.index("by_brand", ["brand"])
.index("by_status_and_parent_slug", ["status", "parentCategorySlug"])
.index("by_status_and_parent_and_child_slug", [
"status",
"parentCategorySlug",
"childCategorySlug",
])
.index("by_status_and_top_category_slug", ["status", "topCategorySlug"])
.index("by_status_and_top_and_parent_slug", [
"status",
"topCategorySlug",
"parentCategorySlug",
])
.searchIndex("search_products", {
searchField: "name",
filterFields: ["status", "categoryId", "brand", "parentCategorySlug"],
}),
productImages: defineTable({
productId: v.id("products"),
url: v.string(),
alt: v.optional(v.string()),
position: v.number(),
}).index("by_product", ["productId"]),
productVariants: defineTable({
productId: v.id("products"),
name: v.string(),
sku: v.string(),
price: v.number(),
compareAtPrice: v.optional(v.number()),
stockQuantity: v.number(),
attributes: v.optional(
v.object({
size: v.optional(v.string()),
flavor: v.optional(v.string()),
color: v.optional(v.string()),
}),
),
isActive: v.boolean(),
weight: v.number(),
weightUnit: v.union(
v.literal("g"),
v.literal("kg"),
v.literal("lb"),
v.literal("oz"),
),
length: v.optional(v.number()),
width: v.optional(v.number()),
height: v.optional(v.number()),
dimensionUnit: v.optional(v.union(
v.literal("cm"),
v.literal("in"),
)),
})
.index("by_product", ["productId"])
.index("by_sku", ["sku"])
.index("by_product_and_active", ["productId", "isActive"]),
// ─── Orders & Payments ──────────────────────────────────────────────────
orders: defineTable({
orderNumber: v.string(),
userId: v.id("users"),
email: v.string(),
status: v.union(
v.literal("pending"),
v.literal("confirmed"),
v.literal("processing"),
v.literal("shipped"),
v.literal("delivered"),
v.literal("cancelled"),
v.literal("refunded"),
),
paymentStatus: v.union(
v.literal("pending"),
v.literal("paid"),
v.literal("failed"),
v.literal("refunded"),
),
subtotal: v.number(),
tax: v.number(),
shipping: v.number(),
discount: v.number(),
total: v.number(),
currency: v.string(),
shippingAddressSnapshot: v.object({
fullName: v.string(),
firstName: v.string(),
lastName: v.string(),
addressLine1: v.string(),
additionalInformation: v.optional(v.string()),
city: v.string(),
postalCode: v.string(),
country: v.string(),
phone: v.optional(v.string()),
}),
billingAddressSnapshot: v.object({
firstName: v.string(),
lastName: v.string(),
addressLine1: v.string(),
additionalInformation: v.optional(v.string()),
city: v.string(),
postalCode: v.string(),
country: v.string(),
}),
stripePaymentIntentId: v.optional(v.string()),
stripeCheckoutSessionId: v.optional(v.string()),
shippoOrderId: v.optional(v.string()),
shippoShipmentId: v.string(),
shippingMethod: v.string(),
shippingServiceCode: v.string(),
carrier: v.string(),
trackingNumber: v.optional(v.string()),
trackingUrl: v.optional(v.string()),
estimatedDelivery: v.optional(v.number()),
actualDelivery: v.optional(v.number()),
notes: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
paidAt: v.optional(v.number()),
shippedAt: v.optional(v.number()),
})
.index("by_user", ["userId"])
.index("by_status", ["status"])
.index("by_payment_status", ["paymentStatus"])
.index("by_order_number", ["orderNumber"])
.index("by_email", ["email"])
.index("by_created_at", ["createdAt"])
.index("by_stripe_checkout_session_id", ["stripeCheckoutSessionId"]),
orderItems: defineTable({
orderId: v.id("orders"),
variantId: v.id("productVariants"),
productName: v.string(),
variantName: v.string(),
sku: v.string(),
quantity: v.number(),
unitPrice: v.number(),
totalPrice: v.number(),
imageUrl: v.optional(v.string()),
}).index("by_order", ["orderId"]),
// ─── Reviews ───────────────────────────────────────────────────────────
reviews: defineTable({
productId: v.id("products"),
userId: v.id("users"),
orderId: v.optional(v.id("orders")),
rating: v.number(),
title: v.string(),
content: v.string(),
images: v.optional(v.array(v.string())),
verifiedPurchase: v.boolean(),
helpfulCount: v.number(),
createdAt: v.number(),
updatedAt: v.optional(v.number()),
isApproved: v.boolean(),
})
.index("by_product", ["productId"])
.index("by_user", ["userId"])
.index("by_product_approved", ["productId", "isApproved"]),
// ─── Wishlists ──────────────────────────────────────────────────────────
wishlists: defineTable({
userId: v.id("users"),
productId: v.id("products"),
variantId: v.optional(v.id("productVariants")),
addedAt: v.number(),
notifyOnPriceDrop: v.boolean(),
notifyOnBackInStock: v.boolean(),
priceWhenAdded: v.number(),
})
.index("by_user", ["userId"])
.index("by_product", ["productId"])
.index("by_user_and_product", ["userId", "productId"]),
// ─── Carts ──────────────────────────────────────────────────────────────
carts: defineTable({
userId: v.optional(v.id("users")),
sessionId: v.optional(v.string()),
items: v.array(
v.object({
productId: v.id("products"),
variantId: v.optional(v.id("productVariants")),
quantity: v.number(),
price: v.number(),
}),
),
createdAt: v.number(),
updatedAt: v.number(),
expiresAt: v.number(),
})
.index("by_user", ["userId"])
.index("by_session", ["sessionId"]),
});