feat(scan): standalone delivery-note scanner PWA at /scan
Scan the QR printed on a delivery note, see what it is, and act on it: reprint, photograph the signed copy, or walk it toward invoicing. It is its own route group outside `(app)` so it gets a clean full-bleed layout and its own manifest — "add to home screen" should give the operator a scanner, not the CRM. Mirrors the pattern `(counter)` already uses. Two detectors, not one. `BarcodeDetector` is fast and allocation-free but is Chrome-only: Safari/iOS still has it "Under Consideration" (WebKit bug #254573) and Firefox does not ship it. A scanner that only used it would simply not work on an iPhone. So native where it exists, jsQR everywhere else — pure JS, so no 2 MB WASM payload on a weak warehouse signal, and the delivery-note QR is always a QR code. Both run against the same canvas frame so the caller never branches. Also requires HTTPS, which the page says plainly when it isn't. The QR carries the human number, so the first call is a new resolve endpoint mapping number -> PB id. Everything after that speaks ids, which is what every existing route already takes. The number is shape-validated before any network call, so a supplier's packing label can't fire a lookup. STATUS LADDER. mark-invoiced hard-refuses anything that isn't po_received, and customer-po refuses a note that isn't sent/loaded — so rather than offering a button that 500s, each action only appears when the server would accept it, and the card shows which step comes next. It routes draft -> loaded -> po_received -> invoiced via the existing load-dn, NOT draft -> sent, because draft -> sent means "create a quote and email it" and a warehouse scan must never trigger that silently. The signed-copy upload deliberately uses the upload-only endpoint, not /api/v1/deliveries/mark-signed-copy — that one flips the note to `completed` in the same write, which would jump a note straight past invoicing. Manual entry is always available, so the page works on a device with no camera or a browser that won't scan. Tests decode the REAL delivery-note QR through the production fallback path at print size, and assert the shape check rejects a supplier QR, a random string and an over-long number.
This commit is contained in:
parent
f64311b08c
commit
d98c263f45
7 changed files with 1090 additions and 0 deletions
27
public/manifest-scan.json
Normal file
27
public/manifest-scan.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "WFDASH Delivery Scanner",
|
||||
"short_name": "DN Scanner",
|
||||
"description": "Scan the QR on a delivery note to reprint it, capture the signed copy, or move it to ready to invoice",
|
||||
"start_url": "/scan",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#FFFFFF",
|
||||
"theme_color": "#4C96D7",
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"categories": ["business", "productivity", "utilities"],
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
101
src/app/api/v1/delivery-notes/resolve/route.ts
Normal file
101
src/app/api/v1/delivery-notes/resolve/route.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { assertBrowserSession } from "@/lib/server/browser-session";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/delivery-notes/resolve?number=DN26100401
|
||||
*
|
||||
* Turns a scanned delivery note NUMBER into the record the rest of the
|
||||
* API speaks in (a 15-char PB id).
|
||||
*
|
||||
* The QR on the printed note carries the human number, because that is
|
||||
* what the operator reads out and what the paper already shows. Every
|
||||
* existing route — detail, signed-copy, mark-invoiced, customer-po —
|
||||
* is keyed on the PB id, so the scanner needs this hop. It is the only
|
||||
* place a number becomes an id, which keeps that translation in one
|
||||
* auditable spot instead of spreading a guess-the-key lookup over the
|
||||
* client and every action button.
|
||||
*
|
||||
* Returns a compact summary rather than the full detail read model:
|
||||
* the scanner card needs the header, the line count and the status (to
|
||||
* decide which ladder step to offer), not the quote history.
|
||||
*
|
||||
* Errors:
|
||||
* - 401 no browser session
|
||||
* - 400 missing / malformed `number`
|
||||
* - 404 no delivery note with that number
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
await assertBrowserSession(req);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unauthorized";
|
||||
return NextResponse.json({ error: message }, { status: 401 });
|
||||
}
|
||||
|
||||
const raw = (req.nextUrl.searchParams.get("number") ?? "").trim().toUpperCase();
|
||||
if (!raw) {
|
||||
return NextResponse.json({ error: "number is required" }, { status: 400 });
|
||||
}
|
||||
// `DN` + 8-10 digits. Matches the validation in
|
||||
// `src/lib/scan/qr-detect.ts`; the scanner checks first, this is the
|
||||
// server-side belt so a hand-rolled request can't put a PB filter
|
||||
// string in front of an unvalidated value.
|
||||
if (!/^DN\d{8,10}$/.test(raw)) {
|
||||
return NextResponse.json(
|
||||
{ error: `"${raw}" is not a delivery note number (expected DN followed by 8-10 digits).` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const pb = await getAdminPocketBase();
|
||||
// The number is regex-validated to DN + digits only, so it cannot
|
||||
// contain a quote or a PB filter operator.
|
||||
// Same collection name the service uses; it is a module-local
|
||||
// const there and not exported, so we repeat the literal.
|
||||
const page = await pb.collection("delivery_notes").getList<Record<string, unknown>>(
|
||||
1,
|
||||
1,
|
||||
{
|
||||
filter: `delivery_note_number = "${raw}"`,
|
||||
expand: "customer,company",
|
||||
},
|
||||
);
|
||||
const dn = page.items[0];
|
||||
if (!dn) {
|
||||
return NextResponse.json(
|
||||
{ error: `No delivery note found for ${raw}.` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const customer = (dn as { expand?: { customer?: { customer?: string } } }).expand?.customer;
|
||||
const company = (dn as { expand?: { company?: { company?: string } } }).expand?.company;
|
||||
const itemCount = await pb
|
||||
.collection("order_items")
|
||||
.getList(1, 1, { filter: `delivery_note_number = "${raw}"` })
|
||||
.then((p) => p.totalItems ?? p.items.length)
|
||||
.catch(() => 0);
|
||||
|
||||
return NextResponse.json({
|
||||
id: dn.id,
|
||||
delivery_note_number: dn.delivery_note_number,
|
||||
status: dn.status,
|
||||
customer_name: customer?.customer ?? "",
|
||||
company_name: company?.company ?? "",
|
||||
customer_po: dn.customer_po ?? "",
|
||||
delivery_date: dn.delivery_date ?? "",
|
||||
signed_copy: Array.isArray(dn.signed_copy) ? dn.signed_copy[0] ?? "" : "",
|
||||
item_count: itemCount,
|
||||
created: dn.created,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to resolve delivery note";
|
||||
const status = (error as Error & { status?: number }).status || 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
24
src/app/scan/layout.tsx
Normal file
24
src/app/scan/layout.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { Metadata, Viewport } from "next";
|
||||
|
||||
/**
|
||||
* /scan is its own route group (outside `(app)`) so it gets a
|
||||
* dedicated PWA manifest — "add to home screen" should give the
|
||||
* operator a scanner, not the full CRM. Mirrors the pattern already
|
||||
* used by `(counter)` for `/manifest-counter.json`.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: "DN Scanner · WFDASH",
|
||||
manifest: "/manifest-scan.json",
|
||||
applicationName: "WFDASH DN Scanner",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: "#4C96D7",
|
||||
};
|
||||
|
||||
export default function ScanLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
549
src/app/scan/page.tsx
Normal file
549
src/app/scan/page.tsx
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* /scan — delivery-note scanner.
|
||||
*
|
||||
* Standalone (outside the `(app)` route group) so it gets a clean
|
||||
* full-bleed layout: a scanner has no business in a CRM sidebar, and
|
||||
* the operator using it is often holding a phone.
|
||||
*
|
||||
* Flow: point the camera at the QR printed on a delivery note → the
|
||||
* note is resolved → offer the three things you actually do at the
|
||||
* counter: reprint, photograph the signed copy, or move it toward
|
||||
* invoicing.
|
||||
*
|
||||
* The QR carries the human number (`DN26100401`), so the first call is
|
||||
* `resolve`, which maps number → PB id. Everything after that speaks
|
||||
* ids, which is what every existing route already takes.
|
||||
*
|
||||
* STATUS LADDER — the actions offered depend on where the note sits.
|
||||
* `mark-invoiced` hard-refuses anything that isn't `po_received`, and
|
||||
* `customer-po` refuses a note that isn't `sent`/`loaded`. So rather
|
||||
* than offering a button that 500s, each step only appears when it is
|
||||
* legal, and the card shows which one comes next:
|
||||
*
|
||||
* draft ──load──▶ loaded ──PO──▶ po_received ──invoice──▶ invoiced
|
||||
* └─(sent via quote)──▶ sent ──PO──▶ po_received ──▶ ...
|
||||
*
|
||||
* `loaded` is used rather than `sent` because `draft → sent` means
|
||||
* "create a quote and email it" — a side effect a warehouse scan must
|
||||
* never trigger silently. `load-dn` is the honest "the van has this
|
||||
* paperwork" step.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Camera,
|
||||
CameraOff,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Loader2,
|
||||
QrCode,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import getPocketBase from "@/lib/pocketbase";
|
||||
import {
|
||||
detectScannerSupport,
|
||||
parseDeliveryNoteNumber,
|
||||
scanVideoFrames,
|
||||
type QrDetectorKind,
|
||||
} from "@/lib/scan/qr-detect";
|
||||
|
||||
type Resolved = {
|
||||
id: string;
|
||||
delivery_note_number: string;
|
||||
status: string;
|
||||
customer_name: string;
|
||||
company_name: string;
|
||||
customer_po: string;
|
||||
delivery_date: string;
|
||||
signed_copy: string;
|
||||
item_count: number;
|
||||
};
|
||||
|
||||
type Toast = { kind: "ok" | "err"; text: string } | null;
|
||||
|
||||
const STATUS_COPY: Record<string, string> = {
|
||||
draft: "Draft — not yet loaded",
|
||||
sent: "Sent to customer",
|
||||
loaded: "Loaded — with the driver",
|
||||
po_received: "Customer PO received",
|
||||
invoiced: "Invoiced",
|
||||
completed: "Completed",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
|
||||
export default function ScanPage() {
|
||||
const videoRef = React.useRef<HTMLVideoElement | null>(null);
|
||||
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
|
||||
const streamRef = React.useRef<MediaStream | null>(null);
|
||||
const abortRef = React.useRef<AbortController | null>(null);
|
||||
const fileRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [support, setSupport] = React.useState<{
|
||||
kind: QrDetectorKind;
|
||||
camera: boolean;
|
||||
secureContext: boolean;
|
||||
} | null>(null);
|
||||
const [cameraOn, setCameraOn] = React.useState(false);
|
||||
const [manual, setManual] = React.useState("");
|
||||
const [note, setNote] = React.useState<Resolved | null>(null);
|
||||
const [busy, setBusy] = React.useState<string | null>(null);
|
||||
const [toast, setToast] = React.useState<Toast>(null);
|
||||
const [poDraft, setPoDraft] = React.useState("");
|
||||
const [showPoBox, setShowPoBox] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSupport(detectScannerSupport());
|
||||
}, []);
|
||||
|
||||
// Stop the camera on unmount. Leaving a getUserMedia stream running
|
||||
// keeps the camera light on after the operator navigates away.
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
abortRef.current?.abort();
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const flash = React.useCallback((kind: "ok" | "err", text: string) => {
|
||||
setToast({ kind, text });
|
||||
window.setTimeout(() => setToast(null), 6000);
|
||||
}, []);
|
||||
|
||||
const stopCamera = React.useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
setCameraOn(false);
|
||||
}, []);
|
||||
|
||||
const api = React.useCallback(
|
||||
async (path: string, init?: RequestInit) => {
|
||||
const token = getPocketBase().authStore.token;
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.headers ?? {}),
|
||||
Authorization: token,
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
body && typeof body === "object" && "error" in body && typeof body.error === "string"
|
||||
? body.error
|
||||
: `HTTP ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return body as unknown;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const lookup = React.useCallback(
|
||||
async (number: string) => {
|
||||
setBusy("lookup");
|
||||
try {
|
||||
const data = await api(
|
||||
`/api/v1/delivery-notes/resolve?number=${encodeURIComponent(number)}`,
|
||||
);
|
||||
setNote(data as Resolved);
|
||||
setPoDraft((data as Resolved).customer_po ?? "");
|
||||
setShowPoBox(false);
|
||||
stopCamera();
|
||||
flash("ok", `${number} loaded`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Lookup failed";
|
||||
setNote(null);
|
||||
flash("err", msg);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[api, flash, stopCamera],
|
||||
);
|
||||
|
||||
const startCamera = React.useCallback(async () => {
|
||||
setToast(null);
|
||||
if (!support?.camera) {
|
||||
flash("err", "This device has no camera available to the browser.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: "environment" } },
|
||||
audio: false,
|
||||
});
|
||||
streamRef.current = stream;
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!video || !canvas) return;
|
||||
video.srcObject = stream;
|
||||
video.setAttribute("playsinline", "true");
|
||||
await video.play();
|
||||
setCameraOn(true);
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
void scanVideoFrames(
|
||||
video,
|
||||
canvas,
|
||||
(raw) => {
|
||||
const dn = parseDeliveryNoteNumber(raw);
|
||||
if (dn) void lookup(dn);
|
||||
else flash("err", `Scanned a code that isn't a delivery note: ${raw.slice(0, 40)}`);
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
flash("err", `Camera failed to start: ${msg}`);
|
||||
}
|
||||
}, [support, flash, lookup]);
|
||||
|
||||
const runAction = React.useCallback(
|
||||
async (key: string, fn: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
flash("err", err instanceof Error ? err.message : "Action failed");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[flash],
|
||||
);
|
||||
|
||||
const status = note?.status ?? "";
|
||||
|
||||
// Ladder helpers — each mirrors the server's own guards so the UI
|
||||
// never offers a button the API will refuse.
|
||||
const canLoad = status === "draft" || status === "sent";
|
||||
const canPo = status === "sent" || status === "loaded";
|
||||
const canInvoice = status === "po_received";
|
||||
const isDead = status === "cancelled" || status === "completed";
|
||||
const nextStep = canLoad
|
||||
? "Load this note for the driver"
|
||||
: canPo
|
||||
? "Capture the customer's PO number"
|
||||
: canInvoice
|
||||
? "Ready to send to Invoicing"
|
||||
: isDead
|
||||
? "This note is closed"
|
||||
: "No action needed right now";
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh w-full max-w-2xl flex-col gap-5 px-4 py-6">
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Delivery note scanner</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scan the QR on a printed delivery note
|
||||
</p>
|
||||
</div>
|
||||
{note ? (
|
||||
<Button variant="outline" size="sm" onClick={() => { setNote(null); setPoDraft(""); }}>
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
Scan another
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{/* ---- the note card, or the scanner ---- */}
|
||||
{note ? (
|
||||
<section className="flex flex-col gap-4" data-testid="scan-note">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-mono text-lg font-semibold">{note.delivery_note_number}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{note.company_name || note.customer_name || "—"}
|
||||
</p>
|
||||
{note.customer_name ? (
|
||||
<p className="text-sm text-muted-foreground">{note.customer_name}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold">
|
||||
{STATUS_COPY[status] ?? status}
|
||||
</span>
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-muted-foreground">Items</dt>
|
||||
<dd className="font-medium">{note.item_count}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-muted-foreground">Customer PO</dt>
|
||||
<dd className="font-medium">{note.customer_po || "—"}</dd>
|
||||
</div>
|
||||
{note.signed_copy ? (
|
||||
<div className="col-span-2">
|
||||
<dt className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Signed copy
|
||||
</dt>
|
||||
<dd className="truncate font-medium text-emerald-700">{note.signed_copy}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">{nextStep}</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
className="h-12"
|
||||
onClick={() =>
|
||||
runAction("reprint", async () => {
|
||||
// Opens in a new tab: an <iframe> would need a
|
||||
// browser PDF viewer to print from, and Chrome backs
|
||||
// those with an extension that ignores print().
|
||||
const w = window.open(
|
||||
`/api/invoicing/delivery-note-pdf?deliveryNoteNumber=${encodeURIComponent(
|
||||
note.delivery_note_number,
|
||||
)}`,
|
||||
"_blank",
|
||||
);
|
||||
if (!w) flash("err", "Pop-up blocked — allow pop-ups to reprint.");
|
||||
})
|
||||
}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{busy === "reprint" ? <Loader2 className="animate-spin" /> : <FileText />}
|
||||
Reprint delivery note
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={busy !== null || isDead}
|
||||
>
|
||||
{busy === "signed" ? <Loader2 className="animate-spin" /> : <Camera />}
|
||||
Photograph / upload signed copy
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
className="hidden"
|
||||
data-testid="scan-signed-input"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
void runAction("signed", async () => {
|
||||
// NOTE: deliberately the upload-only endpoint, NOT
|
||||
// /api/v1/deliveries/mark-signed-copy. That one flips
|
||||
// the note to `completed` in the same write, which
|
||||
// would jump a note straight past invoicing.
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
await api(`/api/v1/delivery-notes/${note.id}/signed-copy`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
flash("ok", "Signed copy uploaded");
|
||||
await lookup(note.delivery_note_number);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{canLoad ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-12"
|
||||
onClick={() =>
|
||||
runAction("load", async () => {
|
||||
await api("/api/v1/deliveries/load-dn", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ deliveryNoteId: note.id }),
|
||||
});
|
||||
flash("ok", "Marked as loaded");
|
||||
await lookup(note.delivery_note_number);
|
||||
})
|
||||
}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{busy === "load" ? <Loader2 className="animate-spin" /> : <Upload />}
|
||||
Mark as loaded (for the driver)
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canPo ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-12"
|
||||
onClick={() => setShowPoBox((v) => !v)}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
<QrCode className="h-4 w-4" />
|
||||
Capture customer PO number
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{showPoBox ? (
|
||||
<div className="flex flex-col gap-2 rounded-lg border bg-muted/30 p-3">
|
||||
<Label htmlFor="scan-po" className="text-sm">
|
||||
Customer PO number
|
||||
</Label>
|
||||
<Input
|
||||
id="scan-po"
|
||||
value={poDraft}
|
||||
onChange={(e) => setPoDraft(e.target.value)}
|
||||
placeholder="e.g. PO-88213"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!poDraft.trim() || busy !== null}
|
||||
onClick={() =>
|
||||
runAction("po", async () => {
|
||||
await api(`/api/v1/delivery-notes/${note.id}/customer-po`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ customer_po: poDraft.trim() }),
|
||||
});
|
||||
flash("ok", "Customer PO saved");
|
||||
setShowPoBox(false);
|
||||
await lookup(note.delivery_note_number);
|
||||
})
|
||||
}
|
||||
>
|
||||
{busy === "po" ? <Loader2 className="animate-spin" /> : <CheckCircle2 />}
|
||||
Save PO
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setShowPoBox(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canInvoice ? (
|
||||
<Button
|
||||
className="h-12"
|
||||
onClick={() =>
|
||||
runAction("invoice", async () => {
|
||||
await api(`/api/v1/delivery-notes/${note.id}/mark-invoiced`, {
|
||||
method: "POST",
|
||||
});
|
||||
flash("ok", "Moved to ready to invoice");
|
||||
await lookup(note.delivery_note_number);
|
||||
})
|
||||
}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{busy === "invoice" ? <Loader2 className="animate-spin" /> : <CheckCircle2 />}
|
||||
Move to ready to invoice
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-xl border bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="h-full w-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
data-testid="scan-video"
|
||||
/>
|
||||
{!cameraOn ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<QrCode className="h-10 w-10 text-white/60" />
|
||||
<p className="text-sm text-white/80">
|
||||
{support?.camera
|
||||
? "Camera is off"
|
||||
: "This browser will not give the page a camera"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pointer-events-none absolute inset-[18%] rounded-xl border-2 border-white/70" />
|
||||
</div>
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
|
||||
{support && !support.secureContext ? (
|
||||
<p className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
|
||||
This page is not on HTTPS, so the browser blocks the camera. Use the office
|
||||
network's secure address, or type the number below.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{cameraOn ? (
|
||||
<Button variant="outline" className="h-12 flex-1" onClick={stopCamera}>
|
||||
<CameraOff className="mr-1 h-4 w-4" />
|
||||
Stop camera
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="h-12 flex-1"
|
||||
onClick={startCamera}
|
||||
disabled={!support?.camera}
|
||||
>
|
||||
<Camera className="mr-1 h-4 w-4" />
|
||||
Start camera
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3">
|
||||
<Label htmlFor="scan-manual" className="text-sm">
|
||||
Or type the delivery note number
|
||||
</Label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<Input
|
||||
id="scan-manual"
|
||||
value={manual}
|
||||
onChange={(e) => setManual(e.target.value.toUpperCase())}
|
||||
placeholder="DN26100401"
|
||||
className="font-mono"
|
||||
data-testid="scan-manual-input"
|
||||
/>
|
||||
<Button
|
||||
disabled={!parseDeliveryNoteNumber(manual) || busy !== null}
|
||||
onClick={() => {
|
||||
const dn = parseDeliveryNoteNumber(manual);
|
||||
if (dn) void lookup(dn);
|
||||
}}
|
||||
>
|
||||
{busy === "lookup" ? <Loader2 className="animate-spin" /> : null}
|
||||
Go
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Works on any device, camera or not.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{toast ? (
|
||||
<div
|
||||
data-testid="scan-toast"
|
||||
className={`fixed inset-x-3 bottom-3 z-50 mx-auto max-w-md rounded-lg px-4 py-3 text-sm shadow-lg ${
|
||||
toast.kind === "ok" ? "bg-emerald-600 text-white" : "bg-red-600 text-white"
|
||||
}`}
|
||||
>
|
||||
{toast.text}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
Moon,
|
||||
Package,
|
||||
Plus,
|
||||
QrCode,
|
||||
Settings as SettingsIcon,
|
||||
ShoppingCart,
|
||||
Sun,
|
||||
|
|
@ -119,6 +120,10 @@ const operationsItems: NavEntry[] = [
|
|||
// reference number. n8n will eventually auto-complete
|
||||
// these (docs/deliveries-n8n-workflow.md).
|
||||
{ href: "/deliveries", label: "Deliveries", icon: Truck },
|
||||
// Counter-side scanner for the printed delivery-note QR. Separate
|
||||
// standalone PWA at /scan (it declares its own manifest), linked
|
||||
// here so the office can find it from the CRM too.
|
||||
{ href: "/scan", label: "DN Scanner", icon: QrCode },
|
||||
];
|
||||
|
||||
// Stock is its own top-level group (not nested under Operations) so the
|
||||
|
|
|
|||
173
src/lib/scan/qr-detect.test.ts
Normal file
173
src/lib/scan/qr-detect.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { buildQrMatrix } from "@/lib/pdf/qr";
|
||||
import {
|
||||
isDeliveryNoteNumber,
|
||||
parseDeliveryNoteNumber,
|
||||
decodeQrFromCanvas,
|
||||
detectScannerSupport,
|
||||
} from "./qr-detect";
|
||||
|
||||
/**
|
||||
* The scanner is judged on one thing: does it read the QR that
|
||||
* `buildQrMatrix` actually puts on a delivery note? So the headline
|
||||
* tests are END-TO-END — encode with the production encoder,
|
||||
* rasterise, decode through the production fallback path, and assert
|
||||
* we get the delivery note number back.
|
||||
*
|
||||
* `BarcodeDetector` does not exist under jsdom, so these always
|
||||
* exercise the jsQR fallback. Deliberate: that is the path that has
|
||||
* to work on the devices the native API abandons (iOS, Firefox), and
|
||||
* it is the easy one to silently break.
|
||||
*/
|
||||
|
||||
type Raster = { data: Uint8ClampedArray; width: number; height: number };
|
||||
|
||||
/** Render a QR matrix to RGBA exactly as a canvas would hold it. */
|
||||
function rasterise(qr: ReturnType<typeof buildQrMatrix>, modulePx: number): Raster {
|
||||
const span = qr.size + qr.quietZone * 2;
|
||||
const dim = span * modulePx;
|
||||
const data = new Uint8ClampedArray(dim * dim * 4).fill(255);
|
||||
for (let i = 0; i < qr.modules.length; i++) {
|
||||
if (!qr.modules[i]) continue;
|
||||
const mx = (i % qr.size) + qr.quietZone;
|
||||
const my = Math.floor(i / qr.size) + qr.quietZone;
|
||||
for (let dy = 0; dy < modulePx; dy++) {
|
||||
for (let dx = 0; dx < modulePx; dx++) {
|
||||
const o = ((my * modulePx + dy) * dim + (mx * modulePx + dx)) * 4;
|
||||
data[o] = 0;
|
||||
data[o + 1] = 0;
|
||||
data[o + 2] = 0;
|
||||
data[o + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { data, width: dim, height: dim };
|
||||
}
|
||||
|
||||
function fakeCanvas(image: Raster): HTMLCanvasElement {
|
||||
return { width: image.width, height: image.height } as unknown as HTMLCanvasElement;
|
||||
}
|
||||
|
||||
function fakeCtx(image: Raster): CanvasRenderingContext2D {
|
||||
return {
|
||||
getImageData: () => ({ data: image.data, width: image.width, height: image.height }),
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
}
|
||||
|
||||
describe("parseDeliveryNoteNumber", () => {
|
||||
it("accepts a real delivery note number", () => {
|
||||
expect(parseDeliveryNoteNumber("DN26100401")).toBe("DN26100401");
|
||||
});
|
||||
|
||||
it("accepts a 3-digit day counter (a busy day)", () => {
|
||||
expect(parseDeliveryNoteNumber("DN261004123")).toBe("DN261004123");
|
||||
});
|
||||
|
||||
it("normalises case and surrounding whitespace", () => {
|
||||
expect(parseDeliveryNoteNumber(" dn26100401 \n")).toBe("DN26100401");
|
||||
});
|
||||
|
||||
it("rejects a random QR payload", () => {
|
||||
// A supplier's packing label or a pallet code must not reach the
|
||||
// network — these return null and the UI says "not a DN".
|
||||
expect(parseDeliveryNoteNumber("https://example.com/product/99281")).toBeNull();
|
||||
expect(parseDeliveryNoteNumber("ORDER-12345")).toBeNull();
|
||||
expect(parseDeliveryNoteNumber("mailto:accounts@wfsales.co.za")).toBeNull();
|
||||
expect(parseDeliveryNoteNumber("")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a DN-shaped string with a non-numeric body", () => {
|
||||
expect(parseDeliveryNoteNumber("DNabcdefgh")).toBeNull();
|
||||
});
|
||||
|
||||
it("pulls the number out of a scan URL", () => {
|
||||
// Lets a printed QR be re-pointed at a URL later without
|
||||
// reprinting every sheet already in circulation.
|
||||
expect(parseDeliveryNoteNumber("https://wfdash.example/scan?dn=DN26100401")).toBe(
|
||||
"DN26100401",
|
||||
);
|
||||
expect(parseDeliveryNoteNumber("https://wfdash.example/dn/DN26100401")).toBe(
|
||||
"DN26100401",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDeliveryNoteNumber", () => {
|
||||
it("is a strict shape check", () => {
|
||||
// DN + YY(2) MM(2) DD(2) NN(2) = 8 digits is the normal shape.
|
||||
expect(isDeliveryNoteNumber("DN26100401")).toBe(true);
|
||||
// A busy day pushes the counter to 3 digits (9 total).
|
||||
expect(isDeliveryNoteNumber("DN261004001")).toBe(true);
|
||||
// Still accepted with a margin above that.
|
||||
expect(isDeliveryNoteNumber("DN2610040123")).toBe(true);
|
||||
// Too short — 7 digits.
|
||||
expect(isDeliveryNoteNumber("DN2610040")).toBe(false);
|
||||
// Too long — 11 digits.
|
||||
expect(isDeliveryNoteNumber("DN26100401234")).toBe(false);
|
||||
expect(isDeliveryNoteNumber("XX26100401")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeQrFromCanvas — end to end with the real delivery-note QR", () => {
|
||||
it("reads back the number the delivery-note PDF prints", async () => {
|
||||
const qr = buildQrMatrix("DN26100401");
|
||||
const image = rasterise(qr, 4);
|
||||
const raw = await decodeQrFromCanvas(
|
||||
fakeCanvas(image),
|
||||
fakeCtx(image),
|
||||
image.width,
|
||||
image.height,
|
||||
);
|
||||
expect(parseDeliveryNoteNumber(raw ?? "")).toBe("DN26100401");
|
||||
}, 30_000);
|
||||
|
||||
it("reads a busy-day 3-digit number too", async () => {
|
||||
const qr = buildQrMatrix("DN261004123");
|
||||
const image = rasterise(qr, 4);
|
||||
const raw = await decodeQrFromCanvas(
|
||||
fakeCanvas(image),
|
||||
fakeCtx(image),
|
||||
image.width,
|
||||
image.height,
|
||||
);
|
||||
expect(parseDeliveryNoteNumber(raw ?? "")).toBe("DN261004123");
|
||||
}, 30_000);
|
||||
|
||||
it("still reads it at the pessimistic end of the printed size", async () => {
|
||||
// 2px/module ~ the real 200 dpi case. A camera at an angle is
|
||||
// worse, but if 2px fails on a clean frame the printed QR is too
|
||||
// small and that is worth catching here.
|
||||
const qr = buildQrMatrix("DN26100401");
|
||||
const image = rasterise(qr, 2);
|
||||
const raw = await decodeQrFromCanvas(
|
||||
fakeCanvas(image),
|
||||
fakeCtx(image),
|
||||
image.width,
|
||||
image.height,
|
||||
);
|
||||
expect(parseDeliveryNoteNumber(raw ?? "")).toBe("DN26100401");
|
||||
}, 30_000);
|
||||
|
||||
it("returns null for a frame with no code (must not throw)", async () => {
|
||||
const blank = new Uint8ClampedArray(120 * 120 * 4).fill(255);
|
||||
const image: Raster = { data: blank, width: 120, height: 120 };
|
||||
const raw = await decodeQrFromCanvas(
|
||||
fakeCanvas(image),
|
||||
fakeCtx(image),
|
||||
120,
|
||||
120,
|
||||
);
|
||||
expect(raw).toBeNull();
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("detectScannerSupport", () => {
|
||||
it("reports the jsQR fallback when BarcodeDetector is absent", () => {
|
||||
// jsdom has no BarcodeDetector — exactly the iOS/Firefox case.
|
||||
expect(detectScannerSupport().kind).toBe("fallback");
|
||||
});
|
||||
|
||||
it("does not throw when browser globals are missing (SSR/prerender)", () => {
|
||||
expect(() => detectScannerSupport()).not.toThrow();
|
||||
});
|
||||
});
|
||||
211
src/lib/scan/qr-detect.ts
Normal file
211
src/lib/scan/qr-detect.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import jsQR from "jsqr";
|
||||
|
||||
/**
|
||||
* QR detection for the /scan app.
|
||||
*
|
||||
* ## Why there are two detectors
|
||||
*
|
||||
* The native `BarcodeDetector` (Shape Detection API) is fast and
|
||||
* allocation-free, but it is **Chrome-only**. As of 2026 it is not
|
||||
* Baseline: Safari/iOS still has it "Under Consideration" (WebKit bug
|
||||
* #254573) and Firefox does not ship it at all. It is also
|
||||
* secure-context-only, so plain-http office LAN access silently
|
||||
* disables it.
|
||||
*
|
||||
* A scanner that only uses it simply does not work on an iPhone — and
|
||||
* a warehouse tablet is exactly the sort of device that might be one.
|
||||
* So: native where it exists, `jsQR` everywhere else. `jsQR` is pure
|
||||
* JS (no 2 MB WASM payload, which matters for a PWA on a weak signal),
|
||||
* and the delivery-note QR is always a QR code, so a QR-only decoder
|
||||
* is sufficient.
|
||||
*
|
||||
* Both paths run against the same `<canvas>` frame, so the caller
|
||||
* never branches.
|
||||
*/
|
||||
|
||||
export type QrDetectorKind = "native" | "fallback" | "none";
|
||||
|
||||
/**
|
||||
* Which detector this browser can actually run, and whether the
|
||||
* camera can be opened at all.
|
||||
*
|
||||
* `secureContext` and `mediaDevices` are reported separately from
|
||||
* `native` because a http:// origin loses BOTH the native detector
|
||||
* and — in most browsers — `getUserMedia` itself, and those need
|
||||
* different messages ("this device can't scan" vs "open it over
|
||||
* HTTPS").
|
||||
*/
|
||||
export function detectScannerSupport(): {
|
||||
kind: QrDetectorKind;
|
||||
camera: boolean;
|
||||
secureContext: boolean;
|
||||
} {
|
||||
const secureContext =
|
||||
typeof window !== "undefined" && window.isSecureContext !== false;
|
||||
const native =
|
||||
typeof window !== "undefined" && "BarcodeDetector" in window;
|
||||
const camera =
|
||||
typeof navigator !== "undefined" &&
|
||||
!!navigator.mediaDevices &&
|
||||
typeof navigator.mediaDevices.getUserMedia === "function";
|
||||
return {
|
||||
kind: native ? "native" : "fallback",
|
||||
camera,
|
||||
secureContext,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery note numbers are `DN{YY}{MM}{DD}{NN}` — DN + 8 digits, with
|
||||
* the day-counter able to run to 3 digits on a busy day.
|
||||
*
|
||||
* Validated BEFORE any network call so a random QR on a packing slip
|
||||
* (a supplier's, a pallet label's) can't fire a lookup, and so the
|
||||
* manual-entry box gives immediate feedback instead of a 404.
|
||||
*/
|
||||
const DN_PATTERN = /^DN\d{8,10}$/i;
|
||||
|
||||
export function isDeliveryNoteNumber(value: string): boolean {
|
||||
return DN_PATTERN.test(value.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a raw scanned string into a delivery note number, or null.
|
||||
*
|
||||
* Tolerates a URL that embeds the number, because a QR may be
|
||||
* re-pointed at a scan URL later without changing the printed paper —
|
||||
* `.../scan?dn=DN26100401` still resolves.
|
||||
*/
|
||||
export function parseDeliveryNoteNumber(raw: string): string | null {
|
||||
const text = (raw || "").trim();
|
||||
if (!text) return null;
|
||||
if (DN_PATTERN.test(text)) return text.toUpperCase();
|
||||
// A URL, or a path ending in the number.
|
||||
const fromUrl = /[?&]dn=([A-Za-z0-9]+)/.exec(text) ?? /\/([A-Za-z0-9]+)\/?$/.exec(text);
|
||||
const candidate = fromUrl?.[1] ?? "";
|
||||
return DN_PATTERN.test(candidate) ? candidate.toUpperCase() : null;
|
||||
}
|
||||
|
||||
type NativeDetector = {
|
||||
detect: (source: CanvasImageSource) => Promise<Array<{ rawValue?: string }>>;
|
||||
};
|
||||
|
||||
let nativeDetector: NativeDetector | null = null;
|
||||
let nativeDetectorChecked = false;
|
||||
|
||||
async function getNativeDetector(): Promise<NativeDetector | null> {
|
||||
if (nativeDetectorChecked) return nativeDetector;
|
||||
nativeDetectorChecked = true;
|
||||
if (typeof window === "undefined" || !("BarcodeDetector" in window)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const Ctor = (window as unknown as {
|
||||
BarcodeDetector: {
|
||||
new (o: { formats: string[] }): NativeDetector;
|
||||
getSupportedFormats?: () => Promise<string[]>;
|
||||
};
|
||||
}).BarcodeDetector;
|
||||
const supported = await Ctor.getSupportedFormats?.();
|
||||
// Some builds expose the constructor but not QR in practice.
|
||||
// If we can query and QR is missing, don't use it.
|
||||
if (Array.isArray(supported) && !supported.includes("qr_code")) {
|
||||
return null;
|
||||
}
|
||||
nativeDetector = new Ctor({ formats: ["qr_code"] });
|
||||
} catch {
|
||||
nativeDetector = null;
|
||||
}
|
||||
return nativeDetector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one already-drawn canvas frame. Returns the raw QR text, or
|
||||
* null when the frame holds no readable code.
|
||||
*
|
||||
* `reuseInverted` is off deliberately: an inverted (white-on-black)
|
||||
* code is not something the delivery-note QR ever is, and leaving it
|
||||
* off measurably cuts false positives on printed labels.
|
||||
*/
|
||||
export async function decodeQrFromCanvas(
|
||||
canvas: HTMLCanvasElement,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<string | null> {
|
||||
const native = await getNativeDetector();
|
||||
if (native) {
|
||||
try {
|
||||
const found = await native.detect(canvas);
|
||||
const value = found.find((b) => b.rawValue)?.rawValue;
|
||||
if (value) return value;
|
||||
// Fall THROUGH to jsQR rather than returning null — a native
|
||||
// miss on a blurred/angled frame is exactly when the JS decoder
|
||||
// sometimes still succeeds.
|
||||
} catch {
|
||||
/* fall through to the fallback */
|
||||
}
|
||||
}
|
||||
const image = ctx.getImageData(0, 0, width, height);
|
||||
const result = jsQR(image.data, width, height, { inversionAttempts: "dontInvert" });
|
||||
return result?.data ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the detector over a video stream until it finds something.
|
||||
*
|
||||
* Throttled to ~8 fps: a camera frame is 30-60 fps, and decoding every
|
||||
* one burns battery for no gain — the QR is not moving relative to the
|
||||
* page, so once per 120 ms is plenty. Rejects if `signal` aborts so
|
||||
* the caller can cancel cleanly on unmount or after a hit.
|
||||
*/
|
||||
export function scanVideoFrames(
|
||||
video: HTMLVideoElement,
|
||||
canvas: HTMLCanvasElement,
|
||||
onFound: (raw: string) => void,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!ctx) {
|
||||
return Promise.reject(new Error("Could not get a 2D context for scanning."));
|
||||
}
|
||||
const INTERVAL_MS = 120;
|
||||
return new Promise((resolve) => {
|
||||
let stopped = false;
|
||||
const finish = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
};
|
||||
const onAbort = () => finish();
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (stopped) return;
|
||||
if (signal.aborted) return finish();
|
||||
if (video.readyState < 2 || !video.videoWidth) return;
|
||||
// Scan a downscaled frame: cheaper to decode, and the code is
|
||||
// small on screen. 640px wide keeps it reliable.
|
||||
const scale = Math.min(1, 640 / video.videoWidth);
|
||||
const w = Math.max(1, Math.round(video.videoWidth * scale));
|
||||
const h = Math.max(1, Math.round(video.videoHeight * scale));
|
||||
if (canvas.width !== w) canvas.width = w;
|
||||
if (canvas.height !== h) canvas.height = h;
|
||||
ctx.drawImage(video, 0, 0, w, h);
|
||||
void decodeQrFromCanvas(canvas, ctx, w, h)
|
||||
.then((raw) => {
|
||||
if (raw && !stopped) {
|
||||
onFound(raw);
|
||||
finish();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* a single bad frame must not kill the loop */
|
||||
});
|
||||
}, INTERVAL_MS);
|
||||
|
||||
if (signal.aborted) finish();
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue