deckhearth/lib/use-scanner-session.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

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