diff --git a/prisma/migrations/20260719102746_add_shipping_rates_table/migration.sql b/prisma/migrations/20260719102746_add_shipping_rates_table/migration.sql new file mode 100644 index 0000000..134db33 --- /dev/null +++ b/prisma/migrations/20260719102746_add_shipping_rates_table/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "ShippingRate" ( + "id" TEXT NOT NULL, + "service" TEXT NOT NULL, + "basePrice" INTEGER NOT NULL, + "pricePerKg" INTEGER NOT NULL DEFAULT 0, + "estimatedDays" INTEGER NOT NULL, + "isInternational" BOOLEAN NOT NULL DEFAULT false, + "active" BOOLEAN NOT NULL DEFAULT true, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ShippingRate_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ShippingRate_service_key" ON "ShippingRate"("service"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9b2ee1e..7528b72 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -546,3 +546,17 @@ model BankReconciliation { confidence Int @default(100) // 0-100, how confident the match is (100=exact amount+date, <100=fuzzy match) notes String? // manual notes about the match } + +// Shipping rates configuration — managed from admin panel +// Allows easy updates to Royal Mail pricing without code changes +model ShippingRate { + id String @id @default(cuid()) + service String @unique // e.g., "Royal Mail 24®", "International Signed" + basePrice Int // cents — base price for this service + pricePerKg Int @default(0) // cents — additional cost per kg (mainly for international) + estimatedDays Int // estimated delivery days + isInternational Boolean @default(false) + active Boolean @default(true) + updatedAt DateTime @updatedAt +} + diff --git a/prisma/seed.ts b/prisma/seed.ts index e71cf7b..58fed83 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -123,6 +123,44 @@ const products: Array<{ }, ]; +const shippingRates = [ + { + service: 'Special Delivery Guaranteed by 9am', + basePrice: 895, + pricePerKg: 0, + estimatedDays: 1, + isInternational: false, + }, + { + service: 'Royal Mail 24®', + basePrice: 495, + pricePerKg: 0, + estimatedDays: 1, + isInternational: false, + }, + { + service: 'Royal Mail 48®', + basePrice: 295, + pricePerKg: 0, + estimatedDays: 3, + isInternational: false, + }, + { + service: 'International Signed', + basePrice: 1495, + pricePerKg: 300, + estimatedDays: 14, + isInternational: true, + }, + { + service: 'International Economy', + basePrice: 1095, + pricePerKg: 200, + estimatedDays: 21, + isInternational: true, + }, +]; + async function main() { for (const c of categories) { await prisma.category.upsert({ @@ -153,7 +191,17 @@ async function main() { }); } - console.log(`Seeded ${categories.length} categories and ${products.length} products.`); + for (const rate of shippingRates) { + await prisma.shippingRate.upsert({ + where: { service: rate.service }, + update: { ...rate }, + create: { ...rate }, + }); + } + + console.log( + `Seeded ${categories.length} categories, ${products.length} products, and ${shippingRates.length} shipping rates.` + ); } main() diff --git a/src/app/admin/shipping-rates/actions.ts b/src/app/admin/shipping-rates/actions.ts new file mode 100644 index 0000000..3910095 --- /dev/null +++ b/src/app/admin/shipping-rates/actions.ts @@ -0,0 +1,68 @@ +'use server'; + +import { redirect } from 'next/navigation'; +import { prisma } from '@/lib/prisma'; + +export async function saveShippingRates(formData: FormData) { + const entries = formData.getAll('rates') as string[]; + + if (!entries || entries.length === 0) { + redirect('/admin/shipping-rates?error=no_rates'); + } + + try { + for (const entry of entries) { + const rate = JSON.parse(entry) as { + id?: string; + service: string; + basePrice: number; + pricePerKg: number; + estimatedDays: number; + isInternational: boolean; + }; + + if (rate.id) { + // Update existing rate + await prisma.shippingRate.update({ + where: { id: rate.id }, + data: { + service: rate.service, + basePrice: Math.round(rate.basePrice), + pricePerKg: Math.round(rate.pricePerKg), + estimatedDays: rate.estimatedDays, + isInternational: rate.isInternational, + }, + }); + } else { + // Create new rate + await prisma.shippingRate.create({ + data: { + service: rate.service, + basePrice: Math.round(rate.basePrice), + pricePerKg: Math.round(rate.pricePerKg), + estimatedDays: rate.estimatedDays, + isInternational: rate.isInternational, + }, + }); + } + } + + console.log('✓ Shipping rates updated'); + redirect('/admin/shipping-rates?success=rates_saved'); + } catch (error) { + console.error('Error saving shipping rates:', error); + redirect('/admin/shipping-rates?error=save_failed'); + } +} + +export async function deleteShippingRate(id: string) { + try { + await prisma.shippingRate.delete({ + where: { id }, + }); + redirect('/admin/shipping-rates?success=rate_deleted'); + } catch (error) { + console.error('Error deleting shipping rate:', error); + redirect('/admin/shipping-rates?error=delete_failed'); + } +} diff --git a/src/app/admin/shipping-rates/page.tsx b/src/app/admin/shipping-rates/page.tsx new file mode 100644 index 0000000..3231de8 --- /dev/null +++ b/src/app/admin/shipping-rates/page.tsx @@ -0,0 +1,34 @@ +import { prisma } from '@/lib/prisma'; +import { isAdminAuthenticated } from '@/lib/adminAuth'; +import AdminNav from '@/components/AdminNav'; +import ShippingRatesForm from '@/components/ShippingRatesForm'; +import Link from 'next/link'; + +export default async function ShippingRatesPage() { + const isAdmin = await isAdminAuthenticated(); + if (!isAdmin) { + return
Unauthorized
; + } + + const shippingRates = await prisma.shippingRate.findMany({ + orderBy: { isInternational: 'asc' }, + }); + + return ( +
+ +
+

Shipping Rates

+ + Back to dashboard + +
+ +

+ Manage Royal Mail shipping rates. Changes apply immediately to checkout. +

+ + +
+ ); +} diff --git a/src/components/AdminNav.tsx b/src/components/AdminNav.tsx index 667a7e9..e3c7191 100644 --- a/src/components/AdminNav.tsx +++ b/src/components/AdminNav.tsx @@ -39,6 +39,9 @@ export default function AdminNav() { Change password + + Shipping rates + ); } diff --git a/src/components/ShippingRatesForm.tsx b/src/components/ShippingRatesForm.tsx new file mode 100644 index 0000000..a143ee3 --- /dev/null +++ b/src/components/ShippingRatesForm.tsx @@ -0,0 +1,257 @@ +'use client'; + +import { useState } from 'react'; +import { saveShippingRates, deleteShippingRate } from '@/app/admin/shipping-rates/actions'; + +interface ShippingRateType { + id: string; + service: string; + basePrice: number; + pricePerKg: number; + estimatedDays: number; + isInternational: boolean; +} + +interface Props { + existingRates: ShippingRateType[]; +} + +export default function ShippingRatesForm({ existingRates }: Props) { + const [rates, setRates] = useState(existingRates); + const [newRate, setNewRate] = useState({ + service: '', + basePrice: 0, + pricePerKg: 0, + estimatedDays: 1, + isInternational: false, + }); + + const handleAddRate = () => { + if (!newRate.service.trim()) { + alert('Please enter a service name'); + return; + } + + const rate: ShippingRateType = { + id: `new-${Date.now()}`, + ...newRate, + }; + + setRates([...rates, rate]); + setNewRate({ + service: '', + basePrice: 0, + pricePerKg: 0, + estimatedDays: 1, + isInternational: false, + }); + }; + + const handleUpdateRate = (id: string, field: string, value: string | number | boolean) => { + setRates( + rates.map((rate) => + rate.id === id ? { ...rate, [field]: value } : rate + ) + ); + }; + + const handleRemoveRate = (id: string) => { + if (id.startsWith('new-')) { + setRates(rates.filter((r) => r.id !== id)); + } else { + // Delete from database + deleteShippingRate(id); + } + }; + + return ( +
+
+

Current Rates

+ + {rates.length === 0 ? ( +

No shipping rates configured yet.

+ ) : ( +
+ + + + + + + + + + + + + {rates.map((rate) => ( + + + + + + + + + ))} + +
ServiceBase PricePrice/kgEst. DaysTypeAction
+ handleUpdateRate(rate.id, 'service', e.target.value)} + className="w-full border border-line bg-paper px-2 py-1 text-sm" + /> + + +
+ £ + + handleUpdateRate( + rate.id, + 'basePrice', + Math.round(parseFloat(e.target.value) * 100) + ) + } + className="w-24 border border-line bg-paper px-2 py-1 text-sm" + /> +
+
+
+ £ + + handleUpdateRate( + rate.id, + 'pricePerKg', + Math.round(parseFloat(e.target.value) * 100) + ) + } + className="w-24 border border-line bg-paper px-2 py-1 text-sm" + /> +
+
+ handleUpdateRate(rate.id, 'estimatedDays', parseInt(e.target.value))} + className="w-20 border border-line bg-paper px-2 py-1 text-sm" + /> + + + + +
+
+ )} + +
+

Add New Rate

+
+
+ + setNewRate({ ...newRate, service: e.target.value })} + placeholder="e.g. Special Delivery" + className="w-full border border-line bg-paper px-2 py-1 text-sm" + /> +
+
+ + + setNewRate({ ...newRate, basePrice: Math.round(parseFloat(e.target.value) * 100) }) + } + className="w-full border border-line bg-paper px-2 py-1 text-sm" + /> +
+
+ + + setNewRate({ ...newRate, pricePerKg: Math.round(parseFloat(e.target.value) * 100) }) + } + className="w-full border border-line bg-paper px-2 py-1 text-sm" + /> +
+
+ + setNewRate({ ...newRate, estimatedDays: parseInt(e.target.value) })} + className="w-full border border-line bg-paper px-2 py-1 text-sm" + /> +
+
+ + +
+
+ +
+ +
+ +
+
+
+ ); +} diff --git a/src/lib/royalmail-shipping.ts b/src/lib/royalmail-shipping.ts index 4c59e57..c503910 100644 --- a/src/lib/royalmail-shipping.ts +++ b/src/lib/royalmail-shipping.ts @@ -11,24 +11,18 @@ export interface ShippingQuote { // 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 +// Default fallback rates if no database rates are configured +// These are standard Royal Mail pricing estimates +const DEFAULT_RATES = { + DOMESTIC: [ + { service: 'Special Delivery Guaranteed by 9am', basePrice: 895, perKg: 0, estimatedDays: 1, isInternational: false }, + { service: 'Royal Mail 24®', basePrice: 495, perKg: 0, estimatedDays: 1, isInternational: false }, + { service: 'Royal Mail 48®', basePrice: 295, perKg: 0, estimatedDays: 3, isInternational: false }, + ], + INTERNATIONAL: [ + { service: 'International Signed', basePrice: 1495, perKg: 300, estimatedDays: 14, isInternational: true }, + { service: 'International Economy', basePrice: 1095, perKg: 200, estimatedDays: 21, isInternational: true }, + ], }; /** @@ -44,10 +38,31 @@ export async function getRoyalMailShippingQuotes( const weightKg = weightGrams / 1000; const isInternational = !UK_COUNTRIES.includes(destinationCountry.toUpperCase()); - // Use calculated estimated rates based on Royal Mail's standard pricing - // Note: Royal Mail doesn't provide a direct real-time pricing API - // Instead, they expect you to build your own rate matrix using their OBA rates - console.log('📦 Using Royal Mail estimated rates (based on standard pricing)'); + try { + // Try to fetch rates from database first + const { prisma } = await import('@/lib/prisma'); + const dbRates = await prisma.shippingRate.findMany({ + where: { + active: true, + isInternational, + }, + }); + + if (dbRates.length > 0) { + console.log('📦 Using shipping rates from database'); + return dbRates.map((rate) => ({ + service: rate.service, + price: Math.ceil(rate.basePrice + rate.pricePerKg * Math.ceil(weightKg)), + estimatedDays: rate.estimatedDays, + isInternational: rate.isInternational, + })); + } + } catch (error) { + console.warn('Could not fetch database rates, using defaults:', error); + } + + // Fallback to default rates + console.log('📦 Using default Royal Mail rates'); return calculateEstimatedRates(weightKg, isInternational, destinationCountry); } @@ -56,56 +71,14 @@ function calculateEstimatedRates( isInternational: boolean, destinationCountry: string ): ShippingQuote[] { - const quotes: ShippingQuote[] = []; + const rates = isInternational ? DEFAULT_RATES.INTERNATIONAL : DEFAULT_RATES.DOMESTIC; - 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; + return rates.map((rate) => ({ + service: rate.service, + price: Math.ceil(rate.basePrice + rate.perKg * Math.ceil(weightKg)), + estimatedDays: rate.estimatedDays, + isInternational: rate.isInternational, + })); }