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>
102 lines
2.9 KiB
JavaScript
102 lines
2.9 KiB
JavaScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { VOCAB } from './collection-vocabulary.js';
|
|
|
|
const STORAGE_KEY = 'deckhearth:scanner-session';
|
|
const HISTORY_KEY = 'deckhearth:scan-history';
|
|
const MAX_HISTORY = 5;
|
|
|
|
const DEFAULT_DESTINATION = { type: 'owned', id: null, label: VOCAB.MY_COLLECTION };
|
|
const DEFAULT_SCAN_DEFAULTS = { condition: 'NM', isFoil: false };
|
|
|
|
function readJson(key, fallback) {
|
|
if (typeof window === 'undefined') return fallback;
|
|
try {
|
|
const raw = localStorage.getItem(key);
|
|
return raw ? JSON.parse(raw) : fallback;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function writeJson(key, value) {
|
|
if (typeof window === 'undefined') return;
|
|
try {
|
|
localStorage.setItem(key, JSON.stringify(value));
|
|
} catch {
|
|
/* quota exceeded — silently degrade */
|
|
}
|
|
}
|
|
|
|
export function useScannerSession() {
|
|
const [sessionDestination, setSessionDestination] = useState(() => {
|
|
const saved = readJson(STORAGE_KEY, {});
|
|
return saved.destination ?? DEFAULT_DESTINATION;
|
|
});
|
|
const [gameFilter, setGameFilter] = useState(() => {
|
|
const saved = readJson(STORAGE_KEY, {});
|
|
return saved.gameFilter ?? 'All';
|
|
});
|
|
const [scanDefaults, setScanDefaults] = useState(() => {
|
|
const saved = readJson(STORAGE_KEY, {});
|
|
return saved.scanDefaults ?? DEFAULT_SCAN_DEFAULTS;
|
|
});
|
|
const [scanHistory, setScanHistory] = useState(() => readJson(HISTORY_KEY, []));
|
|
|
|
const isInitialRender = useRef(true);
|
|
|
|
useEffect(() => {
|
|
if (isInitialRender.current) {
|
|
isInitialRender.current = false;
|
|
return;
|
|
}
|
|
writeJson(STORAGE_KEY, {
|
|
destination: sessionDestination,
|
|
gameFilter,
|
|
scanDefaults,
|
|
});
|
|
}, [sessionDestination, gameFilter, scanDefaults]);
|
|
|
|
const handleGameFilterChange = useCallback((nextFilter) => {
|
|
setGameFilter(nextFilter);
|
|
setSessionDestination((current) => {
|
|
if (!current || current.type === 'owned') return current;
|
|
return DEFAULT_DESTINATION;
|
|
});
|
|
}, []);
|
|
|
|
const handleDestinationChange = useCallback((next) => {
|
|
setSessionDestination(next);
|
|
}, []);
|
|
|
|
const handleScanDefaultsChange = useCallback((patch) => {
|
|
setScanDefaults((current) => ({ ...current, ...patch }));
|
|
}, []);
|
|
|
|
const saveScanToHistory = useCallback(
|
|
(cardCount) => {
|
|
setScanHistory((prev) => {
|
|
const entry = {
|
|
date: new Date().toISOString(),
|
|
cardCount,
|
|
destination: sessionDestination,
|
|
game: gameFilter,
|
|
};
|
|
const next = [entry, ...prev].slice(0, MAX_HISTORY);
|
|
writeJson(HISTORY_KEY, next);
|
|
return next;
|
|
});
|
|
},
|
|
[sessionDestination, gameFilter]
|
|
);
|
|
|
|
return {
|
|
sessionDestination,
|
|
setSessionDestination: handleDestinationChange,
|
|
gameFilter,
|
|
setGameFilter: handleGameFilterChange,
|
|
scanDefaults,
|
|
setScanDefaults: handleScanDefaultsChange,
|
|
scanHistory,
|
|
saveScanToHistory,
|
|
};
|
|
}
|