diff --git a/components/logistics/QrGenerator.tsx b/components/logistics/QrGenerator.tsx
new file mode 100644
index 0000000..e423609
--- /dev/null
+++ b/components/logistics/QrGenerator.tsx
@@ -0,0 +1,233 @@
+'use client';
+
+/**
+ * QrGenerator — Component layer for offline-ready QR code generation.
+ *
+ * Renders the handoff QR code using `qrcode.react`.
+ * Shows a "Cached" badge when the payload originates from local IndexedDB
+ * rather than a live API response, so the driver knows the data may be stale.
+ *
+ * Component → Hook → Service pattern:
+ * QrGenerator → useOfflineQr → { shipmentHandoffService, qrCacheService }
+ */
+
+import React from 'react';
+import { QRCodeCanvas } from 'qrcode.react';
+import { useOfflineQr } from '@/hooks/useOfflineQr';
+import { ScanConfirmation } from '@/services/qrCacheService';
+
+// ─── Props ────────────────────────────────────────────────────────────────────
+
+export interface QrGeneratorProps {
+ deliveryId: string;
+ /** Pixel dimensions of the rendered QR code (default 256). */
+ size?: number;
+ /** Show delivery ID / expiry metadata below the QR (default true). */
+ includeLabel?: boolean;
+ /** Called after the user confirms a successful scan. */
+ onScanConfirmed?: (confirmation: ScanConfirmation) => void;
+ className?: string;
+}
+
+// ─── Sub-components ───────────────────────────────────────────────────────────
+
+function CachedBadge() {
+ return (
+
+
+ Cached
+
+ );
+}
+
+function LiveBadge() {
+ return (
+
+
+ Live
+
+ );
+}
+
+function SyncBadge({ pending }: { pending: number }) {
+ return (
+
+
+ Syncing {pending}
+
+ );
+}
+
+// ─── Main component ───────────────────────────────────────────────────────────
+
+export function QrGenerator({
+ deliveryId,
+ size = 256,
+ includeLabel = true,
+ onScanConfirmed,
+ className = '',
+}: QrGeneratorProps) {
+ const {
+ payload,
+ source,
+ isLoading,
+ error,
+ isSyncing,
+ pendingCount,
+ queueScan,
+ refresh,
+ } = useOfflineQr(deliveryId);
+
+ // ── Loading ──────────────────────────────────────────────────────────────
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ // ── Error (no live + no cache) ───────────────────────────────────────────
+
+ if (error) {
+ return (
+
+
+
{error}
+
+
+ );
+ }
+
+ // ── No payload (shouldn't normally reach here) ───────────────────────────
+
+ if (!payload) return null;
+
+ // ── Success ──────────────────────────────────────────────────────────────
+
+ const handleConfirmScan = async () => {
+ const confirmation: ScanConfirmation = {
+ deliveryId: payload.deliveryId,
+ token: payload.token,
+ scannedAt: new Date().toISOString(),
+ };
+ await queueScan(confirmation);
+ onScanConfirmed?.(confirmation);
+ };
+
+ const msLeft = new Date(payload.expiresAt).getTime() - Date.now();
+ const minutesLeft = Math.max(0, Math.floor(msLeft / 60_000));
+
+ return (
+
+ {/* ── Source + sync badges ── */}
+
+ {source === 'cached' ? : }
+ {isSyncing && }
+
+
+ {/* ── QR code ── */}
+
+
+
+
+ {/* ── Label ── */}
+ {includeLabel && (
+
+
Package Handoff QR
+
Delivery: {deliveryId}
+
+ {msLeft <= 0 ? 'Expired' : `Expires in ${minutesLeft}m`}
+
+ {source === 'cached' && (
+
+ Offline — showing cached QR
+
+ )}
+
+ )}
+
+ {/* ── Actions ── */}
+
+
+
+
+
+ );
+}
+
+export default QrGenerator;
diff --git a/components/logistics/__tests__/QrGenerator.test.tsx b/components/logistics/__tests__/QrGenerator.test.tsx
new file mode 100644
index 0000000..67cab2d
--- /dev/null
+++ b/components/logistics/__tests__/QrGenerator.test.tsx
@@ -0,0 +1,222 @@
+/**
+ * QrGenerator — unit tests
+ *
+ * Key scenarios:
+ * - Loading skeleton while hook initialises
+ * - Renders QR canvas + "Live" badge on live payload
+ * - Renders QR canvas + "Cached" badge on cached payload
+ * - Shows "Offline — showing cached QR" note when source is cached
+ * - Renders error state with retry button when no data available
+ * - Confirm Scan button calls queueScan and onScanConfirmed callback
+ * - Refresh button calls refresh()
+ * - Syncing badge rendered when pendingCount > 0
+ */
+
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { QrGenerator } from '../QrGenerator';
+import * as useOfflineQrModule from '@/hooks/useOfflineQr';
+
+// ─── Mocks ────────────────────────────────────────────────────────────────────
+
+jest.mock('@/hooks/useOfflineQr');
+jest.mock('qrcode.react', () => ({
+ QRCodeCanvas: ({ value }: { value: string }) => (
+
+ ),
+}));
+
+const mockUseOfflineQr = useOfflineQrModule.useOfflineQr as jest.MockedFunction<
+ typeof useOfflineQrModule.useOfflineQr
+>;
+
+// ─── Fixture ──────────────────────────────────────────────────────────────────
+
+const DELIVERY_ID = 'del-001';
+
+const basePayload = {
+ qrData: 'https://example.com/qr/tok',
+ expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
+ deliveryId: DELIVERY_ID,
+ token: 'tok-abc',
+};
+
+function makeHookResult(
+ overrides: Partial = {},
+): useOfflineQrModule.UseOfflineQrResult {
+ return {
+ payload: basePayload,
+ source: 'live',
+ isLoading: false,
+ error: null,
+ isSyncing: false,
+ pendingCount: 0,
+ queueScan: jest.fn().mockResolvedValue(undefined),
+ refresh: jest.fn().mockResolvedValue(undefined),
+ ...overrides,
+ };
+}
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+beforeEach(() => jest.clearAllMocks());
+
+describe('QrGenerator — loading state', () => {
+ it('renders loading skeleton while isLoading is true', () => {
+ mockUseOfflineQr.mockReturnValue(makeHookResult({ isLoading: true, payload: null, source: null }));
+ render();
+ expect(screen.getByTestId('qr-loading')).toBeInTheDocument();
+ expect(screen.queryByTestId('qr-canvas')).not.toBeInTheDocument();
+ });
+});
+
+describe('QrGenerator — live payload', () => {
+ it('renders QR canvas when payload is available', () => {
+ mockUseOfflineQr.mockReturnValue(makeHookResult({ source: 'live' }));
+ render();
+ expect(screen.getByTestId('qr-canvas')).toBeInTheDocument();
+ });
+
+ it('encodes the correct qrData value into the canvas', () => {
+ mockUseOfflineQr.mockReturnValue(makeHookResult({ source: 'live' }));
+ render();
+ expect(screen.getByTestId('qr-canvas')).toHaveAttribute('data-value', basePayload.qrData);
+ });
+
+ it('shows "Live" badge when source is live', () => {
+ mockUseOfflineQr.mockReturnValue(makeHookResult({ source: 'live' }));
+ render();
+ expect(screen.getByLabelText('QR code loaded from live API')).toBeInTheDocument();
+ });
+
+ it('does NOT show "Cached" badge when source is live', () => {
+ mockUseOfflineQr.mockReturnValue(makeHookResult({ source: 'live' }));
+ render();
+ expect(screen.queryByLabelText('QR code loaded from local cache')).not.toBeInTheDocument();
+ });
+});
+
+describe('QrGenerator — cached payload (offline fallback)', () => {
+ it('shows "Cached" badge when source is cached', () => {
+ mockUseOfflineQr.mockReturnValue(makeHookResult({ source: 'cached' }));
+ render();
+ expect(screen.getByLabelText('QR code loaded from local cache')).toBeInTheDocument();
+ });
+
+ it('shows offline notice text when source is cached', () => {
+ mockUseOfflineQr.mockReturnValue(makeHookResult({ source: 'cached' }));
+ render();
+ expect(screen.getByText(/Offline — showing cached QR/i)).toBeInTheDocument();
+ });
+
+ it('still renders the QR canvas from cached data', () => {
+ mockUseOfflineQr.mockReturnValue(makeHookResult({ source: 'cached' }));
+ render();
+ expect(screen.getByTestId('qr-canvas')).toBeInTheDocument();
+ });
+
+ it('does NOT show "Live" badge when source is cached', () => {
+ mockUseOfflineQr.mockReturnValue(makeHookResult({ source: 'cached' }));
+ render();
+ expect(screen.queryByLabelText('QR code loaded from live API')).not.toBeInTheDocument();
+ });
+});
+
+describe('QrGenerator — error state', () => {
+ it('renders error container when error is set', () => {
+ mockUseOfflineQr.mockReturnValue(
+ makeHookResult({ payload: null, source: null, error: 'Unable to load QR code. No cached data available.' }),
+ );
+ render();
+ expect(screen.getByTestId('qr-error')).toBeInTheDocument();
+ expect(screen.getByText(/Unable to load QR/i)).toBeInTheDocument();
+ });
+
+ it('does not render QR canvas in error state', () => {
+ mockUseOfflineQr.mockReturnValue(
+ makeHookResult({ payload: null, source: null, error: 'No data' }),
+ );
+ render();
+ expect(screen.queryByTestId('qr-canvas')).not.toBeInTheDocument();
+ });
+
+ it('calls refresh() when Retry button is clicked', async () => {
+ const refresh = jest.fn().mockResolvedValue(undefined);
+ mockUseOfflineQr.mockReturnValue(
+ makeHookResult({ payload: null, source: null, error: 'No data', refresh }),
+ );
+ render();
+ fireEvent.click(screen.getByText('Retry'));
+ await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
+ });
+});
+
+describe('QrGenerator — syncing badge', () => {
+ it('shows syncing badge when isSyncing is true', () => {
+ mockUseOfflineQr.mockReturnValue(
+ makeHookResult({ isSyncing: true, pendingCount: 2 }),
+ );
+ render();
+ expect(
+ screen.getByLabelText('2 scan confirmations queued for sync'),
+ ).toBeInTheDocument();
+ });
+
+ it('does not show syncing badge when isSyncing is false', () => {
+ mockUseOfflineQr.mockReturnValue(makeHookResult({ isSyncing: false, pendingCount: 0 }));
+ render();
+ expect(screen.queryByLabelText(/queued for sync/i)).not.toBeInTheDocument();
+ });
+});
+
+describe('QrGenerator — actions', () => {
+ it('calls refresh() when Refresh button is clicked', async () => {
+ const refresh = jest.fn().mockResolvedValue(undefined);
+ mockUseOfflineQr.mockReturnValue(makeHookResult({ refresh }));
+ render();
+ fireEvent.click(screen.getByTestId('refresh-button'));
+ await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
+ });
+
+ it('calls queueScan() when Confirm Scan button is clicked', async () => {
+ const queueScan = jest.fn().mockResolvedValue(undefined);
+ mockUseOfflineQr.mockReturnValue(makeHookResult({ queueScan }));
+ render();
+ fireEvent.click(screen.getByTestId('confirm-scan-button'));
+ await waitFor(() => expect(queueScan).toHaveBeenCalledTimes(1));
+ expect(queueScan).toHaveBeenCalledWith(
+ expect.objectContaining({ deliveryId: DELIVERY_ID, token: 'tok-abc' }),
+ );
+ });
+
+ it('fires onScanConfirmed callback after Confirm Scan', async () => {
+ const queueScan = jest.fn().mockResolvedValue(undefined);
+ const onScanConfirmed = jest.fn();
+ mockUseOfflineQr.mockReturnValue(makeHookResult({ queueScan }));
+ render();
+ fireEvent.click(screen.getByTestId('confirm-scan-button'));
+ await waitFor(() => expect(onScanConfirmed).toHaveBeenCalledTimes(1));
+ });
+});
+
+describe('QrGenerator — label', () => {
+ it('shows delivery ID and expiry when includeLabel is true', () => {
+ mockUseOfflineQr.mockReturnValue(makeHookResult());
+ render();
+ expect(screen.getByText(/Delivery: del-001/)).toBeInTheDocument();
+ expect(screen.getByText('Package Handoff QR')).toBeInTheDocument();
+ });
+
+ it('hides label when includeLabel is false', () => {
+ mockUseOfflineQr.mockReturnValue(makeHookResult());
+ render();
+ expect(screen.queryByText('Package Handoff QR')).not.toBeInTheDocument();
+ });
+
+ it('uses custom size prop on QR canvas', () => {
+ mockUseOfflineQr.mockReturnValue(makeHookResult());
+ // The mock doesn't use size, but the wrapper is rendered
+ render();
+ expect(screen.getByTestId('qr-canvas-wrapper')).toBeInTheDocument();
+ });
+});
diff --git a/hooks/__tests__/useOfflineQr.test.ts b/hooks/__tests__/useOfflineQr.test.ts
new file mode 100644
index 0000000..2ab385b
--- /dev/null
+++ b/hooks/__tests__/useOfflineQr.test.ts
@@ -0,0 +1,223 @@
+/**
+ * useOfflineQr — unit tests
+ *
+ * Key scenarios:
+ * - Returns live payload when API succeeds
+ * - Falls back to cache on network timeout
+ * - Falls back to cache when navigator.onLine is false
+ * - Reports error when both API and cache are unavailable
+ * - Queues a scan confirmation and flushes it when online
+ */
+
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { useOfflineQr } from '../useOfflineQr';
+import { shipmentHandoffService } from '@/services/shipmentHandoffService';
+import { qrCacheService } from '@/services/qrCacheService';
+
+// ─── Mocks ────────────────────────────────────────────────────────────────────
+
+jest.mock('@/services/shipmentHandoffService');
+jest.mock('@/services/qrCacheService');
+
+const mockService = shipmentHandoffService as jest.Mocked;
+const mockCache = qrCacheService as jest.Mocked;
+
+const DELIVERY_ID = 'del-001';
+
+const mockPayload = {
+ qrData: 'https://example.com/qr/abc',
+ expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
+ deliveryId: DELIVERY_ID,
+ token: 'tok-abc',
+};
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function setupCacheDefaults() {
+ mockCache.savePayload.mockResolvedValue(undefined);
+ mockCache.getPayload.mockResolvedValue(null);
+ mockCache.getPendingConfirmations.mockResolvedValue([]);
+ mockCache.pendingCount.mockResolvedValue(0);
+ mockCache.queueScanConfirmation.mockResolvedValue(undefined);
+ mockCache.removeConfirmation.mockResolvedValue(undefined);
+}
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ setupCacheDefaults();
+});
+
+describe('useOfflineQr — live fetch success', () => {
+ it('returns live payload and source="live" when API succeeds', async () => {
+ mockService.getHandoffQR.mockResolvedValue(mockPayload);
+
+ const { result } = renderHook(() => useOfflineQr(DELIVERY_ID));
+
+ expect(result.current.isLoading).toBe(true);
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.payload).toEqual(mockPayload);
+ expect(result.current.source).toBe('live');
+ expect(result.current.error).toBeNull();
+ });
+
+ it('persists payload to cache after a successful live fetch', async () => {
+ mockService.getHandoffQR.mockResolvedValue(mockPayload);
+
+ renderHook(() => useOfflineQr(DELIVERY_ID));
+
+ await waitFor(() => {
+ expect(mockCache.savePayload).toHaveBeenCalledWith(mockPayload);
+ });
+ });
+});
+
+describe('useOfflineQr — offline / timeout fallback', () => {
+ it('returns cached payload and source="cached" when API times out', async () => {
+ // API never resolves within the 5 s window — fake with a long delay
+ mockService.getHandoffQR.mockImplementation(
+ () => new Promise((resolve) => setTimeout(() => resolve(mockPayload), 10_000)),
+ );
+ mockCache.getPayload.mockResolvedValue(mockPayload);
+
+ // Fake timers so the 5 s timeout fires immediately
+ jest.useFakeTimers();
+
+ const { result } = renderHook(() => useOfflineQr(DELIVERY_ID));
+
+ act(() => { jest.advanceTimersByTime(6_000); });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.source).toBe('cached');
+ expect(result.current.payload).toEqual(mockPayload);
+ expect(result.current.error).toBeNull();
+
+ jest.useRealTimers();
+ });
+
+ it('returns error when API fails and cache is empty', async () => {
+ mockService.getHandoffQR.mockRejectedValue(new Error('Network error'));
+ mockCache.getPayload.mockResolvedValue(null);
+
+ const { result } = renderHook(() => useOfflineQr(DELIVERY_ID));
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.payload).toBeNull();
+ expect(result.current.error).toMatch(/Unable to load QR/);
+ });
+
+ it('falls back to cache when API rejects', async () => {
+ mockService.getHandoffQR.mockRejectedValue(new Error('503'));
+ mockCache.getPayload.mockResolvedValue(mockPayload);
+
+ const { result } = renderHook(() => useOfflineQr(DELIVERY_ID));
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.source).toBe('cached');
+ expect(result.current.payload).toEqual(mockPayload);
+ });
+});
+
+describe('useOfflineQr — scan confirmation queue', () => {
+ it('queues a scan confirmation via queueScan()', async () => {
+ mockService.getHandoffQR.mockResolvedValue(mockPayload);
+
+ const { result } = renderHook(() => useOfflineQr(DELIVERY_ID));
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ const confirmation = {
+ deliveryId: DELIVERY_ID,
+ token: 'tok-abc',
+ scannedAt: new Date().toISOString(),
+ };
+
+ await act(async () => {
+ await result.current.queueScan(confirmation);
+ });
+
+ expect(mockCache.queueScanConfirmation).toHaveBeenCalledWith(confirmation);
+ });
+
+ it('flushes queue immediately when online after queueScan()', async () => {
+ mockService.getHandoffQR.mockResolvedValue(mockPayload);
+ mockService.verifyHandoffQR.mockResolvedValue({
+ id: 'v1',
+ deliveryId: DELIVERY_ID,
+ token: 'tok-abc',
+ expiresAt: mockPayload.expiresAt,
+ createdAt: new Date().toISOString(),
+ });
+ // Return one pending confirmation on the first call, then 0 afterwards
+ mockCache.getPendingConfirmations
+ .mockResolvedValueOnce([{ key: 1, value: { deliveryId: DELIVERY_ID, token: 'tok-abc', scannedAt: '' } }])
+ .mockResolvedValue([]);
+ mockCache.pendingCount.mockResolvedValue(0);
+
+ // Simulate being online
+ Object.defineProperty(navigator, 'onLine', { value: true, configurable: true });
+
+ const { result } = renderHook(() => useOfflineQr(DELIVERY_ID));
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ await act(async () => {
+ await result.current.queueScan({ deliveryId: DELIVERY_ID, token: 'tok-abc', scannedAt: '' });
+ });
+
+ expect(mockService.verifyHandoffQR).toHaveBeenCalledWith(DELIVERY_ID, 'tok-abc');
+ expect(mockCache.removeConfirmation).toHaveBeenCalledWith(1);
+ });
+});
+
+describe('useOfflineQr — online event sync', () => {
+ it('flushes pending queue when browser comes back online', async () => {
+ mockService.getHandoffQR.mockResolvedValue(mockPayload);
+ mockService.verifyHandoffQR.mockResolvedValue({
+ id: 'v2',
+ deliveryId: DELIVERY_ID,
+ token: 'tok-xyz',
+ expiresAt: mockPayload.expiresAt,
+ createdAt: new Date().toISOString(),
+ });
+ mockCache.getPendingConfirmations.mockResolvedValue([
+ { key: 42, value: { deliveryId: DELIVERY_ID, token: 'tok-xyz', scannedAt: '' } },
+ ]);
+ mockCache.pendingCount.mockResolvedValue(0);
+
+ renderHook(() => useOfflineQr(DELIVERY_ID));
+
+ // Simulate coming back online
+ await act(async () => {
+ window.dispatchEvent(new Event('online'));
+ });
+
+ await waitFor(() => {
+ expect(mockService.verifyHandoffQR).toHaveBeenCalledWith(DELIVERY_ID, 'tok-xyz');
+ });
+ });
+});
+
+describe('useOfflineQr — refresh', () => {
+ it('re-fetches and updates source to "live" on manual refresh', async () => {
+ mockService.getHandoffQR
+ .mockRejectedValueOnce(new Error('offline'))
+ .mockResolvedValueOnce(mockPayload);
+ mockCache.getPayload.mockResolvedValue(mockPayload);
+
+ const { result } = renderHook(() => useOfflineQr(DELIVERY_ID));
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.source).toBe('cached');
+
+ await act(async () => {
+ await result.current.refresh();
+ });
+
+ expect(result.current.source).toBe('live');
+ });
+});
diff --git a/hooks/useOfflineQr.ts b/hooks/useOfflineQr.ts
new file mode 100644
index 0000000..0ca06d8
--- /dev/null
+++ b/hooks/useOfflineQr.ts
@@ -0,0 +1,183 @@
+'use client';
+
+/**
+ * useOfflineQr — Hook layer for offline-ready QR code handoff.
+ *
+ * Strategy:
+ * 1. Attempt to fetch the latest payload from the backend API (5 s timeout).
+ * 2. On success — persist to IndexedDB and return the live payload.
+ * 3. On timeout / network failure — return the cached payload from IndexedDB.
+ * 4. Listen for the browser `online` event to flush the pending scan
+ * confirmation queue and refresh the live payload.
+ *
+ * Component → Hook → Service pattern:
+ * QrGenerator (component) → useOfflineQr (hook)
+ * → shipmentHandoffService (API)
+ * → qrCacheService (IndexedDB)
+ */
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { shipmentHandoffService } from '@/services/shipmentHandoffService';
+import { qrCacheService, ScanConfirmation } from '@/services/qrCacheService';
+import { HandoffQRData } from '@/types/shipment';
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+export type QrSource = 'live' | 'cached';
+
+export interface UseOfflineQrResult {
+ /** The QR payload to render — either live or from cache. */
+ payload: HandoffQRData | null;
+ /** Where the current payload came from. */
+ source: QrSource | null;
+ /** True while the initial fetch / cache lookup is in progress. */
+ isLoading: boolean;
+ /** Error message when both live fetch and cache are unavailable. */
+ error: string | null;
+ /** True when queued scan confirmations are waiting to be synced. */
+ isSyncing: boolean;
+ /** Number of confirmations still in the queue. */
+ pendingCount: number;
+ /** Queue a successful scan for background sync. */
+ queueScan: (confirmation: ScanConfirmation) => Promise;
+ /** Manually trigger a live refresh. */
+ refresh: () => Promise;
+}
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const FETCH_TIMEOUT_MS = 5_000;
+
+// ─── Helper ───────────────────────────────────────────────────────────────────
+
+function withTimeout(promise: Promise, ms: number): Promise {
+ return new Promise((resolve, reject) => {
+ const id = setTimeout(() => reject(new Error('Request timed out')), ms);
+ promise.then(
+ (val) => { clearTimeout(id); resolve(val); },
+ (err) => { clearTimeout(id); reject(err); },
+ );
+ });
+}
+
+// ─── Hook ─────────────────────────────────────────────────────────────────────
+
+export function useOfflineQr(deliveryId: string): UseOfflineQrResult {
+ const [payload, setPayload] = useState(null);
+ const [source, setSource] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [isSyncing, setIsSyncing] = useState(false);
+ const [pendingCount, setPendingCount] = useState(0);
+ const mountedRef = useRef(true);
+
+ // ── Core fetch logic ─────────────────────────────────────────────────────
+
+ const fetchAndCache = useCallback(async () => {
+ if (!deliveryId) return;
+
+ try {
+ const live = await withTimeout(
+ shipmentHandoffService.getHandoffQR(deliveryId),
+ FETCH_TIMEOUT_MS,
+ );
+ // Persist immediately — driver may go offline at any time
+ await qrCacheService.savePayload(live);
+ if (mountedRef.current) {
+ setPayload(live);
+ setSource('live');
+ setError(null);
+ }
+ } catch {
+ // Live fetch failed — fall back to cache
+ const cached = await qrCacheService.getPayload(deliveryId);
+ if (!mountedRef.current) return;
+ if (cached) {
+ setPayload(cached);
+ setSource('cached');
+ setError(null);
+ } else {
+ setPayload(null);
+ setSource(null);
+ setError('Unable to load QR code. No cached data available.');
+ }
+ }
+ }, [deliveryId]);
+
+ // ── Sync queue flush ─────────────────────────────────────────────────────
+
+ const flushQueue = useCallback(async () => {
+ const pending = await qrCacheService.getPendingConfirmations();
+ if (pending.length === 0) return;
+
+ setIsSyncing(true);
+ for (const { key, value } of pending) {
+ try {
+ await shipmentHandoffService.verifyHandoffQR(value.deliveryId, value.token);
+ await qrCacheService.removeConfirmation(key);
+ } catch {
+ // Leave failed entries in the queue for the next online event
+ }
+ }
+ const remaining = await qrCacheService.pendingCount();
+ if (mountedRef.current) {
+ setPendingCount(remaining);
+ setIsSyncing(remaining > 0);
+ }
+ }, []);
+
+ const syncPendingCount = useCallback(async () => {
+ const count = await qrCacheService.pendingCount();
+ if (mountedRef.current) {
+ setPendingCount(count);
+ setIsSyncing(count > 0);
+ }
+ }, []);
+
+ // ── Initial load ─────────────────────────────────────────────────────────
+
+ useEffect(() => {
+ mountedRef.current = true;
+
+ const init = async () => {
+ setIsLoading(true);
+ await fetchAndCache();
+ await syncPendingCount();
+ if (mountedRef.current) setIsLoading(false);
+ };
+
+ void init();
+
+ return () => { mountedRef.current = false; };
+ }, [deliveryId, fetchAndCache, syncPendingCount]);
+
+ // ── Online event → flush queue + refresh ────────────────────────────────
+
+ useEffect(() => {
+ const handleOnline = () => {
+ void flushQueue();
+ void fetchAndCache();
+ };
+ window.addEventListener('online', handleOnline);
+ return () => window.removeEventListener('online', handleOnline);
+ }, [flushQueue, fetchAndCache]);
+
+ // ── Public API ───────────────────────────────────────────────────────────
+
+ const queueScan = useCallback(async (confirmation: ScanConfirmation) => {
+ await qrCacheService.queueScanConfirmation(confirmation);
+ await syncPendingCount();
+ // Attempt immediate sync if online
+ if (typeof navigator !== 'undefined' && navigator.onLine) {
+ await flushQueue();
+ }
+ }, [flushQueue, syncPendingCount]);
+
+ const refresh = useCallback(async () => {
+ setIsLoading(true);
+ await fetchAndCache();
+ if (mountedRef.current) setIsLoading(false);
+ }, [fetchAndCache]);
+
+ return { payload, source, isLoading, error, isSyncing, pendingCount, queueScan, refresh };
+}
diff --git a/jest.config.ts b/jest.config.ts
index b33a0fa..d061a79 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -10,7 +10,11 @@ const config: Config = {
'^.+\\.(ts|tsx)$': [
'ts-jest',
{
- tsconfig: '/tsconfig.jest.json',
+ tsconfig: {
+ jsx: 'react-jsx',
+ moduleResolution: 'node',
+ ignoreDeprecations: '5.0',
+ },
},
],
},
diff --git a/services/qrCacheService.ts b/services/qrCacheService.ts
new file mode 100644
index 0000000..a36b04e
--- /dev/null
+++ b/services/qrCacheService.ts
@@ -0,0 +1,162 @@
+/**
+ * qrCacheService — Service layer for offline QR payload persistence.
+ *
+ * Responsibilities:
+ * - Store / retrieve HandoffQRData in IndexedDB so drivers can generate
+ * QR codes even when fully offline.
+ * - Maintain a queue of successful scan confirmations that must be synced
+ * to the backend once connectivity is restored.
+ *
+ * Follows the singleton pattern used elsewhere in this service layer.
+ */
+
+import { HandoffQRData } from '@/types/shipment';
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+export interface ScanConfirmation {
+ deliveryId: string;
+ token: string;
+ scannedAt: string; // ISO timestamp
+}
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const DB_NAME = 'swiftchain_qr_cache';
+const DB_VERSION = 1;
+const STORE_PAYLOAD = 'qr_payloads';
+const STORE_QUEUE = 'scan_queue';
+
+// ─── IndexedDB bootstrap ──────────────────────────────────────────────────────
+
+function openDB(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
+
+ request.onupgradeneeded = (event) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+ if (!db.objectStoreNames.contains(STORE_PAYLOAD)) {
+ db.createObjectStore(STORE_PAYLOAD, { keyPath: 'deliveryId' });
+ }
+ if (!db.objectStoreNames.contains(STORE_QUEUE)) {
+ // Auto-increment key — one entry per queued confirmation
+ db.createObjectStore(STORE_QUEUE, { autoIncrement: true });
+ }
+ };
+
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error);
+ });
+}
+
+// ─── Service class ────────────────────────────────────────────────────────────
+
+class QrCacheService {
+ // ── Payload cache ──────────────────────────────────────────────────────────
+
+ /**
+ * Persist the latest live payload for a delivery.
+ * Call this immediately after a successful API fetch.
+ */
+ async savePayload(payload: HandoffQRData): Promise {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_PAYLOAD, 'readwrite');
+ tx.objectStore(STORE_PAYLOAD).put(payload);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ }
+
+ /**
+ * Retrieve the cached payload for a delivery.
+ * Returns `null` if no cache entry exists.
+ */
+ async getPayload(deliveryId: string): Promise {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_PAYLOAD, 'readonly');
+ const request = tx.objectStore(STORE_PAYLOAD).get(deliveryId);
+ request.onsuccess = () => resolve((request.result as HandoffQRData) ?? null);
+ request.onerror = () => reject(request.error);
+ });
+ }
+
+ /**
+ * Remove the cached payload for a delivery (e.g. after a confirmed handoff).
+ */
+ async clearPayload(deliveryId: string): Promise {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_PAYLOAD, 'readwrite');
+ tx.objectStore(STORE_PAYLOAD).delete(deliveryId);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ }
+
+ // ── Scan confirmation queue ────────────────────────────────────────────────
+
+ /**
+ * Enqueue a scan confirmation to be synced once connectivity returns.
+ */
+ async queueScanConfirmation(confirmation: ScanConfirmation): Promise {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_QUEUE, 'readwrite');
+ tx.objectStore(STORE_QUEUE).add(confirmation);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ }
+
+ /**
+ * Return all queued confirmations (in insertion order).
+ */
+ async getPendingConfirmations(): Promise> {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const results: Array<{ key: IDBValidKey; value: ScanConfirmation }> = [];
+ const tx = db.transaction(STORE_QUEUE, 'readonly');
+ const request = tx.objectStore(STORE_QUEUE).openCursor();
+ request.onsuccess = (event) => {
+ const cursor = (event.target as IDBRequest).result;
+ if (cursor) {
+ results.push({ key: cursor.key, value: cursor.value as ScanConfirmation });
+ cursor.continue();
+ } else {
+ resolve(results);
+ }
+ };
+ request.onerror = () => reject(request.error);
+ });
+ }
+
+ /**
+ * Remove a synced confirmation from the queue by its IDB key.
+ */
+ async removeConfirmation(key: IDBValidKey): Promise {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_QUEUE, 'readwrite');
+ tx.objectStore(STORE_QUEUE).delete(key);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ }
+
+ /**
+ * Return the number of confirmations waiting to be synced.
+ */
+ async pendingCount(): Promise {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_QUEUE, 'readonly');
+ const request = tx.objectStore(STORE_QUEUE).count();
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error);
+ });
+ }
+}
+
+export const qrCacheService = new QrCacheService();