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