Replace the desktop-first, everything-at-once scanner layout with a phased mobile-optimized experience: Setup → Scanning → Review. Phase 1 (Setup): destination picker, game filter, deck mode toggle, scan history (last 5 sessions). Phase 2 (Scanning): full-screen camera with auto-start, haptic + sound feedback on card detection, torch/flash toggle, count pill, bottom-sheet disambiguation (replaces full-screen modal). Phase 3 (Review): card list with inline condition/foil/qty edits, batch confirm, 30-second undo, deck progress indicator. New features: - Deck mode (progress toward 40/60/99 card target) - Scan history (persisted to localStorage) - Sound feedback (Web Audio oscillator, configurable) - Offline queue (localStorage persistence + auto-retry on reconnect) - Camera flash/torch toggle - Batch ownership API (replaces N+1 per-card fetches) - Visibility pause (detection loop stops when tab is backgrounded) Convoy: scanner-rebuild Co-authored-by: Cursor <cursoragent@cursor.com>
135 lines
3.6 KiB
JavaScript
135 lines
3.6 KiB
JavaScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import {
|
|
addScannedCardToOwned,
|
|
addScannedCardToCollection,
|
|
addScannedCardToDeck,
|
|
} from './scanner-route-api.js';
|
|
|
|
const OFFLINE_QUEUE_KEY = 'deckhearth:offline-scan-queue';
|
|
const MAX_RETRIES = 3;
|
|
|
|
function loadQueue() {
|
|
if (typeof window === 'undefined') return [];
|
|
try {
|
|
return JSON.parse(localStorage.getItem(OFFLINE_QUEUE_KEY)) || [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function persistQueue(queue) {
|
|
if (typeof window === 'undefined') return;
|
|
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue));
|
|
}
|
|
|
|
async function executeAction(action, payload) {
|
|
switch (action) {
|
|
case 'addToOwned':
|
|
return addScannedCardToOwned(payload);
|
|
case 'addToCollection':
|
|
return addScannedCardToCollection(payload, payload.collectionId);
|
|
case 'addToDeck':
|
|
return addScannedCardToDeck(payload, payload.deckId);
|
|
default:
|
|
throw new Error(`Unknown offline action: ${action}`);
|
|
}
|
|
}
|
|
|
|
export function useScannerOffline() {
|
|
const [isOnline, setIsOnline] = useState(
|
|
() => typeof navigator !== 'undefined' ? navigator.onLine : true
|
|
);
|
|
const [queuedActions, setQueuedActions] = useState(loadQueue);
|
|
const processingRef = useRef(false);
|
|
|
|
const updateQueue = useCallback((updater) => {
|
|
setQueuedActions((prev) => {
|
|
const next = typeof updater === 'function' ? updater(prev) : updater;
|
|
persistQueue(next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const processQueue = useCallback(async () => {
|
|
if (processingRef.current) return;
|
|
processingRef.current = true;
|
|
|
|
try {
|
|
const current = loadQueue();
|
|
const pending = current.filter((item) => item.status === 'pending');
|
|
|
|
for (const item of pending) {
|
|
try {
|
|
await executeAction(item.action, item.payload);
|
|
updateQueue((prev) => prev.filter((q) => q.id !== item.id));
|
|
} catch {
|
|
updateQueue((prev) =>
|
|
prev.map((q) => {
|
|
if (q.id !== item.id) return q;
|
|
const retries = q.retries + 1;
|
|
return {
|
|
...q,
|
|
retries,
|
|
status: retries >= MAX_RETRIES ? 'failed' : 'pending',
|
|
};
|
|
})
|
|
);
|
|
}
|
|
}
|
|
} finally {
|
|
processingRef.current = false;
|
|
}
|
|
}, [updateQueue]);
|
|
|
|
useEffect(() => {
|
|
const goOnline = () => {
|
|
setIsOnline(true);
|
|
processQueue();
|
|
};
|
|
const goOffline = () => setIsOnline(false);
|
|
|
|
window.addEventListener('online', goOnline);
|
|
window.addEventListener('offline', goOffline);
|
|
return () => {
|
|
window.removeEventListener('online', goOnline);
|
|
window.removeEventListener('offline', goOffline);
|
|
};
|
|
}, [processQueue]);
|
|
|
|
// Process any items left over from a previous session on mount
|
|
useEffect(() => {
|
|
if (isOnline && loadQueue().some((q) => q.status === 'pending')) {
|
|
processQueue();
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const enqueue = useCallback(
|
|
async (action, payload) => {
|
|
const item = {
|
|
id: Date.now() + Math.random(),
|
|
action,
|
|
payload,
|
|
timestamp: Date.now(),
|
|
retries: 0,
|
|
status: 'pending',
|
|
};
|
|
|
|
if (isOnline) {
|
|
try {
|
|
await executeAction(action, payload);
|
|
return;
|
|
} catch {
|
|
// Network error while supposedly online — fall through to queue
|
|
}
|
|
}
|
|
|
|
updateQueue((prev) => [...prev, item]);
|
|
},
|
|
[isOnline, updateQueue]
|
|
);
|
|
|
|
const pendingCount = queuedActions.filter((q) => q.status === 'pending').length;
|
|
|
|
return { isOnline, queuedActions, enqueue, processQueue, pendingCount };
|
|
}
|