* Start scanner-mobile-checkout convoy for the cart-then-commit phone flow. Co-authored-by: Cursor <cursoragent@cursor.com> * Ship a cart-then-commit mobile scanner so phone sessions stay on the camera. Scan matches enqueue locally instead of auto-writing ownership, checkout happens in a sheet, and audit fixes cover stale commit detection, returnUrl open redirects, nested Escape, and ember detection chrome. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
388 lines
11 KiB
JavaScript
388 lines
11 KiB
JavaScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import {
|
|
addScannedCardToCollection,
|
|
addScannedCardToDeck,
|
|
addScannedCardToOwned,
|
|
createScannerCollection,
|
|
fetchBatchOwnership,
|
|
fetchScannerCollections,
|
|
fetchScannerDecks,
|
|
} from './scanner-route-api.js';
|
|
import {
|
|
loadScannerCart,
|
|
mergeScannedCardEntry,
|
|
saveScannerCart,
|
|
} from './scanner-session.js';
|
|
|
|
const UNDO_TTL_MS = 30_000;
|
|
const UNDO_CLEANUP_INTERVAL_MS = 5_000;
|
|
const OWNERSHIP_DEBOUNCE_MS = 500;
|
|
const CART_PERSIST_DEBOUNCE_MS = 300;
|
|
|
|
/**
|
|
* Scanned-card queue, bulk selection, in-flight guards, and destination lists.
|
|
*/
|
|
export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMode }) {
|
|
const [scannedCards, setScannedCards] = useState(() => {
|
|
const cart = loadScannerCart();
|
|
return cart?.scannedCards ?? [];
|
|
});
|
|
const [collections, setCollections] = useState([]);
|
|
const [decks, setDecks] = useState([]);
|
|
const [selectedCards, setSelectedCards] = useState(() => {
|
|
const cart = loadScannerCart();
|
|
return new Set(cart?.selectedCardIds ?? []);
|
|
});
|
|
const [bulkAction, setBulkAction] = useState('');
|
|
const [bulkTarget, setBulkTarget] = useState('');
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [autoRouteError, setAutoRouteError] = useState(null);
|
|
const [showCreateCollection, setShowCreateCollection] = useState(false);
|
|
const [newCollectionName, setNewCollectionName] = useState('');
|
|
const [undoStack, setUndoStack] = useState([]);
|
|
const [ownershipMap, setOwnershipMap] = useState({});
|
|
|
|
const addingInFlightRef = useRef(new Set());
|
|
const [addingCardIds, setAddingCardIds] = useState(() => new Set());
|
|
const cartPersistTimerRef = useRef(null);
|
|
|
|
const persistCart = (cards, selection) => {
|
|
saveScannerCart({
|
|
scannedCards: cards,
|
|
selectedCardIds: Array.from(selection),
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
clearTimeout(cartPersistTimerRef.current);
|
|
cartPersistTimerRef.current = setTimeout(() => {
|
|
persistCart(scannedCards, selectedCards);
|
|
}, CART_PERSIST_DEBOUNCE_MS);
|
|
|
|
return () => clearTimeout(cartPersistTimerRef.current);
|
|
}, [scannedCards, selectedCards]);
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
|
|
let cancelled = false;
|
|
|
|
(async () => {
|
|
try {
|
|
const [loadedCollections, loadedDecks] = await Promise.all([
|
|
fetchScannerCollections(),
|
|
fetchScannerDecks(),
|
|
]);
|
|
if (cancelled) return;
|
|
setCollections(loadedCollections);
|
|
setDecks(loadedDecks);
|
|
} catch (error) {
|
|
console.error('Error loading scanner destinations:', error);
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [user]);
|
|
|
|
const syncAddingState = () => {
|
|
setAddingCardIds(new Set(addingInFlightRef.current));
|
|
};
|
|
|
|
const tryBeginAdding = (cardId) => {
|
|
if (addingInFlightRef.current.has(cardId)) return false;
|
|
addingInFlightRef.current.add(cardId);
|
|
syncAddingState();
|
|
return true;
|
|
};
|
|
|
|
const endAdding = (cardId) => {
|
|
addingInFlightRef.current.delete(cardId);
|
|
syncAddingState();
|
|
};
|
|
|
|
// --- Undo stack: clear expired entries every 5s ---
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
const cutoff = Date.now() - UNDO_TTL_MS;
|
|
setUndoStack((prev) => {
|
|
const next = prev.filter((entry) => entry.timestamp > cutoff);
|
|
return next.length === prev.length ? prev : next;
|
|
});
|
|
}, UNDO_CLEANUP_INTERVAL_MS);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
// --- Batch ownership: debounced fetch for new card IDs ---
|
|
const ownershipTimerRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
|
|
const dbIds = scannedCards
|
|
.filter((c) => c.databaseId)
|
|
.map((c) => c.databaseId);
|
|
const newIds = dbIds.filter((id) => !(id in ownershipMap));
|
|
|
|
if (newIds.length === 0) return;
|
|
|
|
clearTimeout(ownershipTimerRef.current);
|
|
ownershipTimerRef.current = setTimeout(async () => {
|
|
try {
|
|
const result = await fetchBatchOwnership(newIds);
|
|
setOwnershipMap((prev) => ({ ...prev, ...result }));
|
|
} catch (err) {
|
|
console.error('Batch ownership fetch failed:', err);
|
|
}
|
|
}, OWNERSHIP_DEBOUNCE_MS);
|
|
|
|
return () => clearTimeout(ownershipTimerRef.current);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- ownershipMap in deps would cause infinite loop
|
|
}, [scannedCards, user]);
|
|
|
|
// --- Deck mode completion: derived state, no effect needed ---
|
|
const deckComplete = Boolean(deckMode && scannedCards.length >= deckMode.targetSize);
|
|
|
|
const markCardAsProcessed = (cardId, action) => {
|
|
setScannedCards((prev) =>
|
|
prev.map((card) =>
|
|
card.id === cardId ? { ...card, processed: true, processedAction: action } : card
|
|
)
|
|
);
|
|
setUndoStack((prev) => [...prev, { cardId, action, timestamp: Date.now() }]);
|
|
};
|
|
|
|
const undoCardProcess = useCallback((cardId) => {
|
|
setScannedCards((prev) =>
|
|
prev.map((card) =>
|
|
card.id === cardId ? { ...card, processed: false, processedAction: null } : card
|
|
)
|
|
);
|
|
setUndoStack((prev) => prev.filter((u) => u.cardId !== cardId));
|
|
}, []);
|
|
|
|
const handleCardScanned = (cardData) => {
|
|
setAutoRouteError(null);
|
|
|
|
const { cardEntry, scannedCards: nextQueue } = mergeScannedCardEntry(
|
|
scannedCards,
|
|
cardData,
|
|
scanDefaults
|
|
);
|
|
setScannedCards(nextQueue);
|
|
setSelectedCards((prev) => {
|
|
const next = new Set([...prev, cardEntry.id]);
|
|
persistCart(nextQueue, next);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const incrementCardQuantity = (cardId) => {
|
|
setScannedCards((prev) =>
|
|
prev.map((card) =>
|
|
card.id === cardId ? { ...card, quantity: (card.quantity || 1) + 1 } : card
|
|
)
|
|
);
|
|
};
|
|
|
|
const decrementCardQuantity = (cardId) => {
|
|
setScannedCards((prev) =>
|
|
prev.map((card) =>
|
|
card.id === cardId
|
|
? { ...card, quantity: Math.max(1, (card.quantity || 1) - 1) }
|
|
: card
|
|
)
|
|
);
|
|
};
|
|
|
|
const updateCardMetadata = (cardId, patch) => {
|
|
setScannedCards((prev) =>
|
|
prev.map((card) => (card.id === cardId ? { ...card, ...patch } : card))
|
|
);
|
|
};
|
|
|
|
const addSingleCardToOwned = async (card) => {
|
|
if (!tryBeginAdding(card.id)) return;
|
|
try {
|
|
await addScannedCardToOwned(card);
|
|
markCardAsProcessed(card.id, 'owned');
|
|
} catch (error) {
|
|
console.error('Error adding card to owned:', error);
|
|
} finally {
|
|
endAdding(card.id);
|
|
}
|
|
};
|
|
|
|
const addSingleCardToCollection = async (card, collectionId) => {
|
|
if (!tryBeginAdding(card.id)) return;
|
|
try {
|
|
await addScannedCardToCollection(card, collectionId);
|
|
markCardAsProcessed(card.id, 'collection');
|
|
} catch (error) {
|
|
console.error('Error adding card to collection:', error);
|
|
} finally {
|
|
endAdding(card.id);
|
|
}
|
|
};
|
|
|
|
const addSingleCardToDeck = async (card, deckId) => {
|
|
if (!tryBeginAdding(card.id)) return;
|
|
try {
|
|
await addScannedCardToDeck(card, deckId);
|
|
markCardAsProcessed(card.id, 'deck');
|
|
} catch (error) {
|
|
console.error('Error adding card to deck:', error);
|
|
} finally {
|
|
endAdding(card.id);
|
|
}
|
|
};
|
|
|
|
const handleBulkAction = async (actionOverride, targetOverride) => {
|
|
const action = actionOverride ?? bulkAction;
|
|
const target = targetOverride ?? bulkTarget;
|
|
if (!action || selectedCards.size === 0) return [];
|
|
|
|
setIsProcessing(true);
|
|
const successfulIds = [];
|
|
try {
|
|
const cardsToProcess = scannedCards.filter((card) => selectedCards.has(card.id));
|
|
|
|
for (const card of cardsToProcess) {
|
|
if (action === 'owned') {
|
|
if (!tryBeginAdding(card.id)) continue;
|
|
try {
|
|
await addScannedCardToOwned(card);
|
|
successfulIds.push(card.id);
|
|
} catch (error) {
|
|
console.error('Error adding card to owned:', error);
|
|
} finally {
|
|
endAdding(card.id);
|
|
}
|
|
} else if (action === 'collection' && target) {
|
|
if (!tryBeginAdding(card.id)) continue;
|
|
try {
|
|
await addScannedCardToCollection(card, target);
|
|
successfulIds.push(card.id);
|
|
} catch (error) {
|
|
console.error('Error adding card to collection:', error);
|
|
} finally {
|
|
endAdding(card.id);
|
|
}
|
|
} else if (action === 'deck' && target) {
|
|
if (!tryBeginAdding(card.id)) continue;
|
|
try {
|
|
await addScannedCardToDeck(card, target);
|
|
successfulIds.push(card.id);
|
|
} catch (error) {
|
|
console.error('Error adding card to deck:', error);
|
|
} finally {
|
|
endAdding(card.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (successfulIds.length > 0) {
|
|
setScannedCards((prev) => prev.filter((card) => !successfulIds.includes(card.id)));
|
|
setSelectedCards((prev) => {
|
|
const next = new Set(prev);
|
|
successfulIds.forEach((id) => next.delete(id));
|
|
return next;
|
|
});
|
|
}
|
|
|
|
setBulkAction('');
|
|
setBulkTarget('');
|
|
} catch (error) {
|
|
console.error('Error processing bulk action:', error);
|
|
} finally {
|
|
setIsProcessing(false);
|
|
}
|
|
|
|
return successfulIds;
|
|
};
|
|
|
|
const commitSelectedToOwned = async () => handleBulkAction('owned');
|
|
const commitSelectedToCollection = async (collectionId) =>
|
|
handleBulkAction('collection', collectionId);
|
|
|
|
const unprocessedCount = scannedCards.filter((card) => !card.processed).length;
|
|
|
|
const createCollection = async () => {
|
|
if (!newCollectionName.trim()) return;
|
|
|
|
try {
|
|
const newCollection = await createScannerCollection(newCollectionName.trim());
|
|
setCollections((prev) => [newCollection, ...prev]);
|
|
setBulkTarget(newCollection.id.toString());
|
|
setNewCollectionName('');
|
|
setShowCreateCollection(false);
|
|
} catch (error) {
|
|
console.error('Error creating collection:', error);
|
|
}
|
|
};
|
|
|
|
const clearScannedCards = () => {
|
|
setScannedCards([]);
|
|
setSelectedCards(new Set());
|
|
};
|
|
|
|
const removeScannedCard = (cardId) => {
|
|
setScannedCards((prev) => prev.filter((card) => card.id !== cardId));
|
|
setSelectedCards((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(cardId);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const toggleCardSelection = (cardId) => {
|
|
setSelectedCards((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(cardId)) {
|
|
next.delete(cardId);
|
|
} else {
|
|
next.add(cardId);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const clearSelection = () => {
|
|
setSelectedCards(new Set());
|
|
};
|
|
|
|
return {
|
|
scannedCards,
|
|
collections,
|
|
decks,
|
|
selectedCards,
|
|
isProcessing,
|
|
addingCardIds,
|
|
autoRouteError,
|
|
showCreateCollection,
|
|
setShowCreateCollection,
|
|
newCollectionName,
|
|
setNewCollectionName,
|
|
handleCardScanned,
|
|
incrementCardQuantity,
|
|
decrementCardQuantity,
|
|
updateCardMetadata,
|
|
addSingleCardToOwned,
|
|
addSingleCardToCollection,
|
|
addSingleCardToDeck,
|
|
handleBulkAction,
|
|
commitSelectedToOwned,
|
|
commitSelectedToCollection,
|
|
unprocessedCount,
|
|
createCollection,
|
|
clearScannedCards,
|
|
removeScannedCard,
|
|
toggleCardSelection,
|
|
clearSelection,
|
|
undoCardProcess,
|
|
undoStack,
|
|
deckComplete,
|
|
ownershipMap,
|
|
};
|
|
}
|