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:
257
convex/model/checkout.ts
Normal file
257
convex/model/checkout.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import type { Id } from "../_generated/dataModel";
|
||||
import type { QueryCtx } from "../_generated/server";
|
||||
|
||||
// ─── Cart Item Issues (discriminated union) ─────────────────────────────────
|
||||
|
||||
export type CartItemIssue =
|
||||
| { type: "out_of_stock"; variantId: Id<"productVariants">; requested: number; available: number }
|
||||
| { type: "insufficient_stock"; variantId: Id<"productVariants">; requested: number; available: number }
|
||||
| { type: "variant_inactive"; variantId: Id<"productVariants"> }
|
||||
| { type: "variant_not_found"; variantId: Id<"productVariants"> }
|
||||
| { type: "product_not_found"; productId: Id<"products"> }
|
||||
| { type: "price_changed"; variantId: Id<"productVariants">; oldPrice: number; newPrice: number };
|
||||
|
||||
// ─── Validated & Enriched Cart Item ─────────────────────────────────────────
|
||||
|
||||
export type ValidatedCartItem = {
|
||||
variantId: Id<"productVariants">;
|
||||
productId: Id<"products">;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
originalPrice: number;
|
||||
productName: string;
|
||||
variantName: string;
|
||||
sku: string;
|
||||
imageUrl: string | undefined;
|
||||
stockQuantity: number;
|
||||
|
||||
weight: number;
|
||||
weightUnit: "g" | "kg" | "lb" | "oz";
|
||||
length: number | undefined;
|
||||
width: number | undefined;
|
||||
height: number | undefined;
|
||||
dimensionUnit: "cm" | "in" | undefined;
|
||||
|
||||
productSlug: string;
|
||||
parentCategorySlug: string;
|
||||
childCategorySlug: string;
|
||||
};
|
||||
|
||||
// ─── Validation Result ──────────────────────────────────────────────────────
|
||||
|
||||
export type CartValidationResult = {
|
||||
valid: boolean;
|
||||
items: ValidatedCartItem[];
|
||||
issues: CartItemIssue[];
|
||||
subtotal: number;
|
||||
};
|
||||
|
||||
// ─── Validation & Enrichment Helper ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validates every cart line item (stock, active status, price drift) and
|
||||
* enriches each with weight/dimensions for downstream shipping-rate lookups.
|
||||
*
|
||||
* Price-change issues are warnings — they do NOT block checkout.
|
||||
* Stock/missing/inactive issues ARE blocking.
|
||||
*/
|
||||
export async function validateAndEnrichCart(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
items: { productId: Id<"products">; variantId?: Id<"productVariants">; quantity: number; price: number }[]
|
||||
): Promise<CartValidationResult> {
|
||||
const validatedItems: ValidatedCartItem[] = [];
|
||||
const issues: CartItemIssue[] = [];
|
||||
const blockingVariantIds = new Set<Id<"productVariants">>();
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.variantId) continue;
|
||||
const variantId = item.variantId;
|
||||
|
||||
const variant = await ctx.db.get(variantId);
|
||||
if (!variant) {
|
||||
issues.push({ type: "variant_not_found" as const, variantId });
|
||||
continue;
|
||||
}
|
||||
if (!variant.isActive) {
|
||||
issues.push({ type: "variant_inactive" as const, variantId });
|
||||
continue;
|
||||
}
|
||||
|
||||
const product = await ctx.db.get(variant.productId);
|
||||
if (!product || product.status !== "active") {
|
||||
issues.push({ type: "product_not_found" as const, productId: item.productId });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (variant.stockQuantity === 0) {
|
||||
issues.push({
|
||||
type: "out_of_stock" as const,
|
||||
variantId,
|
||||
requested: item.quantity,
|
||||
available: 0,
|
||||
});
|
||||
blockingVariantIds.add(variantId);
|
||||
} else if (variant.stockQuantity < item.quantity) {
|
||||
issues.push({
|
||||
type: "insufficient_stock" as const,
|
||||
variantId,
|
||||
requested: item.quantity,
|
||||
available: variant.stockQuantity,
|
||||
});
|
||||
blockingVariantIds.add(variantId);
|
||||
}
|
||||
|
||||
if (variant.price !== item.price) {
|
||||
issues.push({
|
||||
type: "price_changed" as const,
|
||||
variantId,
|
||||
oldPrice: item.price,
|
||||
newPrice: variant.price,
|
||||
});
|
||||
}
|
||||
|
||||
const images = await ctx.db
|
||||
.query("productImages")
|
||||
.withIndex("by_product", (q) => q.eq("productId", variant.productId))
|
||||
.collect();
|
||||
images.sort((a, b) => a.position - b.position);
|
||||
|
||||
validatedItems.push({
|
||||
variantId,
|
||||
productId: variant.productId,
|
||||
quantity: item.quantity,
|
||||
unitPrice: variant.price,
|
||||
originalPrice: item.price,
|
||||
productName: product.name,
|
||||
variantName: variant.name,
|
||||
sku: variant.sku,
|
||||
imageUrl: images[0]?.url,
|
||||
stockQuantity: variant.stockQuantity,
|
||||
weight: variant.weight,
|
||||
weightUnit: variant.weightUnit,
|
||||
length: variant.length,
|
||||
width: variant.width,
|
||||
height: variant.height,
|
||||
dimensionUnit: variant.dimensionUnit,
|
||||
productSlug: product.slug,
|
||||
parentCategorySlug: product.parentCategorySlug,
|
||||
childCategorySlug: product.childCategorySlug,
|
||||
});
|
||||
}
|
||||
|
||||
const blockingIssues = issues.filter(i => i.type !== "price_changed");
|
||||
const valid = blockingIssues.length === 0;
|
||||
|
||||
const subtotal = validatedItems
|
||||
.filter(item => !blockingVariantIds.has(item.variantId))
|
||||
.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
|
||||
|
||||
return { valid, items: validatedItems, issues, subtotal };
|
||||
}
|
||||
|
||||
// ─── Shippo Shipping Rate Types ──────────────────────────────────────────────
|
||||
|
||||
export type ShippoRate = {
|
||||
objectId: string;
|
||||
provider: string;
|
||||
servicelevelName: string;
|
||||
servicelevelToken: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
estimatedDays: number | null;
|
||||
durationTerms: string;
|
||||
arrivesBy: string | null;
|
||||
carrierAccount: string;
|
||||
};
|
||||
|
||||
export type SelectedShippingRate = {
|
||||
provider: string;
|
||||
serviceName: string;
|
||||
serviceToken: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
estimatedDays: number | null;
|
||||
durationTerms: string;
|
||||
carrierAccount: string;
|
||||
};
|
||||
|
||||
export type ShippingRateResult = {
|
||||
shipmentObjectId: string;
|
||||
selectedRate: SelectedShippingRate;
|
||||
alternativeRates: SelectedShippingRate[];
|
||||
cartSubtotal: number;
|
||||
shippingTotal: number;
|
||||
orderTotal: number;
|
||||
};
|
||||
|
||||
// ─── Checkout Session Types (Stripe) ─────────────────────────────────────────
|
||||
|
||||
export type CreateCheckoutSessionInput = {
|
||||
addressId: Id<"addresses">;
|
||||
shipmentObjectId: string;
|
||||
shippingRate: {
|
||||
provider: string;
|
||||
serviceName: string;
|
||||
serviceToken: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
estimatedDays: number | null;
|
||||
durationTerms: string;
|
||||
carrierAccount: string;
|
||||
};
|
||||
sessionId: string | undefined;
|
||||
};
|
||||
|
||||
export type CheckoutSessionResult = {
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
export type CheckoutSessionStatus = {
|
||||
status: "complete" | "expired" | "open";
|
||||
paymentStatus: string;
|
||||
customerEmail: string | null;
|
||||
};
|
||||
|
||||
// ─── Shippo Address Validation Types ─────────────────────────────────────────
|
||||
|
||||
export type ShippoConfidenceScore = "high" | "medium" | "low";
|
||||
export type ShippoValidationValue = "valid" | "partially_valid" | "invalid";
|
||||
export type ShippoAddressType =
|
||||
| "residential"
|
||||
| "commercial"
|
||||
| "unknown"
|
||||
| "po_box"
|
||||
| "military";
|
||||
|
||||
export type ShippoValidationReason = {
|
||||
code: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type RecommendedAddress = {
|
||||
addressLine1: string;
|
||||
additionalInformation?: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
completeAddress?: string;
|
||||
confidenceScore: ShippoConfidenceScore;
|
||||
confidenceCode: string;
|
||||
confidenceDescription: string;
|
||||
};
|
||||
|
||||
export type AddressValidationResult = {
|
||||
isValid: boolean;
|
||||
validationValue: ShippoValidationValue;
|
||||
reasons: ShippoValidationReason[];
|
||||
addressType: ShippoAddressType;
|
||||
changedAttributes: string[];
|
||||
recommendedAddress?: RecommendedAddress;
|
||||
originalAddress: {
|
||||
addressLine1: string;
|
||||
additionalInformation?: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user