Feature: Royal Mail shipping integration with weight-based postage

Added:
- Weight field (weightGrams) to Product model for storing item weights
- Shipping fields to Order model (shippingCost, shippingCountry, shippingService)
- Royal Mail shipping service (royalmail-shipping.ts) with:
  * Domestic and international postage calculations
  * Integration ready for Royal Mail API (currently uses estimated rates)
  * Support for multiple shipping services
  * Clear international delivery time warnings
- Shipping quotes API endpoint (/api/checkout/shipping-quotes)
- Checkout integration to calculate and include shipping cost

Weight is stored in grams, displayed to users in kilograms.
Shipping cost is calculated based on total cart weight and destination country.
This commit is contained in:
Andymick
2026-07-19 06:46:35 +01:00
parent 9b9745e6e2
commit e150f2aed9
7 changed files with 296 additions and 12 deletions
+78 -9
View File
@@ -5,6 +5,7 @@ import { getCurrentCustomer } from '@/lib/auth';
import { getEffectivePrice } from '@/lib/pricing';
import { fetchActivePromotions } from '@/lib/promotions';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
import { getRoyalMailShippingQuotes } from '@/lib/royalmail-shipping';
type CheckoutCartItem = {
productId: string;
@@ -54,6 +55,8 @@ export async function POST(req: Request) {
console.log('Checkout request received with items:', body.items?.length, 'customerId:', body.customerId);
const items: CheckoutCartItem[] = body.items ?? [];
const guest: GuestInfo | undefined = body.guest;
const shippingCountry: string = body.shippingCountry ?? 'GB';
const shippingService: string | undefined = body.shippingService;
if (items.length === 0) {
return NextResponse.json({ error: 'Your bag is empty.' }, { status: 400 });
@@ -86,7 +89,53 @@ export async function POST(req: Request) {
};
});
const total = pricedItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
const subtotal = pricedItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
// Calculate shipping cost
let shippingCost = 0;
let selectedShippingService = shippingService;
try {
// Calculate total weight from cart items
let totalWeightGrams = 0;
for (const item of items) {
const product = await prisma.product.findUnique({
where: { id: item.productId },
select: { weightGrams: true },
});
if (product?.weightGrams) {
totalWeightGrams += product.weightGrams * item.quantity;
}
}
// Default weight if none specified
if (totalWeightGrams === 0) {
totalWeightGrams = 500;
}
// Get available shipping quotes
const quotes = await getRoyalMailShippingQuotes(totalWeightGrams, shippingCountry);
// Find the selected shipping service or use the first (cheapest) option
if (shippingService) {
const selectedQuote = quotes.find((q) => q.service === shippingService);
if (selectedQuote) {
shippingCost = selectedQuote.price;
} else {
shippingCost = quotes[0].price;
selectedShippingService = quotes[0].service;
}
} else if (quotes.length > 0) {
shippingCost = quotes[0].price;
selectedShippingService = quotes[0].service;
}
} catch (error) {
console.error('Error calculating shipping:', error);
// Default shipping cost if calculation fails
shippingCost = 500; // £5.00
}
const total = subtotal + shippingCost;
const customer = await getCurrentCustomer();
// Save the order up front, before redirecting to Stripe — this is what makes
@@ -96,6 +145,9 @@ export async function POST(req: Request) {
data: {
status: 'PENDING',
total,
shippingCost,
shippingCountry,
shippingService: selectedShippingService,
customerId: customer?.id,
email: guest?.email ?? customer?.email,
guestName: guest?.name,
@@ -123,19 +175,36 @@ export async function POST(req: Request) {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
try {
console.log('Creating Stripe session for order:', order.id, 'total:', total);
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: pricedItems.map((i) => ({
console.log('Creating Stripe session for order:', order.id, 'subtotal:', subtotal, 'shipping:', shippingCost, 'total:', total);
const lineItems = pricedItems.map((i) => ({
price_data: {
currency: 'gbp',
unit_amount: i.unitPrice,
product_data: {
name: `${i.name}${i.color}${i.size ? `, ${i.size}` : ''}`,
},
},
quantity: i.quantity,
}));
// Add shipping as a line item
if (shippingCost > 0) {
lineItems.push({
price_data: {
currency: 'gbp',
unit_amount: i.unitPrice,
unit_amount: shippingCost,
product_data: {
name: `${i.name}${i.color}${i.size ? `, ${i.size}` : ''}`,
name: `Royal Mail Shipping${selectedShippingService ? ` ${selectedShippingService}` : ''}`,
},
},
quantity: i.quantity,
})),
quantity: 1,
});
}
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: lineItems,
shipping_address_collection: {
allowed_countries: ['GB', 'IE', 'US', 'CA', 'AU', 'NZ'],
},
@@ -0,0 +1,60 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getRoyalMailShippingQuotes, formatShippingPrice } from '@/lib/royalmail-shipping';
interface CartItem {
productId: string;
quantity: number;
}
export async function POST(req: Request) {
try {
const { items, country } = await req.json();
if (!items || !Array.isArray(items) || !country) {
return NextResponse.json(
{ error: 'Missing items or country' },
{ status: 400 }
);
}
// Calculate total weight from cart items
let totalWeightGrams = 0;
for (const item of items) {
const product = await prisma.product.findUnique({
where: { id: item.productId },
select: { weightGrams: true },
});
if (product?.weightGrams) {
totalWeightGrams += product.weightGrams * item.quantity;
}
}
// If no weight is set for products, return default shipping
if (totalWeightGrams === 0) {
totalWeightGrams = 500; // Default 500g if not specified
}
// Get Royal Mail shipping quotes
const quotes = await getRoyalMailShippingQuotes(totalWeightGrams, country);
return NextResponse.json({
success: true,
totalWeightGrams,
totalWeightKg: (totalWeightGrams / 1000).toFixed(2),
country,
quotes: quotes.map((q) => ({
...q,
priceFormatted: formatShippingPrice(q.price),
})),
});
} catch (error) {
console.error('Error calculating shipping quotes:', error);
return NextResponse.json(
{ error: 'Failed to calculate shipping' },
{ status: 500 }
);
}
}