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:
291
convex/schema.ts
Normal file
291
convex/schema.ts
Normal 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"]),
|
||||
});
|
||||
Reference in New Issue
Block a user