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:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user