Feature: Add database-driven shipping rates management

- Add ShippingRate model to Prisma schema with service name, base price, per-kg surcharge, estimated delivery days, and international flag
- Create migration to add shipping_rates table
- Update seed.ts to populate initial Royal Mail rates (domestic and international)
- Refactor getRoyalMailShippingQuotes to fetch rates from database with fallback to defaults
- Add /admin/shipping-rates page for managing rates
- Add ShippingRatesForm component with inline editing and add/remove functionality
- Add server actions for saving and deleting rates
- Add "Shipping rates" link to admin navigation

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-19 11:30:31 +01:00
co-authored by Claude Haiku 4.5
parent 617a5acbb2
commit f770428556
8 changed files with 485 additions and 72 deletions
+68
View File
@@ -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');
}
}
+34
View File
@@ -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 <div className="px-6 py-12 text-center">Unauthorized</div>;
}
const shippingRates = await prisma.shippingRate.findMany({
orderBy: { isInternational: 'asc' },
});
return (
<div className="mx-auto max-w-4xl px-6 py-12">
<AdminNav />
<div className="flex items-baseline justify-between gap-3">
<h1 className="font-display text-3xl">Shipping Rates</h1>
<Link href="/admin/dashboard" className="text-sm text-clay hover:text-clay-dark">
Back to dashboard
</Link>
</div>
<p className="mt-2 text-sm text-muted">
Manage Royal Mail shipping rates. Changes apply immediately to checkout.
</p>
<ShippingRatesForm existingRates={shippingRates} />
</div>
);
}
+3
View File
@@ -39,6 +39,9 @@ export default function AdminNav() {
<Link href="/admin/change-password" className="tag-label hover:text-ink">
Change password
</Link>
<Link href="/admin/shipping-rates" className="tag-label hover:text-ink">
Shipping rates
</Link>
</div>
);
}
+257
View File
@@ -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<ShippingRateType[]>(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 (
<form action={saveShippingRates} className="mt-8">
<div className="border border-line rounded bg-surface p-6">
<h2 className="font-display text-xl mb-4">Current Rates</h2>
{rates.length === 0 ? (
<p className="text-sm text-muted py-4">No shipping rates configured yet.</p>
) : (
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line">
<th className="text-left py-3 px-3 font-medium">Service</th>
<th className="text-left py-3 px-3 font-medium">Base Price</th>
<th className="text-left py-3 px-3 font-medium">Price/kg</th>
<th className="text-left py-3 px-3 font-medium">Est. Days</th>
<th className="text-left py-3 px-3 font-medium">Type</th>
<th className="text-left py-3 px-3 font-medium">Action</th>
</tr>
</thead>
<tbody>
{rates.map((rate) => (
<tr key={rate.id} className="border-b border-line hover:bg-surface-1">
<td className="py-3 px-3">
<input
type="text"
value={rate.service}
onChange={(e) => handleUpdateRate(rate.id, 'service', e.target.value)}
className="w-full border border-line bg-paper px-2 py-1 text-sm"
/>
<input
type="hidden"
name="rates"
value={JSON.stringify(rate)}
/>
</td>
<td className="py-3 px-3">
<div className="flex items-center gap-1">
<span>£</span>
<input
type="number"
step="0.01"
value={(rate.basePrice / 100).toFixed(2)}
onChange={(e) =>
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"
/>
</div>
</td>
<td className="py-3 px-3">
<div className="flex items-center gap-1">
<span>£</span>
<input
type="number"
step="0.01"
value={(rate.pricePerKg / 100).toFixed(2)}
onChange={(e) =>
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"
/>
</div>
</td>
<td className="py-3 px-3">
<input
type="number"
min="1"
value={rate.estimatedDays}
onChange={(e) => handleUpdateRate(rate.id, 'estimatedDays', parseInt(e.target.value))}
className="w-20 border border-line bg-paper px-2 py-1 text-sm"
/>
</td>
<td className="py-3 px-3">
<select
value={rate.isInternational ? 'international' : 'domestic'}
onChange={(e) =>
handleUpdateRate(rate.id, 'isInternational', e.target.value === 'international')
}
className="border border-line bg-paper px-2 py-1 text-sm"
>
<option value="domestic">Domestic</option>
<option value="international">International</option>
</select>
</td>
<td className="py-3 px-3">
<button
type="button"
onClick={() => handleRemoveRate(rate.id)}
className="text-xs text-splash-pink hover:text-splash-pink-dark"
>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="border-t border-line pt-6 mt-6">
<h3 className="font-medium mb-4">Add New Rate</h3>
<div className="grid gap-4 sm:grid-cols-5">
<div>
<label className="tag-label mb-1 block text-xs">Service Name</label>
<input
type="text"
value={newRate.service}
onChange={(e) => 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"
/>
</div>
<div>
<label className="tag-label mb-1 block text-xs">Base Price (£)</label>
<input
type="number"
step="0.01"
value={(newRate.basePrice / 100).toFixed(2)}
onChange={(e) =>
setNewRate({ ...newRate, basePrice: Math.round(parseFloat(e.target.value) * 100) })
}
className="w-full border border-line bg-paper px-2 py-1 text-sm"
/>
</div>
<div>
<label className="tag-label mb-1 block text-xs">Price/kg (£)</label>
<input
type="number"
step="0.01"
value={(newRate.pricePerKg / 100).toFixed(2)}
onChange={(e) =>
setNewRate({ ...newRate, pricePerKg: Math.round(parseFloat(e.target.value) * 100) })
}
className="w-full border border-line bg-paper px-2 py-1 text-sm"
/>
</div>
<div>
<label className="tag-label mb-1 block text-xs">Days</label>
<input
type="number"
min="1"
value={newRate.estimatedDays}
onChange={(e) => setNewRate({ ...newRate, estimatedDays: parseInt(e.target.value) })}
className="w-full border border-line bg-paper px-2 py-1 text-sm"
/>
</div>
<div>
<label className="tag-label mb-1 block text-xs">Type</label>
<select
value={newRate.isInternational ? 'international' : 'domestic'}
onChange={(e) =>
setNewRate({ ...newRate, isInternational: e.target.value === 'international' })
}
className="w-full border border-line bg-paper px-2 py-1 text-sm"
>
<option value="domestic">Domestic</option>
<option value="international">International</option>
</select>
</div>
</div>
<button
type="button"
onClick={handleAddRate}
className="mt-3 text-sm text-clay hover:text-clay-dark font-medium"
>
+ Add Rate
</button>
</div>
<div className="mt-6 flex gap-3">
<button
type="submit"
className="bg-clay px-6 py-2 text-sm font-medium text-paper rounded hover:bg-clay-dark"
>
Save All Changes
</button>
</div>
</div>
</form>
);
}
+44 -71
View File
@@ -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,
}));
}