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>
258 lines
7.9 KiB
TypeScript
258 lines
7.9 KiB
TypeScript
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;
|
|
};
|
|
};
|