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
@@ -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");
+14
View File
@@ -546,3 +546,17 @@ model BankReconciliation {
confidence Int @default(100) // 0-100, how confident the match is (100=exact amount+date, <100=fuzzy match) 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 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
}
+49 -1
View File
@@ -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() { async function main() {
for (const c of categories) { for (const c of categories) {
await prisma.category.upsert({ 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() main()
+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"> <Link href="/admin/change-password" className="tag-label hover:text-ink">
Change password Change password
</Link> </Link>
<Link href="/admin/shipping-rates" className="tag-label hover:text-ink">
Shipping rates
</Link>
</div> </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 // UK postal areas
const UK_COUNTRIES = ['GB', 'UK']; const UK_COUNTRIES = ['GB', 'UK'];
// Royal Mail standard services - these are approximate rates, will be updated via API // Default fallback rates if no database rates are configured
// when Royal Mail credentials are available // These are standard Royal Mail pricing estimates
const DOMESTIC_RATES = { const DEFAULT_RATES = {
// Special Delivery Guaranteed by 9am next working day DOMESTIC: [
special_next_day: { basePrice: 895, perKg: 0 }, // £8.95 flat { service: 'Special Delivery Guaranteed by 9am', basePrice: 895, perKg: 0, estimatedDays: 1, isInternational: false },
// Special Delivery Guaranteed by 9am Saturday { service: 'Royal Mail 24®', basePrice: 495, perKg: 0, estimatedDays: 1, isInternational: false },
special_saturday: { basePrice: 895, perKg: 0 }, // £8.95 flat { service: 'Royal Mail 48®', basePrice: 295, perKg: 0, estimatedDays: 3, isInternational: false },
// Royal Mail 24® - Next working day ],
royal_24: { basePrice: 495, perKg: 0 }, // £4.95 flat INTERNATIONAL: [
// Royal Mail 48® - 2-3 working days { service: 'International Signed', basePrice: 1495, perKg: 300, estimatedDays: 14, isInternational: true },
royal_48: { basePrice: 295, perKg: 0 }, // £2.95 flat { service: 'International Economy', basePrice: 1095, perKg: 200, estimatedDays: 21, isInternational: true },
}; ],
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
}; };
/** /**
@@ -44,10 +38,31 @@ export async function getRoyalMailShippingQuotes(
const weightKg = weightGrams / 1000; const weightKg = weightGrams / 1000;
const isInternational = !UK_COUNTRIES.includes(destinationCountry.toUpperCase()); const isInternational = !UK_COUNTRIES.includes(destinationCountry.toUpperCase());
// Use calculated estimated rates based on Royal Mail's standard pricing try {
// Note: Royal Mail doesn't provide a direct real-time pricing API // Try to fetch rates from database first
// Instead, they expect you to build your own rate matrix using their OBA rates const { prisma } = await import('@/lib/prisma');
console.log('📦 Using Royal Mail estimated rates (based on standard pricing)'); 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); return calculateEstimatedRates(weightKg, isInternational, destinationCountry);
} }
@@ -56,56 +71,14 @@ function calculateEstimatedRates(
isInternational: boolean, isInternational: boolean,
destinationCountry: string destinationCountry: string
): ShippingQuote[] { ): ShippingQuote[] {
const quotes: ShippingQuote[] = []; const rates = isInternational ? DEFAULT_RATES.INTERNATIONAL : DEFAULT_RATES.DOMESTIC;
if (isInternational) { return rates.map((rate) => ({
// International options service: rate.service,
quotes.push({ price: Math.ceil(rate.basePrice + rate.perKg * Math.ceil(weightKg)),
service: 'International Signed', estimatedDays: rate.estimatedDays,
price: Math.ceil( isInternational: rate.isInternational,
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;
} }