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:
380
convex/model/shippo.ts
Normal file
380
convex/model/shippo.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import type {
|
||||
AddressValidationResult,
|
||||
RecommendedAddress,
|
||||
ShippoRate,
|
||||
ValidatedCartItem,
|
||||
} from "./checkout";
|
||||
|
||||
type ValidateAddressInput = {
|
||||
addressLine1: string;
|
||||
additionalInformation?: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type ShippoRawResponse = {
|
||||
original_address: {
|
||||
address_line_1: string;
|
||||
address_line_2?: string;
|
||||
city_locality: string;
|
||||
state_province: string;
|
||||
postal_code: string;
|
||||
country_code: string;
|
||||
name?: string;
|
||||
organization?: string;
|
||||
};
|
||||
recommended_address?: {
|
||||
address_line_1: string;
|
||||
address_line_2?: string;
|
||||
city_locality: string;
|
||||
state_province: string;
|
||||
postal_code: string;
|
||||
country_code: string;
|
||||
complete_address?: string;
|
||||
confidence_result: {
|
||||
score: "high" | "medium" | "low";
|
||||
code: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
analysis: {
|
||||
validation_result: {
|
||||
value: "valid" | "partially_valid" | "invalid";
|
||||
reasons: Array<{ code: string; description: string }>;
|
||||
};
|
||||
address_type:
|
||||
| "residential"
|
||||
| "commercial"
|
||||
| "unknown"
|
||||
| "po_box"
|
||||
| "military";
|
||||
changed_attributes?: string[];
|
||||
};
|
||||
geo?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
};
|
||||
|
||||
const SHIPPO_VALIDATE_URL =
|
||||
"https://api.goshippo.com/v2/addresses/validate";
|
||||
|
||||
/**
|
||||
* Calls Shippo Address Validation v2 and normalizes the response into
|
||||
* an `AddressValidationResult`. This is a pure async helper — it does NOT
|
||||
* export a Convex function; it's consumed by actions in `checkoutActions.ts`.
|
||||
*/
|
||||
export async function validateAddressWithShippo(
|
||||
input: ValidateAddressInput,
|
||||
): Promise<AddressValidationResult> {
|
||||
const apiKey = process.env.SHIPPO_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new ConvexError(
|
||||
"Address validation is unavailable (missing API key configuration).",
|
||||
);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("address_line_1", input.addressLine1);
|
||||
if (input.additionalInformation)
|
||||
params.set("address_line_2", input.additionalInformation);
|
||||
params.set("city_locality", input.city);
|
||||
params.set("postal_code", input.postalCode);
|
||||
params.set("country_code", input.country);
|
||||
if (input.name) params.set("name", input.name);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${SHIPPO_VALIDATE_URL}?${params.toString()}`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `ShippoToken ${apiKey}` },
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ConvexError(
|
||||
"Address validation service is unreachable. Please try again later.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ConvexError(
|
||||
`Address validation service unavailable (status ${response.status}).`,
|
||||
);
|
||||
}
|
||||
|
||||
let body: ShippoRawResponse;
|
||||
try {
|
||||
body = (await response.json()) as ShippoRawResponse;
|
||||
} catch {
|
||||
throw new ConvexError(
|
||||
"Address validation returned an unexpected response. Please try again.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!body.analysis?.validation_result) {
|
||||
throw new ConvexError(
|
||||
"Address validation returned a malformed response.",
|
||||
);
|
||||
}
|
||||
|
||||
const { analysis, recommended_address, original_address } = body;
|
||||
|
||||
let recommendedAddress: RecommendedAddress | undefined;
|
||||
if (recommended_address) {
|
||||
recommendedAddress = {
|
||||
addressLine1: recommended_address.address_line_1,
|
||||
additionalInformation: recommended_address.address_line_2,
|
||||
city: recommended_address.city_locality,
|
||||
postalCode: recommended_address.postal_code,
|
||||
country: recommended_address.country_code,
|
||||
completeAddress: recommended_address.complete_address,
|
||||
confidenceScore: recommended_address.confidence_result.score,
|
||||
confidenceCode: recommended_address.confidence_result.code,
|
||||
confidenceDescription:
|
||||
recommended_address.confidence_result.description,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: analysis.validation_result.value === "valid",
|
||||
validationValue: analysis.validation_result.value,
|
||||
reasons: analysis.validation_result.reasons.map((r) => ({
|
||||
code: r.code,
|
||||
description: r.description,
|
||||
})),
|
||||
addressType: analysis.address_type,
|
||||
changedAttributes: analysis.changed_attributes ?? [],
|
||||
recommendedAddress,
|
||||
originalAddress: {
|
||||
addressLine1: original_address.address_line_1,
|
||||
additionalInformation: original_address.address_line_2,
|
||||
city: original_address.city_locality,
|
||||
postalCode: original_address.postal_code,
|
||||
country: original_address.country_code,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Shipping Rates Helpers ──────────────────────────────────────────────────
|
||||
|
||||
export const PREFERRED_CARRIERS = ["DPD UK", "Evri UK", "UPS", "UDS"];
|
||||
|
||||
/**
|
||||
* Hard ceiling across preferred UK carriers.
|
||||
* DPD UK premium (door-to-door, Saturday/Sunday): 30kg
|
||||
* DPD UK standard (Classic, Next Day, Two Day): 20kg
|
||||
* Evri UK (Courier Collection, ParcelShop): 15kg
|
||||
*/
|
||||
export const MAX_PARCEL_WEIGHT_G = 30_000;
|
||||
|
||||
const WEIGHT_TO_GRAMS: Record<ValidatedCartItem["weightUnit"], number> = {
|
||||
g: 1,
|
||||
kg: 1000,
|
||||
lb: 453.592,
|
||||
oz: 28.3495,
|
||||
};
|
||||
|
||||
const DIMENSION_TO_CM: Record<NonNullable<ValidatedCartItem["dimensionUnit"]>, number> = {
|
||||
cm: 1,
|
||||
in: 2.54,
|
||||
};
|
||||
|
||||
type ParcelResult = {
|
||||
weight: string;
|
||||
mass_unit: "g";
|
||||
length?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
distance_unit?: "cm";
|
||||
};
|
||||
|
||||
export function computeParcel(items: ValidatedCartItem[]): ParcelResult {
|
||||
let totalWeightGrams = 0;
|
||||
for (const item of items) {
|
||||
const factor = WEIGHT_TO_GRAMS[item.weightUnit];
|
||||
totalWeightGrams += item.weight * factor * item.quantity;
|
||||
}
|
||||
|
||||
const withDimensions = items.filter(
|
||||
(item): item is ValidatedCartItem & { length: number; width: number; height: number; dimensionUnit: "cm" | "in" } =>
|
||||
item.length != null && item.width != null && item.height != null && item.dimensionUnit != null,
|
||||
);
|
||||
|
||||
if (withDimensions.length === 0) {
|
||||
return { weight: String(Math.round(totalWeightGrams)), mass_unit: "g" };
|
||||
}
|
||||
|
||||
let maxLengthCm = 0;
|
||||
let maxWidthCm = 0;
|
||||
let totalHeightCm = 0;
|
||||
|
||||
for (const item of withDimensions) {
|
||||
const factor = DIMENSION_TO_CM[item.dimensionUnit];
|
||||
const lengthCm = item.length * factor;
|
||||
const widthCm = item.width * factor;
|
||||
const heightCm = item.height * factor * item.quantity;
|
||||
|
||||
if (lengthCm > maxLengthCm) maxLengthCm = lengthCm;
|
||||
if (widthCm > maxWidthCm) maxWidthCm = widthCm;
|
||||
totalHeightCm += heightCm;
|
||||
}
|
||||
|
||||
return {
|
||||
weight: String(Math.round(totalWeightGrams)),
|
||||
mass_unit: "g",
|
||||
length: String(Math.round(maxLengthCm * 100) / 100),
|
||||
width: String(Math.round(maxWidthCm * 100) / 100),
|
||||
height: String(Math.round(totalHeightCm * 100) / 100),
|
||||
distance_unit: "cm",
|
||||
};
|
||||
}
|
||||
|
||||
const SHIPPO_SHIPMENTS_URL = "https://api.goshippo.com/shipments/";
|
||||
|
||||
export async function getShippingRatesFromShippo(input: {
|
||||
sourceAddressId: string;
|
||||
destinationAddress: {
|
||||
name: string;
|
||||
street1: string;
|
||||
street2?: string;
|
||||
city: string;
|
||||
zip: string;
|
||||
country: string;
|
||||
phone?: string;
|
||||
};
|
||||
parcels: Array<{
|
||||
weight: string;
|
||||
mass_unit: string;
|
||||
length?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
distance_unit?: string;
|
||||
}>;
|
||||
}): Promise<{ shipmentObjectId: string; rates: ShippoRate[] }> {
|
||||
const apiKey = process.env.SHIPPO_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new ConvexError(
|
||||
"Shipping rate service is unavailable (missing API key configuration).",
|
||||
);
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
address_from: input.sourceAddressId,
|
||||
address_to: input.destinationAddress,
|
||||
parcels: input.parcels,
|
||||
async: false,
|
||||
};
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(SHIPPO_SHIPMENTS_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `ShippoToken ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
} catch {
|
||||
throw new ConvexError(
|
||||
"Shipping rate service is unreachable. Please try again later.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorDetail = "";
|
||||
try {
|
||||
const errBody = await response.json();
|
||||
errorDetail = JSON.stringify(errBody);
|
||||
console.error("Shippo /shipments/ error:", response.status, errorDetail);
|
||||
} catch {
|
||||
console.error("Shippo /shipments/ error:", response.status, "(no parseable body)");
|
||||
}
|
||||
throw new ConvexError(
|
||||
`Shipping rate service unavailable (status ${response.status}).`,
|
||||
);
|
||||
}
|
||||
|
||||
let body: {
|
||||
object_id: string;
|
||||
messages?: Array<{ source: string; text: string }>;
|
||||
rates: Array<{
|
||||
object_id: string;
|
||||
provider: string;
|
||||
servicelevel: { name: string; token: string };
|
||||
amount: string;
|
||||
currency: string;
|
||||
estimated_days: number;
|
||||
duration_terms: string;
|
||||
arrives_by?: string | null;
|
||||
carrier_account: string;
|
||||
}>;
|
||||
};
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new ConvexError(
|
||||
"Shipping rate service returned an unexpected response. Please try again.",
|
||||
);
|
||||
}
|
||||
|
||||
if (body.rates.length === 0 && body.messages?.length) {
|
||||
console.warn(
|
||||
"Shippo returned 0 rates. Carrier messages:",
|
||||
body.messages.map((m) => `[${m.source}] ${m.text}`).join(" | "),
|
||||
);
|
||||
}
|
||||
|
||||
const rates: ShippoRate[] = body.rates.map((rate) => ({
|
||||
objectId: rate.object_id,
|
||||
provider: rate.provider,
|
||||
servicelevelName: rate.servicelevel.name,
|
||||
servicelevelToken: rate.servicelevel.token,
|
||||
amount: rate.amount,
|
||||
currency: rate.currency,
|
||||
estimatedDays: rate.estimated_days,
|
||||
durationTerms: rate.duration_terms,
|
||||
arrivesBy: rate.arrives_by ?? null,
|
||||
carrierAccount: rate.carrier_account,
|
||||
}));
|
||||
|
||||
return { shipmentObjectId: body.object_id, rates };
|
||||
}
|
||||
|
||||
export function selectBestRate(rates: ShippoRate[]): {
|
||||
selected: ShippoRate;
|
||||
alternatives: ShippoRate[];
|
||||
} {
|
||||
if (rates.length === 0) {
|
||||
throw new ConvexError(
|
||||
"No shipping rates available for this address. Please verify your address and try again.",
|
||||
);
|
||||
}
|
||||
|
||||
const preferredLower = PREFERRED_CARRIERS.map((c) => c.toLowerCase());
|
||||
|
||||
const preferred = rates.filter((r) =>
|
||||
preferredLower.includes(r.provider.toLowerCase()),
|
||||
);
|
||||
|
||||
const sortByDaysThenPrice = (a: ShippoRate, b: ShippoRate) => {
|
||||
const aDays = a.estimatedDays ?? Infinity;
|
||||
const bDays = b.estimatedDays ?? Infinity;
|
||||
const daysDiff = aDays - bDays;
|
||||
if (daysDiff !== 0) return daysDiff;
|
||||
return parseFloat(a.amount) - parseFloat(b.amount);
|
||||
};
|
||||
|
||||
if (preferred.length > 0) {
|
||||
preferred.sort(sortByDaysThenPrice);
|
||||
return { selected: preferred[0], alternatives: preferred.slice(1, 3) };
|
||||
}
|
||||
|
||||
console.warn(
|
||||
"No preferred carriers returned rates. Falling back to all carriers.",
|
||||
);
|
||||
const sorted = [...rates].sort(sortByDaysThenPrice);
|
||||
return { selected: sorted[0], alternatives: sorted.slice(1, 3) };
|
||||
}
|
||||
Reference in New Issue
Block a user