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:
co-authored by
Claude Haiku 4.5
parent
617a5acbb2
commit
f770428556
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user