From c476f92a661b1e6a831029a1cd9a04447dec0eab Mon Sep 17 00:00:00 2001 From: Andymick Date: Sat, 18 Jul 2026 20:36:40 +0100 Subject: [PATCH] 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). --- .claude/settings.local.json | 3 +- src/app/admin/orders/[id]/page.tsx | 29 ++++- .../api/orders/[id]/sync-tracking/route.ts | 54 ++++++++ src/components/SyncRoyalMailButton.tsx | 71 +++++++++++ src/lib/royalmail.ts | 115 ++++++++++++++++++ 5 files changed, 265 insertions(+), 7 deletions(-) create mode 100644 src/app/api/orders/[id]/sync-tracking/route.ts create mode 100644 src/components/SyncRoyalMailButton.tsx create mode 100644 src/lib/royalmail.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d10072e..dd18139 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -76,7 +76,8 @@ "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 '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\\) *)" ] } } diff --git a/src/app/admin/orders/[id]/page.tsx b/src/app/admin/orders/[id]/page.tsx index cb1f20c..d0ec630 100644 --- a/src/app/admin/orders/[id]/page.tsx +++ b/src/app/admin/orders/[id]/page.tsx @@ -1,6 +1,7 @@ import { notFound } from 'next/navigation'; import { prisma } from '@/lib/prisma'; import AdminNav from '@/components/AdminNav'; +import SyncRoyalMailButton from '@/components/SyncRoyalMailButton'; import { archiveOrder, unarchiveOrder, updateOrderTracking } from '../actions'; function formatPrice(cents: number) { @@ -133,13 +134,29 @@ export default async function OrderDetailPage({ params }: { params: { id: string - +
+ +
+ + {order.trackingNumber && ( +
+

Royal Mail Integration

+ { + // In a real app, we'd refresh the page or update state + console.log('Tracking synced successfully'); + }} + /> +
+ )}
diff --git a/src/app/api/orders/[id]/sync-tracking/route.ts b/src/app/api/orders/[id]/sync-tracking/route.ts new file mode 100644 index 0000000..26579aa --- /dev/null +++ b/src/app/api/orders/[id]/sync-tracking/route.ts @@ -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 } + ); + } +} diff --git a/src/components/SyncRoyalMailButton.tsx b/src/components/SyncRoyalMailButton.tsx new file mode 100644 index 0000000..d896a3c --- /dev/null +++ b/src/components/SyncRoyalMailButton.tsx @@ -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 ( +
+ + + {message && ( +

+ {message.text} +

+ )} +
+ ); +} diff --git a/src/lib/royalmail.ts b/src/lib/royalmail.ts new file mode 100644 index 0000000..533eced --- /dev/null +++ b/src/lib/royalmail.ts @@ -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 { + 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'; + } +}