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 (
+
+ );
+}
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,
+ }));
}