Production runtime ReferenceError on the scan flow:
ReferenceError: useFocusTrap is not defined
at ScanDisambiguationDialog (...)
`components/ScanDisambiguationDialog.js` calls `useFocusTrap(...)` on
line 14 but never imports it. The file previously had `import { useRef
} from 'react'` (unused since the focus-trap refactor in PR #67
`071a3dc`); the `useFocusTrap` import was never added when the hook
was introduced. The bug only manifests at runtime when the scan flow
reaches a disambiguation result (i.e. when the OCR/match returns
multiple candidate cards), which is why neither smoke nor visual diff
caught it — the dialog never renders in the homepage smoke path.
Lint also missed it because the project's flat ESLint config does NOT
extend `eslint:recommended` / `@eslint/js`'s `no-undef` rule. The
`react/jsx-no-undef` rule catches undefined JSX components but not
plain JS identifier references. Hardening that gap is the queued
`enable-no-undef-eslint-rule` follow-up — out of scope for this hotfix.
Replaces the dead `useRef` import with the missing `useFocusTrap`
named import from `lib/use-focus-trap.js` (parallel to how
`components/ui/Modal.js` imports `useFocusTrapContainer` as the
default export — see Modal.js:3 + use-focus-trap.js:21 / :87 for the
two-export shape).
Test plan:
- [x] `npm run lint` — clean (1 pre-existing unrelated warning)
- [x] `npm run test:run` — 123 tests pass (24 files)
- [ ] CI on this PR
- [ ] Post-merge: re-trigger a scan that disambiguates (ambiguous OCR
hit) and confirm the modal renders without console errors.
Co-authored-by: Cursor <cursoragent@cursor.com>
82 lines
3.3 KiB
JavaScript
82 lines
3.3 KiB
JavaScript
/* eslint-disable @next/next/no-img-element -- external Scryfall/card CDN URLs */
|
|
import { useFocusTrap } from '../lib/use-focus-trap';
|
|
|
|
/**
|
|
* Modal for picking among multiple catalog matches after a scan.
|
|
*/
|
|
export default function ScanDisambiguationDialog({
|
|
disambiguation,
|
|
submittingReview,
|
|
onPick,
|
|
onNotInCatalog,
|
|
onCancel,
|
|
}) {
|
|
const dialogRef = useFocusTrap(Boolean(disambiguation));
|
|
|
|
if (!disambiguation) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4">
|
|
<div
|
|
ref={dialogRef}
|
|
className="glass-panel-strong max-w-lg w-full rounded-xl p-6 max-h-[80vh] overflow-y-auto"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="disambiguation-title"
|
|
>
|
|
<h3 id="disambiguation-title" className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
Which card is this?
|
|
</h3>
|
|
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
|
|
{disambiguation.message || 'Multiple matches found. Select the correct printing.'}
|
|
</p>
|
|
{disambiguation.visionHint && (
|
|
<p className="text-xs mb-3 px-2 py-1 rounded" style={{ color: 'var(--accent-ember)', backgroundColor: 'var(--bg-tertiary)' }}>
|
|
Vision detected set: {disambiguation.visionHint}
|
|
</p>
|
|
)}
|
|
<div className="space-y-2">
|
|
{disambiguation.candidates.map((candidate) => (
|
|
<button
|
|
type="button"
|
|
key={candidate.id}
|
|
onClick={() => onPick(candidate)}
|
|
className="w-full flex items-center gap-3 p-3 rounded-lg border text-left hover:opacity-90"
|
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-tertiary)' }}
|
|
aria-label={`Select ${candidate.name}${candidate.set_name ? `, ${candidate.set_name}` : ''}`}
|
|
>
|
|
{candidate.image_url ? (
|
|
<img src={candidate.image_url} alt="" className="w-12 h-16 object-cover rounded" />
|
|
) : (
|
|
<div className="glass-panel w-12 h-16 rounded flex items-center justify-center text-xs">🃏</div>
|
|
)}
|
|
<div>
|
|
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>{candidate.name}</div>
|
|
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
{[candidate.set_name, candidate.set_code, candidate.card_number].filter(Boolean).join(' · ')}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onNotInCatalog}
|
|
disabled={submittingReview}
|
|
className="mt-4 w-full py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
|
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
|
|
>
|
|
{submittingReview ? 'Submitting…' : "My card isn't listed — send for review"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="mt-2 w-full py-2 rounded-lg border text-sm"
|
|
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|