deckhearth/lib/use-scanner-offline.js
Randall Stillwell cf9fea0726 feat(scanner): rebuild as mobile-first three-phase flow
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>
2026-06-13 08:42:58 -05:00

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