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,16 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ShippingRate" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"service" TEXT NOT NULL,
|
||||||
|
"basePrice" INTEGER NOT NULL,
|
||||||
|
"pricePerKg" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"estimatedDays" INTEGER NOT NULL,
|
||||||
|
"isInternational" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ShippingRate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ShippingRate_service_key" ON "ShippingRate"("service");
|
||||||
@@ -546,3 +546,17 @@ model BankReconciliation {
|
|||||||
confidence Int @default(100) // 0-100, how confident the match is (100=exact amount+date, <100=fuzzy match)
|
confidence Int @default(100) // 0-100, how confident the match is (100=exact amount+date, <100=fuzzy match)
|
||||||
notes String? // manual notes about the match
|
notes String? // manual notes about the match
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shipping rates configuration — managed from admin panel
|
||||||
|
// Allows easy updates to Royal Mail pricing without code changes
|
||||||
|
model ShippingRate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
service String @unique // e.g., "Royal Mail 24®", "International Signed"
|
||||||
|
basePrice Int // cents — base price for this service
|
||||||
|
pricePerKg Int @default(0) // cents — additional cost per kg (mainly for international)
|
||||||
|
estimatedDays Int // estimated delivery days
|
||||||
|
isInternational Boolean @default(false)
|
||||||
|
active Boolean @default(true)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+49
-1
@@ -123,6 +123,44 @@ const products: Array<{
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const shippingRates = [
|
||||||
|
{
|
||||||
|
service: 'Special Delivery Guaranteed by 9am',
|
||||||
|
basePrice: 895,
|
||||||
|
pricePerKg: 0,
|
||||||
|
estimatedDays: 1,
|
||||||
|
isInternational: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
service: 'Royal Mail 24®',
|
||||||
|
basePrice: 495,
|
||||||
|
pricePerKg: 0,
|
||||||
|
estimatedDays: 1,
|
||||||
|
isInternational: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
service: 'Royal Mail 48®',
|
||||||
|
basePrice: 295,
|
||||||
|
pricePerKg: 0,
|
||||||
|
estimatedDays: 3,
|
||||||
|
isInternational: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
service: 'International Signed',
|
||||||
|
basePrice: 1495,
|
||||||
|
pricePerKg: 300,
|
||||||
|
estimatedDays: 14,
|
||||||
|
isInternational: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
service: 'International Economy',
|
||||||
|
basePrice: 1095,
|
||||||
|
pricePerKg: 200,
|
||||||
|
estimatedDays: 21,
|
||||||
|
isInternational: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
for (const c of categories) {
|
for (const c of categories) {
|
||||||
await prisma.category.upsert({
|
await prisma.category.upsert({
|
||||||
@@ -153,7 +191,17 @@ async function main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Seeded ${categories.length} categories and ${products.length} products.`);
|
for (const rate of shippingRates) {
|
||||||
|
await prisma.shippingRate.upsert({
|
||||||
|
where: { service: rate.service },
|
||||||
|
update: { ...rate },
|
||||||
|
create: { ...rate },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Seeded ${categories.length} categories, ${products.length} products, and ${shippingRates.length} shipping rates.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -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">
|
<Link href="/admin/change-password" className="tag-label hover:text-ink">
|
||||||
Change password
|
Change password
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/admin/shipping-rates" className="tag-label hover:text-ink">
|
||||||
|
Shipping rates
|
||||||
|
</Link>
|
||||||
</div>
|
</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
|
// UK postal areas
|
||||||
const UK_COUNTRIES = ['GB', 'UK'];
|
const UK_COUNTRIES = ['GB', 'UK'];
|
||||||
|
|
||||||
// Royal Mail standard services - these are approximate rates, will be updated via API
|
// Default fallback rates if no database rates are configured
|
||||||
// when Royal Mail credentials are available
|
// These are standard Royal Mail pricing estimates
|
||||||
const DOMESTIC_RATES = {
|
const DEFAULT_RATES = {
|
||||||
// Special Delivery Guaranteed by 9am next working day
|
DOMESTIC: [
|
||||||
special_next_day: { basePrice: 895, perKg: 0 }, // £8.95 flat
|
{ service: 'Special Delivery Guaranteed by 9am', basePrice: 895, perKg: 0, estimatedDays: 1, isInternational: false },
|
||||||
// Special Delivery Guaranteed by 9am Saturday
|
{ service: 'Royal Mail 24®', basePrice: 495, perKg: 0, estimatedDays: 1, isInternational: false },
|
||||||
special_saturday: { basePrice: 895, perKg: 0 }, // £8.95 flat
|
{ service: 'Royal Mail 48®', basePrice: 295, perKg: 0, estimatedDays: 3, isInternational: false },
|
||||||
// Royal Mail 24® - Next working day
|
],
|
||||||
royal_24: { basePrice: 495, perKg: 0 }, // £4.95 flat
|
INTERNATIONAL: [
|
||||||
// Royal Mail 48® - 2-3 working days
|
{ service: 'International Signed', basePrice: 1495, perKg: 300, estimatedDays: 14, isInternational: true },
|
||||||
royal_48: { basePrice: 295, perKg: 0 }, // £2.95 flat
|
{ service: 'International Economy', basePrice: 1095, perKg: 200, estimatedDays: 21, isInternational: true },
|
||||||
};
|
],
|
||||||
|
|
||||||
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
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,10 +38,31 @@ export async function getRoyalMailShippingQuotes(
|
|||||||
const weightKg = weightGrams / 1000;
|
const weightKg = weightGrams / 1000;
|
||||||
const isInternational = !UK_COUNTRIES.includes(destinationCountry.toUpperCase());
|
const isInternational = !UK_COUNTRIES.includes(destinationCountry.toUpperCase());
|
||||||
|
|
||||||
// Use calculated estimated rates based on Royal Mail's standard pricing
|
try {
|
||||||
// Note: Royal Mail doesn't provide a direct real-time pricing API
|
// Try to fetch rates from database first
|
||||||
// Instead, they expect you to build your own rate matrix using their OBA rates
|
const { prisma } = await import('@/lib/prisma');
|
||||||
console.log('📦 Using Royal Mail estimated rates (based on standard pricing)');
|
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);
|
return calculateEstimatedRates(weightKg, isInternational, destinationCountry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,56 +71,14 @@ function calculateEstimatedRates(
|
|||||||
isInternational: boolean,
|
isInternational: boolean,
|
||||||
destinationCountry: string
|
destinationCountry: string
|
||||||
): ShippingQuote[] {
|
): ShippingQuote[] {
|
||||||
const quotes: ShippingQuote[] = [];
|
const rates = isInternational ? DEFAULT_RATES.INTERNATIONAL : DEFAULT_RATES.DOMESTIC;
|
||||||
|
|
||||||
if (isInternational) {
|
return rates.map((rate) => ({
|
||||||
// International options
|
service: rate.service,
|
||||||
quotes.push({
|
price: Math.ceil(rate.basePrice + rate.perKg * Math.ceil(weightKg)),
|
||||||
service: 'International Signed',
|
estimatedDays: rate.estimatedDays,
|
||||||
price: Math.ceil(
|
isInternational: rate.isInternational,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user