diff --git a/web/src/components/CandidateCard.tsx b/web/src/components/CandidateCard.tsx
new file mode 100644
index 0000000..bef43a0
--- /dev/null
+++ b/web/src/components/CandidateCard.tsx
@@ -0,0 +1,26 @@
+import type { Candidate } from '../types.js'
+import Cover from './Cover.js'
+
+export default function CandidateCard({
+ candidate,
+ onSelect,
+}: {
+ candidate: Candidate
+ onSelect: (candidate: Candidate) => void
+}) {
+ const meta = [candidate.year, candidate.formats[0], candidate.labels[0]].filter(Boolean).join(' · ')
+ return (
+
+ )
+}
diff --git a/web/src/components/Cover.tsx b/web/src/components/Cover.tsx
new file mode 100644
index 0000000..f7fdbec
--- /dev/null
+++ b/web/src/components/Cover.tsx
@@ -0,0 +1,25 @@
+export default function Cover({
+ src,
+ alt,
+ className = 'size-16',
+}: {
+ src: string | null
+ alt: string
+ className?: string
+}) {
+ if (!src) {
+ return (
+
+
+
+ )
+ }
+ return
+}
diff --git a/web/src/pages/ScanPage.tsx b/web/src/pages/ScanPage.tsx
index 711e97d..28cf317 100644
--- a/web/src/pages/ScanPage.tsx
+++ b/web/src/pages/ScanPage.tsx
@@ -1,3 +1,201 @@
-export default function ScanPage() {
- return Scanner goes here.
+import { useCallback, useEffect, useReducer } from 'react'
+import { Link } from 'react-router-dom'
+import { api, ApiError } from '../api.js'
+import Scanner from '../components/Scanner.js'
+import CandidateCard from '../components/CandidateCard.js'
+import ConfirmView from '../scan/ConfirmView.js'
+import Cover from '../components/Cover.js'
+import { scanReducer, INITIAL_SCAN_STATE, type ScanErrorKind } from '../scan/reducer.js'
+
+function toErrorKind(err: unknown): ScanErrorKind {
+ if (err instanceof ApiError) {
+ if (err.code === 'not_found') return 'not_found'
+ if (err.code === 'no_discogs_token') return 'no_discogs_token'
+ if (err.code === 'discogs_rate_limited' || err.status === 429) return 'rate_limited'
+ }
+ return 'server'
+}
+
+export default function ScanPage() {
+ const [state, dispatch] = useReducer(scanReducer, INITIAL_SCAN_STATE)
+
+ const onDetect = useCallback((code: string) => {
+ dispatch({ type: 'DETECT', code })
+ void (async () => {
+ try {
+ const { candidates } = await api.lookupBarcode(code)
+ if (candidates.length === 0) {
+ dispatch({ type: 'NOT_FOUND', code })
+ } else {
+ dispatch({ type: 'CANDIDATES', code, candidates })
+ }
+ } catch (err) {
+ dispatch({ type: 'ERROR', kind: toErrorKind(err), code })
+ }
+ })()
+ }, [])
+
+ // Load the release preview once a candidate is selected.
+ useEffect(() => {
+ if (state.phase !== 'confirm' || state.preview) return
+ const candidateId = state.candidate.id
+ void (async () => {
+ try {
+ const preview = await api.getReleasePreview(candidateId)
+ dispatch({ type: 'PREVIEW', preview })
+ } catch (err) {
+ dispatch({ type: 'ERROR', kind: toErrorKind(err), code: null })
+ }
+ })()
+ }, [state])
+
+ const add = useCallback(() => {
+ if (state.phase !== 'confirm' || !state.preview || state.adding) return
+ const { candidate, code, matchAlbumId } = state
+ void (async () => {
+ try {
+ const item = await api.addToCollection({
+ releaseId: candidate.id,
+ ...(code ? { barcode: code } : {}),
+ ...(matchAlbumId !== null ? { matchAlbumId } : {}),
+ })
+ dispatch({ type: 'ADDED', item })
+ } catch (err) {
+ const message =
+ err instanceof ApiError
+ ? err.code === 'duplicate'
+ ? 'Already in your collection.'
+ : err.detail ?? err.code
+ : 'Something went wrong'
+ dispatch({ type: 'ADD_ERROR', message })
+ }
+ })()
+ }, [state])
+
+ return (
+
+ {state.phase === 'scan' &&
}
+
+ {state.phase === 'looking' && (
+
Looking up {state.code}…
+ )}
+
+ {state.phase === 'candidates' && (
+
+
Which release is it?
+ {state.candidates.map((c) => (
+
dispatch({ type: 'SELECT', candidate: cand })} />
+ ))}
+
+
+ )}
+
+ {state.phase === 'confirm' && (
+
+ {!state.preview &&
Checking release…
}
+ {state.preview && (
+
dispatch({ type: 'SET_MATCH', albumId })}
+ onClearMatch={() => dispatch({ type: 'CLEAR_MATCH' })}
+ onAdd={add}
+ adding={state.adding}
+ addError={state.addError}
+ />
+ )}
+
+
+ )}
+
+ {state.phase === 'added' && (
+
+
+
Added to collection ✓
+
+ {state.item.artist} — {state.item.title}
+
+
+
+
+ View item
+
+
+
+ )}
+
+ {state.phase === 'error' && state.kind === 'not_found' && (
+
+
Nothing found
+
+ Discogs has no release for barcode {state.code}. Older vinyl often isn't listed by barcode.
+
+
+ Search manually
+
+
+ )}
+
+ {state.phase === 'error' && state.kind === 'no_discogs_token' && (
+
+
Add your Discogs token first
+
+ Settings
+
+
+ )}
+
+ {state.phase === 'error' && state.kind === 'rate_limited' && (
+
+
Slow down
+
Discogs is rate limiting us. Try again in a moment.
+
+
+ )}
+
+ {state.phase === 'error' && state.kind === 'server' && (
+
+
Lookup failed
+
+
+ )}
+
+ )
}
diff --git a/web/src/scan/ConfirmView.tsx b/web/src/scan/ConfirmView.tsx
new file mode 100644
index 0000000..5ad1856
--- /dev/null
+++ b/web/src/scan/ConfirmView.tsx
@@ -0,0 +1,100 @@
+import type { ReleasePreview } from '../types.js'
+import Cover from '../components/Cover.js'
+
+export default function ConfirmView({
+ code,
+ preview,
+ matchAlbumId,
+ onSetMatch,
+ onClearMatch,
+ onAdd,
+ adding,
+ addError,
+}: {
+ code: string | null
+ preview: ReleasePreview
+ matchAlbumId: number | null
+ onSetMatch: (albumId: number) => void
+ onClearMatch: () => void
+ onAdd: () => void
+ adding: boolean
+ addError: string | null
+}) {
+ const { release, duplicate, ripMatch, matchCandidates } = preview
+ return (
+
+
+
+
+
{release.title}
+
{release.artist}
+
+ {[release.year, release.formats.join(', '), release.labels[0], release.catno]
+ .filter(Boolean)
+ .join(' · ')}
+
+ {code &&
Barcode {code}
}
+
+
+
+ {ripMatch === 'ripped' && (
+
+ In your digital collection ✓
+
+ )}
+ {ripMatch === 'not_ripped' && (
+
Not ripped yet
+ )}
+ {ripMatch === 'ambiguous' && (
+
+ )}
+
+ {duplicate && (
+
+ Heads up: this release is already in your collection.
+
+ )}
+
+
+ Tracklist
+
+ {release.tracklist.map((t, i) => (
+ -
+ {t.position}
+ {t.title}
+
+ ))}
+
+
+
+ {addError &&
{addError}
}
+
+
+ )
+}
diff --git a/web/test/scanPage.test.tsx b/web/test/scanPage.test.tsx
new file mode 100644
index 0000000..1aeb307
--- /dev/null
+++ b/web/test/scanPage.test.tsx
@@ -0,0 +1,161 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import ScanPage from '../src/pages/ScanPage.js'
+import type { Candidate, ReleasePreview } from '../src/types.js'
+
+vi.mock('../src/components/Scanner.js', () => ({
+ default: ({ onDetect }: { onDetect: (code: string) => void }) => (
+
+ ),
+}))
+
+vi.mock('../src/api.js', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ api: {
+ ...actual.api,
+ lookupBarcode: vi.fn(),
+ getReleasePreview: vi.fn(),
+ addToCollection: vi.fn(),
+ },
+ }
+})
+
+import { api, ApiError } from '../src/api.js'
+
+const candidate: Candidate = {
+ id: 1001,
+ artist: 'The Cinematic Orchestra',
+ title: 'Motion',
+ year: 1999,
+ formats: ['CD'],
+ labels: ['Ninja Tune'],
+ country: 'UK',
+ catno: 'ZENCD012',
+ thumbUrl: null,
+}
+
+const preview: ReleasePreview = {
+ release: { ...candidate, genres: [], tracklist: [{ position: '1', title: 'Overture' }], coverUrl: null, barcodes: ['5021592210629'] },
+ duplicate: false,
+ ripMatch: 'not_ripped',
+ matchCandidates: [],
+}
+
+// The api module is mocked directly, so mocks resolve with parsed bodies.
+function jsonOk(body: unknown) {
+ return Promise.resolve(body)
+}
+
+beforeEach(() => {
+ vi.mocked(api.lookupBarcode).mockReset()
+ vi.mocked(api.getReleasePreview).mockReset()
+ vi.mocked(api.addToCollection).mockReset()
+})
+
+function renderScan() {
+ return render(
+
+
+
+ )
+}
+
+describe('ScanPage flow', () => {
+ it('scan → candidates → confirm → added', async () => {
+ vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
+ vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk(preview) as never)
+ vi.mocked(api.addToCollection).mockResolvedValue(
+ jsonOk({
+ id: 1,
+ discogsReleaseId: 1001,
+ title: 'Motion',
+ artist: 'The Cinematic Orchestra',
+ year: 1999,
+ formats: ['CD'],
+ genres: [],
+ labels: ['Ninja Tune'],
+ tracklist: [],
+ catno: 'ZENCD012',
+ country: 'UK',
+ artworkUrl: null,
+ barcodes: ['5021592210629'],
+ dateAdded: '2026-08-29',
+ ripOverride: null,
+ ripStatus: 'not_ripped',
+ }) as never
+ )
+
+ renderScan()
+ await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
+
+ await waitFor(() => expect(screen.getByRole('button', { name: /motion/i })).toBeTruthy())
+ await userEvent.click(screen.getByRole('button', { name: /motion/i }))
+
+ await waitFor(() => expect(screen.getByText('Not ripped yet')).toBeTruthy())
+ expect(screen.getByText('Overture')).toBeTruthy()
+
+ await userEvent.click(screen.getByRole('button', { name: /add to collection/i }))
+ await waitFor(() => expect(screen.getByText(/added to collection/i)).toBeTruthy())
+ expect(api.addToCollection).toHaveBeenCalledWith({
+ releaseId: 1001,
+ barcode: '5021592210629',
+ })
+ })
+
+ it('shows not-found guidance with a manual-search escape hatch', async () => {
+ vi.mocked(api.lookupBarcode).mockRejectedValue(new ApiError(404, 'not_found'))
+ renderScan()
+ await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
+ await waitFor(() => expect(screen.getByText(/nothing found/i)).toBeTruthy())
+ expect(screen.getByRole('link', { name: /search manually/i }).getAttribute('href')).toBe(
+ '/add?q=5021592210629'
+ )
+ })
+
+ it('sends the picked match candidate for ambiguous rip matches', async () => {
+ vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
+ vi.mocked(api.getReleasePreview).mockResolvedValue(
+ jsonOk({
+ ...preview,
+ ripMatch: 'ambiguous',
+ matchCandidates: [{ id: 77, title: 'Motion', artist: 'Somebody Else' }],
+ }) as never
+ )
+ vi.mocked(api.addToCollection).mockResolvedValue(jsonOk({}) as never)
+
+ renderScan()
+ await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
+ await userEvent.click(await screen.findByRole('button', { name: /motion/i }))
+ await waitFor(() => expect(screen.getByRole('radio', { name: /somebody else/i })).toBeTruthy())
+ await userEvent.click(screen.getByRole('radio', { name: /somebody else/i }))
+ await userEvent.click(screen.getByRole('button', { name: /add to collection/i }))
+ await waitFor(() => expect(api.addToCollection).toHaveBeenCalledWith({
+ releaseId: 1001,
+ barcode: '5021592210629',
+ matchAlbumId: 77,
+ }))
+ })
+
+ it('shows a duplicate warning from the preview', async () => {
+ vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
+ vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk({ ...preview, duplicate: true }) as never)
+ renderScan()
+ await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
+ await userEvent.click(await screen.findByRole('button', { name: /motion/i }))
+ await waitFor(() => expect(screen.getByText(/already in your collection/i)).toBeTruthy())
+ })
+
+ it('links to settings when no discogs token is configured', async () => {
+ vi.mocked(api.lookupBarcode).mockRejectedValue(new ApiError(409, 'no_discogs_token'))
+ renderScan()
+ await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
+ await waitFor(() => expect(screen.getByText(/discogs token/i)).toBeTruthy())
+ expect(screen.getByRole('link', { name: /settings/i }).getAttribute('href')).toBe('/settings')
+ })
+})