Feature: Royal Mail tracking integration (hybrid approach)

Add ability to sync shipment tracking data from Royal Mail API:

- Create Royal Mail API service (src/lib/royalmail.ts) to fetch tracking status
- Add sync-tracking endpoint to pull latest status from Royal Mail
- Create SyncRoyalMailButton component for admin panel
- Add 'Sync from Royal Mail' button on order detail page
- Auto-update order status based on Royal Mail tracking status

Admin workflow:
1. Create shipping label in Royal Mail Click & Drop
2. Copy tracking number into tracking form
3. Click 'Sync from Royal Mail' to fetch and update status
4. System maps Royal Mail status to our order status (SHIPPED, DELIVERED, etc)

Uses Royal Mail's public tracking API - no special credentials required.
Supports manual label creation workflow (hybrid approach).
This commit is contained in:
Andymick
2026-07-18 20:36:40 +01:00
parent 440a735b8a
commit c476f92a66
5 changed files with 265 additions and 7 deletions
+2 -1
View File
@@ -76,7 +76,8 @@
"Bash(git commit -m 'Fix: Ensure cart is completely cleared after payment success *)", "Bash(git commit -m 'Fix: Ensure cart is completely cleared after payment success *)",
"Bash(git commit -m 'Fix: Update order status to PAID on checkout success page *)", "Bash(git commit -m 'Fix: Update order status to PAID on checkout success page *)",
"Bash(git commit -m 'Improve: Cart clearing on success page with better state management *)", "Bash(git commit -m 'Improve: Cart clearing on success page with better state management *)",
"Bash(git commit -m 'Feature: Add order tracking and shipping status updates *)" "Bash(git commit -m 'Feature: Add order tracking and shipping status updates *)",
"Bash(git commit -m 'Feature: Royal Mail tracking integration \\(hybrid approach\\) *)"
] ]
} }
} }
+17
View File
@@ -1,6 +1,7 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav'; import AdminNav from '@/components/AdminNav';
import SyncRoyalMailButton from '@/components/SyncRoyalMailButton';
import { archiveOrder, unarchiveOrder, updateOrderTracking } from '../actions'; import { archiveOrder, unarchiveOrder, updateOrderTracking } from '../actions';
function formatPrice(cents: number) { function formatPrice(cents: number) {
@@ -133,13 +134,29 @@ export default async function OrderDetailPage({ params }: { params: { id: string
</div> </div>
</div> </div>
<div className="flex gap-3">
<button <button
type="submit" type="submit"
className="bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay-dark" className="bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay-dark"
> >
Update Tracking Info Update Tracking Info
</button> </button>
</div>
</form> </form>
{order.trackingNumber && (
<div className="mt-4 border-t border-line pt-4">
<p className="tag-label mb-3">Royal Mail Integration</p>
<SyncRoyalMailButton
orderId={order.id}
trackingNumber={order.trackingNumber}
onSuccess={() => {
// In a real app, we'd refresh the page or update state
console.log('Tracking synced successfully');
}}
/>
</div>
)}
</div> </div>
<div className="mt-10 divide-y divide-line border-t border-line"> <div className="mt-10 divide-y divide-line border-t border-line">
@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { fetchRoyalMailTracking, mapTrackingStatusToOrderStatus } from '@/lib/royalmail';
export async function POST(
req: Request,
{ params }: { params: { id: string } }
) {
try {
const order = await prisma.order.findUnique({
where: { id: params.id },
});
if (!order) {
return NextResponse.json({ error: 'Order not found' }, { status: 404 });
}
if (!order.trackingNumber) {
return NextResponse.json({ error: 'No tracking number set for this order' }, { status: 400 });
}
// Fetch tracking data from Royal Mail
const trackingData = await fetchRoyalMailTracking(order.trackingNumber);
if (!trackingData) {
return NextResponse.json({ error: 'Could not fetch tracking data from Royal Mail' }, { status: 400 });
}
// Map Royal Mail status to our order status
const newStatus = mapTrackingStatusToOrderStatus(trackingData.status);
// Update order with new status
const updated = await prisma.order.update({
where: { id: params.id },
data: {
status: newStatus,
},
});
console.log(`✓ Order ${params.id} tracking synced: ${trackingData.status}${newStatus}`);
return NextResponse.json({
success: true,
trackingData,
orderStatus: newStatus,
});
} catch (err) {
console.error('Error syncing tracking:', err);
return NextResponse.json(
{ error: 'Failed to sync tracking data' },
{ status: 500 }
);
}
}
+71
View File
@@ -0,0 +1,71 @@
'use client';
import { useState } from 'react';
interface SyncRoyalMailButtonProps {
orderId: string;
trackingNumber?: string | null;
onSuccess?: () => void;
}
export default function SyncRoyalMailButton({ orderId, trackingNumber, onSuccess }: SyncRoyalMailButtonProps) {
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const handleSync = async () => {
if (!trackingNumber) {
setMessage({ type: 'error', text: 'Please enter a tracking number first' });
return;
}
setLoading(true);
setMessage(null);
try {
const res = await fetch(`/api/orders/${orderId}/sync-tracking`, {
method: 'POST',
});
const data = await res.json();
if (!res.ok) {
setMessage({ type: 'error', text: data.error || 'Failed to sync tracking' });
return;
}
setMessage({
type: 'success',
text: `Tracking synced! Status: ${data.trackingData.status}`,
});
onSuccess?.();
} catch (err) {
setMessage({ type: 'error', text: 'Error syncing with Royal Mail' });
} finally {
setLoading(false);
}
};
return (
<div className="space-y-2">
<button
type="button"
onClick={handleSync}
disabled={loading || !trackingNumber}
className="bg-splash-blue px-4 py-2 text-sm font-medium text-paper hover:bg-splash-blue/90 disabled:opacity-60"
>
{loading ? 'Syncing...' : '🔄 Sync from Royal Mail'}
</button>
{message && (
<p
className={`text-sm ${
message.type === 'success' ? 'text-splash-blue' : 'text-splash-pink'
}`}
>
{message.text}
</p>
)}
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
// Royal Mail Parcel Tracking API integration
// Uses Royal Mail's public tracking API to get shipment status
export type RoyalMailTrackingStatus =
| 'Pending'
| 'Collected'
| 'In Transit'
| 'Out for Delivery'
| 'Delivered'
| 'Undeliverable'
| 'Unknown';
export interface RoyalMailTrackingEvent {
status: RoyalMailTrackingStatus;
timestamp: Date;
location?: string;
detail?: string;
}
export interface RoyalMailTrackingData {
trackingNumber: string;
status: RoyalMailTrackingStatus;
events: RoyalMailTrackingEvent[];
lastUpdate: Date;
}
/**
* Fetch tracking data from Royal Mail API
* Uses the public Royal Mail tracking service (no authentication required for basic tracking)
*/
export async function fetchRoyalMailTracking(trackingNumber: string): Promise<RoyalMailTrackingData | null> {
try {
// Royal Mail tracking API endpoint
const url = `https://www3.royalmail.com/api/v1/track-and-trace?mailPieces=${trackingNumber}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
});
if (!response.ok) {
console.error('Royal Mail API error:', response.status, response.statusText);
return null;
}
const data = await response.json();
// Parse Royal Mail's response format
if (!data.mailPieces || !Array.isArray(data.mailPieces) || data.mailPieces.length === 0) {
console.warn('No tracking data found for:', trackingNumber);
return null;
}
const mailPiece = data.mailPieces[0];
// Extract status and events
const status: RoyalMailTrackingStatus = parseRoyalMailStatus(mailPiece.status);
const events: RoyalMailTrackingEvent[] = (mailPiece.events || []).map((event: any) => ({
status: parseRoyalMailStatus(event.status),
timestamp: new Date(event.datetime),
location: event.location || event.name,
detail: event.detail,
}));
return {
trackingNumber: mailPiece.mailPieceId,
status,
events: events.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()),
lastUpdate: new Date(),
};
} catch (error) {
console.error('Error fetching Royal Mail tracking:', error);
return null;
}
}
/**
* Map Royal Mail status strings to our internal status format
*/
function parseRoyalMailStatus(royalMailStatus: string): RoyalMailTrackingStatus {
const status = String(royalMailStatus).toLowerCase();
if (status.includes('delivered')) return 'Delivered';
if (status.includes('out for delivery')) return 'Out for Delivery';
if (status.includes('in transit')) return 'In Transit';
if (status.includes('collected')) return 'Collected';
if (status.includes('pending')) return 'Pending';
if (status.includes('undeliverable')) return 'Undeliverable';
return 'Unknown';
}
/**
* Convert Royal Mail tracking status to our order status
*/
export function mapTrackingStatusToOrderStatus(trackingStatus: RoyalMailTrackingStatus): string {
switch (trackingStatus) {
case 'Delivered':
return 'DELIVERED';
case 'Out for Delivery':
return 'SHIPPED';
case 'In Transit':
return 'SHIPPED';
case 'Collected':
return 'PRINTED';
case 'Pending':
return 'PRINTED';
case 'Undeliverable':
return 'SHIPPED'; // Keep as shipped, admin needs to handle
default:
return 'SHIPPED';
}
}