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:
@@ -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\\) *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay-dark"
|
||||
>
|
||||
Update Tracking Info
|
||||
</button>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay-dark"
|
||||
>
|
||||
Update Tracking Info
|
||||
</button>
|
||||
</div>
|
||||
</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 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user