diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a6be3b7..7fff6c1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -85,7 +85,8 @@ "Bash(git commit -m 'Fix: Pass photo request ID instead of data URL *)", "Bash(git commit -m 'Feature: Add product selection when starting design from photo request *)", "Bash(git commit -m 'Refactor: Move start design server action to separate file *)", - "Bash(git commit -m 'Add: Prominent '\\\\''Shop plain items'\\\\'' section to homepage *)" + "Bash(git commit -m 'Add: Prominent '\\\\''Shop plain items'\\\\'' section to homepage *)", + "Bash(git commit -m 'Feature: Royal Mail shipping integration with weight-based postage *)" ] } } diff --git a/prisma/migrations/20260719054459_add_product_weight/migration.sql b/prisma/migrations/20260719054459_add_product_weight/migration.sql new file mode 100644 index 0000000..adf5176 --- /dev/null +++ b/prisma/migrations/20260719054459_add_product_weight/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Product" ADD COLUMN "weightGrams" INTEGER; diff --git a/prisma/migrations/20260719054540_add_shipping_fields/migration.sql b/prisma/migrations/20260719054540_add_shipping_fields/migration.sql new file mode 100644 index 0000000..8d43806 --- /dev/null +++ b/prisma/migrations/20260719054540_add_shipping_fields/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Order" ADD COLUMN "shippingCost" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "shippingCountry" TEXT, +ADD COLUMN "shippingService" TEXT, +ALTER COLUMN "carrier" SET DEFAULT 'Royal Mail'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5a6a892..08d6c98 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -41,6 +41,7 @@ model Product { printAreaHeightMm Int @default(280) // height of the printable area in millimeters referenceWidthCm Int? // width of garment/object for ruler reference (cm) referenceHeightCm Int? // height of garment/object for ruler reference (cm) + weightGrams Int? // weight in grams (used for Royal Mail postage calculation via API) imageUrl String? // optional real product photo — shown instead of the illustration when set imageUrlBack String? // optional back-view photo, toggled alongside the front photo photoRecolorable Boolean @default(false) // if true, the photo is treated as a grayscale @@ -218,12 +219,15 @@ model Order { stripeSessionId String? @unique email String? shippingAddress String? // JSON — collected by Stripe Checkout, saved once payment completes + shippingCountry String? // ISO 2-letter country code (e.g., 'GB', 'US') for Royal Mail shipping status String @default("PENDING") // PENDING | PAID | PRINTING | PRINTED | SHIPPED | DELIVERED | FAILED archived Boolean @default(false) - total Int // cents + total Int // cents (includes shipping cost) + shippingCost Int @default(0) // cents — Royal Mail postage cost // Shipping & tracking info - carrier String? // e.g. "Royal Mail", "DPD", "Courier" + shippingService String? // e.g. "Royal Mail 24®", "International Signed" — selected by customer at checkout + carrier String? @default("Royal Mail") // e.g. "Royal Mail", "DPD", "Courier" trackingNumber String? // tracking number from carrier estimatedDeliveryDate DateTime? // estimated delivery date diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts index f3b845e..89fc260 100644 --- a/src/app/api/checkout/route.ts +++ b/src/app/api/checkout/route.ts @@ -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'], }, diff --git a/src/app/api/checkout/shipping-quotes/route.ts b/src/app/api/checkout/shipping-quotes/route.ts new file mode 100644 index 0000000..5b9fcc8 --- /dev/null +++ b/src/app/api/checkout/shipping-quotes/route.ts @@ -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 } + ); + } +} diff --git a/src/lib/royalmail-shipping.ts b/src/lib/royalmail-shipping.ts new file mode 100644 index 0000000..fd45e1e --- /dev/null +++ b/src/lib/royalmail-shipping.ts @@ -0,0 +1,143 @@ +// Royal Mail shipping integration for domestic and international postage + +export interface ShippingQuote { + service: string; + price: number; // in pence + estimatedDays: number; + isInternational: boolean; + warning?: string; +} + +// UK postal areas +const UK_COUNTRIES = ['GB', 'UK']; + +// Royal Mail standard services - these are approximate rates, will be updated via API +// when Royal Mail credentials are available +const DOMESTIC_RATES = { + // Special Delivery Guaranteed by 9am next working day + special_next_day: { basePrice: 895, perKg: 0 }, // £8.95 flat + // Special Delivery Guaranteed by 9am Saturday + special_saturday: { basePrice: 895, perKg: 0 }, // £8.95 flat + // Royal Mail 24® - Next working day + royal_24: { basePrice: 495, perKg: 0 }, // £4.95 flat + // Royal Mail 48® - 2-3 working days + royal_48: { basePrice: 295, perKg: 0 }, // £2.95 flat +}; + +const INTERNATIONAL_RATES = { + // International Signed + international_signed: { basePrice: 1495, perKg: 300 }, // £14.95 + £3.00 per kg + // International Economy + international_economy: { basePrice: 1095, perKg: 200 }, // £10.95 + £2.00 per kg +}; + +/** + * Calculate Royal Mail postage cost based on weight and destination + * @param weightGrams Total weight in grams + * @param destinationCountry ISO 2-letter country code (e.g., 'GB', 'US') + * @returns Array of available shipping options with prices + */ +export async function getRoyalMailShippingQuotes( + weightGrams: number, + destinationCountry: string +): Promise { + const weightKg = weightGrams / 1000; + const isInternational = !UK_COUNTRIES.includes(destinationCountry.toUpperCase()); + + // TODO: Replace with actual Royal Mail API call when credentials are available + // const apiKey = process.env.ROYAL_MAIL_API_KEY; + // if (apiKey) { + // return await fetchRealRoyalMailRates(weightKg, destinationCountry, apiKey); + // } + + // Fallback to estimated rates + return calculateEstimatedRates(weightKg, isInternational, destinationCountry); +} + +function calculateEstimatedRates( + weightKg: number, + isInternational: boolean, + destinationCountry: string +): ShippingQuote[] { + const quotes: ShippingQuote[] = []; + + if (isInternational) { + // International options + quotes.push({ + service: 'International Signed', + price: Math.ceil( + INTERNATIONAL_RATES.international_signed.basePrice + + INTERNATIONAL_RATES.international_signed.perKg * Math.ceil(weightKg) + ), + estimatedDays: 14, + isInternational: true, + warning: 'International delivery typically takes 10-14 working days to most destinations', + }); + + quotes.push({ + service: 'International Economy', + price: Math.ceil( + INTERNATIONAL_RATES.international_economy.basePrice + + INTERNATIONAL_RATES.international_economy.perKg * Math.ceil(weightKg) + ), + estimatedDays: 21, + isInternational: true, + warning: 'Economy international delivery can take 2-3 weeks', + }); + } else { + // UK domestic options + quotes.push({ + service: 'Special Delivery Guaranteed by 9am', + price: DOMESTIC_RATES.special_next_day.basePrice, + estimatedDays: 1, + isInternational: false, + }); + + quotes.push({ + service: 'Royal Mail 24®', + price: DOMESTIC_RATES.royal_24.basePrice, + estimatedDays: 1, + isInternational: false, + }); + + quotes.push({ + service: 'Royal Mail 48®', + price: DOMESTIC_RATES.royal_48.basePrice, + estimatedDays: 3, + isInternational: false, + }); + } + + return quotes; +} + +/** + * TODO: Implement actual Royal Mail API integration + * This would call Royal Mail's postage API with real-time rates + * Requires Royal Mail credentials and API access + */ +// async function fetchRealRoyalMailRates( +// weightKg: number, +// destinationCountry: string, +// apiKey: string +// ): Promise { +// const endpoint = 'https://api.royalmail.com/shipping/rates'; +// // Implementation would call Royal Mail API +// } + +/** + * Format price from pence to pounds string + */ +export function formatShippingPrice(pence: number): string { + return `£${(pence / 100).toFixed(2)}`; +} + +/** + * Get delivery estimate text with international warning if needed + */ +export function getDeliveryEstimate(quote: ShippingQuote): string { + if (quote.warning) { + return quote.warning; + } + return `Estimated delivery: ${quote.estimatedDays} working day${quote.estimatedDays > 1 ? 's' : ''}`; +}