Skip to content

fix(frontend): scanner — add camera permission recovery and scan feedback (#702)#745

Merged
ericsocrat merged 5 commits intomainfrom
fix/702-scanner-recovery
Mar 8, 2026
Merged

fix(frontend): scanner — add camera permission recovery and scan feedback (#702)#745
ericsocrat merged 5 commits intomainfrom
fix/702-scanner-recovery

Conversation

@ericsocrat
Copy link
Owner

Summary

Closes #702 — Adds camera permission recovery UI, differentiated error cards, haptic feedback, and i18n fixes to the barcode scanner page.

Changes

Camera Error Recovery (typed error states)

  • New type: CameraErrorKind = "permission-denied" | "no-camera" | "generic" replaces the untyped string | null error state
  • Differentiated error cards:
    • No camera: CameraOff icon + "No Camera Available" title + hint about manual entry. No Retry button (retrying won't help).
    • Permission denied: ShieldAlert icon + "Camera Access Blocked" title + instructions to allow access in browser settings + Retry button
    • Generic error: CameraOff icon + generic error message + Retry button

Bug Fix — Error card was invisible

  • Root cause: setMode("manual") was called immediately after setCameraError(...), so mode changed away from "camera" and the error card (rendered only in camera mode) was never visible
  • Fix: Removed setMode("manual") from both the no-camera branch and the catch block, keeping the user in camera mode so the recovery UI is shown

Haptic Feedback

  • Added navigator.vibrate(100) on successful barcode scan (in scanMutation.onSuccess)

i18n

  • Replaced hardcoded "Batch: {N} scanned" with t("scan.scannedCount", { count })
  • Added 6 new i18n keys across all 3 languages (en, pl, de): noCameraTitle, noCameraHint, cameraBlocked, cameraBlockedHint, retryCamera, scannedCount

Test Infrastructure Fix

  • Root cause of 3 test failures: ZXing mock used an arrow function in vi.fn().mockImplementation(() => ({...})). Arrow functions cannot be used as constructors. When the component loads ZXing via dynamic import (await import("@zxing/library")) and calls new BrowserMultiFormatReader(), the arrow function threw TypeError: ... is not a constructor
  • Fix: Replaced arrow function mock with a proper MockBrowserMultiFormatReader class using hoisted mock variables (mockListDevices, mockDecode, mockReaderReset)

Tests

4 new tests (34 total, all passing):

  1. No-camera error card — verifies correct title, hint text, and no Retry button when listVideoInputDevices returns []
  2. Permission-denied error card — verifies "Camera Access Blocked" title, hint, and Retry button when decodeFromVideoDevice throws NotAllowedError
  3. Retry camera button — clicks Retry and verifies listVideoInputDevices is called again
  4. Haptic feedback — verifies navigator.vibrate(100) called on successful scan

Verification

npx vitest run src/app/app/scan/page.test.tsx  → 34/34 pass
npx tsc --noEmit                                → 0 errors

Files Changed

File Change
frontend/src/app/app/scan/page.tsx Typed error states, recovery UI, haptic, bug fix
frontend/src/app/app/scan/page.test.tsx 4 new tests, ZXing mock fix (class constructor)
frontend/messages/en.json +6 i18n keys
frontend/messages/pl.json +6 i18n keys
frontend/messages/de.json +6 i18n keys

…back (#702)

- Add typed CameraErrorKind (permission-denied | no-camera | generic)
- Show differentiated error cards with icons, hints, and Retry button
- Fix bug: remove setMode('manual') so error cards stay visible
- Add haptic feedback (navigator.vibrate) on successful scan
- Replace hardcoded English with i18n keys (scannedCount, cameraBlocked, etc.)
- Add 4 new tests: no-camera card, permission-denied card, retry button, haptic
- Fix ZXing mock: use class constructor for dynamic import compatibility
Copilot AI review requested due to automatic review settings March 7, 2026 23:52
@vercel
Copy link

vercel bot commented Mar 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tryvit Ready Ready Preview, Comment Mar 8, 2026 9:46am

@chatgpt-codex-connector
Copy link

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the frontend barcode scanner experience by adding typed camera error recovery states (with differentiated UI), haptic feedback on scan success, and i18n for batch scan count and camera recovery messaging, along with updated test mocks to support ZXing dynamic imports.

Changes:

  • Introduces CameraErrorKind and a recovery UI with distinct cards for no-camera vs permission-denied vs generic camera errors.
  • Adds navigator.vibrate(100) on successful scan and replaces hardcoded batch text with t("scan.scannedCount", { count }).
  • Fixes ZXing test mocking by replacing the arrow-function “constructor” with a real class and adds new recovery/feedback tests.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
frontend/src/app/app/scan/page.tsx Typed camera error states, recovery card UI, retry behavior, haptic feedback, and i18n batch count usage.
frontend/src/app/app/scan/page.test.tsx ZXing mock rewritten as a constructible class; adds tests for camera recovery and vibration feedback.
frontend/messages/en.json Adds new scan-related i18n strings for recovery messaging.
frontend/messages/pl.json Adds new scan-related i18n strings for recovery messaging.
frontend/messages/de.json Adds new scan-related i18n strings for recovery messaging.

Comment on lines 211 to 227
} catch (err: unknown) {
let errName = "";
if (err instanceof Error) {
errName = err.name;
} else if (err && typeof err === "object" && "name" in err) {
errName = String(err.name);
}
if (
errName === "NotAllowedError" ||
errName === "PermissionDeniedError"
) {
setCameraError(t("scan.permissionDenied"));
setCameraError("permission-denied");
showToast({ type: "error", messageKey: "scan.permissionDenied" });
} else {
setCameraError(t("scan.cameraError"));
setCameraError("generic");
}
setMode("manual");
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startScanner doesn’t clean up if an exception is thrown after creating the ZXing reader (e.g., if decodeFromVideoDevice throws synchronously). That can leave readerRef (and potentially an opened stream) in an inconsistent state. Suggest calling stopScanner() inside the catch (or using try { ... } catch { ... } finally { ... } to reset refs / stop tracks on failure) before setting cameraError.

Copilot uses AI. Check for mistakes.
Comment on lines +499 to +505
<p className="mt-1 text-xs text-amber-700">
{cameraError === "no-camera"
? t("scan.noCameraHint")
: cameraError === "permission-denied"
? t("scan.cameraBlockedHint")
: t("scan.cameraError")}
</p>
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the generic camera error UI, both the title and hint fall back to t("scan.cameraError"). Since scan.cameraError is a full sentence (e.g. "Could not start camera. Try manual entry instead."), this currently renders the same sentence twice. Consider adding distinct i18n keys for a short title vs. the longer hint (or reuse an existing title-style key) so the card isn’t duplicated.

Suggested change
<p className="mt-1 text-xs text-amber-700">
{cameraError === "no-camera"
? t("scan.noCameraHint")
: cameraError === "permission-denied"
? t("scan.cameraBlockedHint")
: t("scan.cameraError")}
</p>
{cameraError !== "generic" && (
<p className="mt-1 text-xs text-amber-700">
{cameraError === "no-camera"
? t("scan.noCameraHint")
: t("scan.cameraBlockedHint")}
</p>
)}

Copilot uses AI. Check for mistakes.
Comment on lines +718 to +747
const vibrateSpy = vi.fn();
Object.defineProperty(navigator, "vibrate", {
value: vibrateSpy,
writable: true,
configurable: true,
});

mockRecordScan.mockResolvedValue({
ok: true,
data: {
found: true,
product_id: 42,
product_name: "Vibrate Test",
brand: "TestBrand",
nutri_score: "B",
},
});
const user = userEvent.setup();

render(<ScanPage />, { wrapper: createWrapper() });
await user.click(screen.getByText("Manual"));
await user.type(
screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"),
"5901234123457",
);
await user.click(screen.getByText("Look up"));

await waitFor(() => {
expect(vibrateSpy).toHaveBeenCalledWith(100);
});
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test defines navigator.vibrate via Object.defineProperty but never restores the original value. Since Vitest may reuse the same jsdom environment across test files, this can leak global state into other tests. Store the previous navigator.vibrate and restore it in a finally/afterEach, or use vi.stubGlobal('navigator', ...) + vi.unstubAllGlobals() cleanup.

Suggested change
const vibrateSpy = vi.fn();
Object.defineProperty(navigator, "vibrate", {
value: vibrateSpy,
writable: true,
configurable: true,
});
mockRecordScan.mockResolvedValue({
ok: true,
data: {
found: true,
product_id: 42,
product_name: "Vibrate Test",
brand: "TestBrand",
nutri_score: "B",
},
});
const user = userEvent.setup();
render(<ScanPage />, { wrapper: createWrapper() });
await user.click(screen.getByText("Manual"));
await user.type(
screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"),
"5901234123457",
);
await user.click(screen.getByText("Look up"));
await waitFor(() => {
expect(vibrateSpy).toHaveBeenCalledWith(100);
});
const originalVibrateDescriptor = Object.getOwnPropertyDescriptor(
navigator,
"vibrate",
);
const vibrateSpy = vi.fn();
Object.defineProperty(navigator, "vibrate", {
value: vibrateSpy,
writable: true,
configurable: true,
});
try {
mockRecordScan.mockResolvedValue({
ok: true,
data: {
found: true,
product_id: 42,
product_name: "Vibrate Test",
brand: "TestBrand",
nutri_score: "B",
},
});
const user = userEvent.setup();
render(<ScanPage />, { wrapper: createWrapper() });
await user.click(screen.getByText("Manual"));
await user.type(
screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"),
"5901234123457",
);
await user.click(screen.getByText("Look up"));
await waitFor(() => {
expect(vibrateSpy).toHaveBeenCalledWith(100);
});
} finally {
if (originalVibrateDescriptor) {
Object.defineProperty(navigator, "vibrate", originalVibrateDescriptor);
} else {
Reflect.deleteProperty(navigator, "vibrate");
}
}

Copilot uses AI. Check for mistakes.
<button
onClick={() => {
setCameraError(null);
setMode("camera");
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Retry Camera handler calls startScanner() without stopping any existing reader/stream first. If the previous attempt partially initialized (or readerRef.current is still set from an earlier run), this can result in multiple readers/streams or leaked resources. Consider calling stopScanner() before startScanner() here, or making startScanner() itself always stop/reset any existing scanner before creating a new reader.

Suggested change
setMode("camera");
setMode("camera");
stopScanner();

Copilot uses AI. Check for mistakes.
@@ -170,8 +177,7 @@

const devices = await reader.listVideoInputDevices();
if (devices.length === 0) {
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In startScanner, if listVideoInputDevices() returns an empty list, the function returns after setting readerRef.current = reader. That leaves the reader instance hanging around (and makes future restarts harder to reason about). Consider calling stopScanner() (or at least reader.reset() + clearing readerRef.current) before returning in the devices.length === 0 branch.

Suggested change
if (devices.length === 0) {
if (devices.length === 0) {
reader.reset();
readerRef.current = null;

Copilot uses AI. Check for mistakes.
@github-actions
Copy link

github-actions bot commented Mar 7, 2026

Bundle Size Report

Metric Value
Main baseline 3467 KB
This PR 3464 KB
Delta -3 KB (-0.1%)
JS chunks 135
Hard limit 4000 KB

✅ Bundle size is within acceptable limits.

Copilot AI review requested due to automatic review settings March 8, 2026 09:09
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

@@ -170,8 +177,7 @@ export default function ScanPage() {

const devices = await reader.listVideoInputDevices();
if (devices.length === 0) {
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When devices.length === 0, readerRef.current has already been set to a BrowserMultiFormatReader, but the function returns without resetting/cleaning it up. Consider calling stopScanner() before setting the error and returning so the reader state is cleared consistently.

Suggested change
if (devices.length === 0) {
if (devices.length === 0) {
stopScanner();

Copilot uses AI. Check for mistakes.
Comment on lines +507 to +514
<button
onClick={() => {
setCameraError(null);
setMode("camera");
startScanner();
}}
className="btn-secondary mt-3"
>
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retry starts a new scanner instance without explicitly stopping any existing reader/stream first. If a previous attempt left a stream active (or readerRef.current set), this can lead to multiple active instances or 'camera already in use' failures. Consider calling stopScanner() before startScanner() here.

Copilot uses AI. Check for mistakes.
Comment on lines +891 to +920
const vibrateSpy = vi.fn();
Object.defineProperty(navigator, "vibrate", {
value: vibrateSpy,
writable: true,
configurable: true,
});

mockRecordScan.mockResolvedValue({
ok: true,
data: {
found: true,
product_id: 42,
product_name: "Vibrate Test",
brand: "TestBrand",
nutri_score: "B",
},
});
const user = userEvent.setup();

render(<ScanPage />, { wrapper: createWrapper() });
await user.click(screen.getByText("Manual"));
await user.type(
screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"),
"5901234123457",
);
await user.click(screen.getByText("Look up"));

await waitFor(() => {
expect(vibrateSpy).toHaveBeenCalledWith(100);
});
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test mutates the global navigator.vibrate and doesn't restore it afterwards, which can leak state into subsequent tests. Consider saving the original descriptor/value and restoring (or deleting) it in a cleanup step (e.g., finally/afterEach) to keep tests isolated.

Suggested change
const vibrateSpy = vi.fn();
Object.defineProperty(navigator, "vibrate", {
value: vibrateSpy,
writable: true,
configurable: true,
});
mockRecordScan.mockResolvedValue({
ok: true,
data: {
found: true,
product_id: 42,
product_name: "Vibrate Test",
brand: "TestBrand",
nutri_score: "B",
},
});
const user = userEvent.setup();
render(<ScanPage />, { wrapper: createWrapper() });
await user.click(screen.getByText("Manual"));
await user.type(
screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"),
"5901234123457",
);
await user.click(screen.getByText("Look up"));
await waitFor(() => {
expect(vibrateSpy).toHaveBeenCalledWith(100);
});
const originalVibrateDescriptor = Object.getOwnPropertyDescriptor(
navigator,
"vibrate",
);
const vibrateSpy = vi.fn();
try {
Object.defineProperty(navigator, "vibrate", {
value: vibrateSpy,
writable: true,
configurable: true,
});
mockRecordScan.mockResolvedValue({
ok: true,
data: {
found: true,
product_id: 42,
product_name: "Vibrate Test",
brand: "TestBrand",
nutri_score: "B",
},
});
const user = userEvent.setup();
render(<ScanPage />, { wrapper: createWrapper() });
await user.click(screen.getByText("Manual"));
await user.type(
screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"),
"5901234123457",
);
await user.click(screen.getByText("Look up"));
await waitFor(() => {
expect(vibrateSpy).toHaveBeenCalledWith(100);
});
} finally {
if (originalVibrateDescriptor) {
Object.defineProperty(navigator, "vibrate", originalVibrateDescriptor);
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (navigator as any).vibrate;
}
}

Copilot uses AI. Check for mistakes.
}
setMode("manual");
}
}, [stopScanner, t]);
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

t is included in the startScanner dependency list, but within the shown code the callback no longer uses t. Removing unused dependencies keeps the callback stable and can avoid unnecessary re-creations (and potential effect re-runs) as translations change.

Copilot uses AI. Check for mistakes.
The component no longer auto-switches to manual mode when no camera
is found — it stays in camera mode and shows an error card. The test
must explicitly switch to manual first, then back to camera.
@ericsocrat ericsocrat merged commit 3965b5c into main Mar 8, 2026
14 of 16 checks passed
@ericsocrat ericsocrat deleted the fix/702-scanner-recovery branch March 8, 2026 09:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(frontend): scanner — add camera permission recovery and scan feedback

2 participants