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,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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user