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