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:
136
convex/checkoutActions.ts
Normal file
136
convex/checkoutActions.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
"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),
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user