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