- 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>
258 lines
9.4 KiB
TypeScript
258 lines
9.4 KiB
TypeScript
'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>
|
|
);
|
|
}
|