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>
137 lines
3.9 KiB
TypeScript
137 lines
3.9 KiB
TypeScript
"use node";
|
|
|
|
import { action } from "./_generated/server";
|
|
import { ConvexError, v } from "convex/values";
|
|
import { internal } from "./_generated/api";
|
|
import {
|
|
validateAddressWithShippo,
|
|
computeParcel,
|
|
getShippingRatesFromShippo,
|
|
selectBestRate,
|
|
MAX_PARCEL_WEIGHT_G,
|
|
} from "./model/shippo";
|
|
import type { ShippingRateResult } from "./model/checkout";
|
|
|
|
export const validateAddress = action({
|
|
args: {
|
|
addressLine1: v.string(),
|
|
additionalInformation: v.optional(v.string()),
|
|
city: v.string(),
|
|
postalCode: v.string(),
|
|
country: v.string(),
|
|
name: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const identity = await ctx.auth.getUserIdentity();
|
|
if (!identity) {
|
|
throw new Error("You must be signed in to validate an address.");
|
|
}
|
|
|
|
return await validateAddressWithShippo({
|
|
addressLine1: args.addressLine1,
|
|
additionalInformation: args.additionalInformation,
|
|
city: args.city,
|
|
postalCode: args.postalCode,
|
|
country: args.country,
|
|
name: args.name,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const getShippingRate = action({
|
|
args: {
|
|
addressId: v.id("addresses"),
|
|
sessionId: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args): Promise<ShippingRateResult> => {
|
|
const identity = await ctx.auth.getUserIdentity();
|
|
if (!identity) {
|
|
throw new Error("You must be signed in to get shipping rates.");
|
|
}
|
|
|
|
const address = await ctx.runQuery(internal.checkout.getAddressById, {
|
|
addressId: args.addressId,
|
|
});
|
|
|
|
if (address.isValidated !== true) {
|
|
console.warn(
|
|
`Shipping rate requested for unvalidated address ${args.addressId}. ` +
|
|
"The shipping address step should validate before proceeding.",
|
|
);
|
|
}
|
|
|
|
const userId = await ctx.runQuery(internal.checkout.getCurrentUserId);
|
|
|
|
const cartResult = await ctx.runQuery(
|
|
internal.checkout.validateCartInternal,
|
|
{ userId, sessionId: args.sessionId },
|
|
);
|
|
|
|
if (!cartResult) {
|
|
throw new ConvexError("Your cart is empty.");
|
|
}
|
|
if (!cartResult.valid) {
|
|
throw new ConvexError(
|
|
"Your cart has issues that need to be resolved before checkout.",
|
|
);
|
|
}
|
|
|
|
const parcel = computeParcel(cartResult.items);
|
|
|
|
const weightG = parseFloat(parcel.weight);
|
|
if (weightG > MAX_PARCEL_WEIGHT_G) {
|
|
const actualKg = (weightG / 1000).toFixed(1);
|
|
const maxKg = MAX_PARCEL_WEIGHT_G / 1000;
|
|
throw new ConvexError(
|
|
`Your order weighs ${actualKg}kg, which exceeds our maximum shipping weight of ${maxKg}kg. ` +
|
|
"Please remove some items or reduce quantities to proceed.",
|
|
);
|
|
}
|
|
|
|
const shippoAddress = {
|
|
name: address.fullName,
|
|
street1: address.addressLine1,
|
|
street2: address.additionalInformation,
|
|
city: address.city,
|
|
zip: address.postalCode,
|
|
country: address.country,
|
|
phone: address.phone,
|
|
};
|
|
|
|
const sourceAddressId = process.env.SHIPPO_SOURCE_ADDRESS_ID;
|
|
if (!sourceAddressId) {
|
|
throw new ConvexError(
|
|
"Shipping configuration is incomplete (missing source address).",
|
|
);
|
|
}
|
|
|
|
const { shipmentObjectId, rates } = await getShippingRatesFromShippo({
|
|
sourceAddressId,
|
|
destinationAddress: shippoAddress,
|
|
parcels: [parcel],
|
|
});
|
|
|
|
const { selected, alternatives } = selectBestRate(rates);
|
|
|
|
const mapRate = (r: typeof selected) => ({
|
|
provider: r.provider,
|
|
serviceName: r.servicelevelName,
|
|
serviceToken: r.servicelevelToken,
|
|
amount: parseFloat(r.amount),
|
|
currency: r.currency,
|
|
estimatedDays: r.estimatedDays,
|
|
durationTerms: r.durationTerms,
|
|
carrierAccount: r.carrierAccount,
|
|
});
|
|
|
|
return {
|
|
shipmentObjectId,
|
|
selectedRate: mapRate(selected),
|
|
alternativeRates: alternatives.map(mapRate),
|
|
cartSubtotal: cartResult.subtotal,
|
|
shippingTotal: parseFloat(selected.amount),
|
|
orderTotal: cartResult.subtotal + parseFloat(selected.amount),
|
|
};
|
|
},
|
|
});
|