Files
hope-events/frontend/src/components/scanner/OfflineScanPanel.tsx
T
joshuaandClaude Opus 5.5 be621c49a2 Add offline ticket scanning
Staff can lock a section (or the whole event) to their device, download
its tickets and keep scanning with no connection. Scans are queued in
IndexedDB and synced idempotently when back online; conflicts go into a
sync report for supervisors, who can also force-release a lost device.
The server now validates scan sections and refuses scans in a scope
locked to another device. Adds a web app manifest and a service worker
for the scanner page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:06:22 +02:00

107 lines
5.1 KiB
TypeScript

"use client";
import React, { useEffect, useState } from "react";
import { Wifi, WifiOff, CloudUpload, Lock } from "lucide-react";
import { apiFetch } from "@/lib/api";
import type { OtherLock } from "./useOfflineScanning";
type Offline = ReturnType<typeof import("./useOfflineScanning").useOfflineScanning>;
/**
* Offline-mode controls above the scanner: shows who holds which section, lets this
* device lock the selected section and download its tickets, and shows the sync queue.
*/
export function OfflineScanPanel({ offline, token, eventId, eventTitle, sectionId, sectionName, onError, onInfo }: {
offline: Offline;
token: string | null;
eventId: string;
eventTitle: string;
sectionId: string | null;
sectionName: string | null;
onError: (m: string) => void;
onInfo: (m: string) => void;
}) {
const [locks, setLocks] = useState<OtherLock[]>([]);
const [busy, setBusy] = useState(false);
const { lock, pending, online, syncing } = offline;
useEffect(() => {
if (!token || !eventId || eventId === "all" || !online) { setLocks([]); return; }
apiFetch<OtherLock[]>(`/api/scan-locks?eventId=${encodeURIComponent(eventId)}`, { authToken: token })
.then(setLocks).catch(() => setLocks([]));
}, [token, eventId, online, lock]);
const others = locks.filter(l => l.deviceId !== offline.deviceId());
const enable = async () => {
setBusy(true);
try {
const n = await offline.enable(eventId, eventTitle, sectionId, sectionName);
onInfo(`Offline mode on — ${n} ticket(s) downloaded. You can keep scanning without a connection.`);
} catch (e: any) {
onError(e?.message || "Couldn't turn on offline mode");
} finally {
setBusy(false);
}
};
const release = async () => {
setBusy(true);
try {
await offline.release();
onInfo("Offline mode ended and the section is unlocked.");
} catch (e: any) {
onError(e?.message || "Couldn't end offline mode");
} finally {
setBusy(false);
}
};
const syncNow = async () => {
try {
const r = await offline.sync();
if (r) onInfo(`Synced ${r.accepted} scan(s)${r.conflicts ? ` — ${r.conflicts} need a supervisor to review` : ""}.`);
} catch (e: any) {
onError(e?.message || "Sync failed — scans are kept on this device and will retry.");
}
};
return (
<div className={`rounded-xl border p-3 mb-4 text-sm ${lock ? "bg-amber-50 border-amber-200" : "bg-white"}`}>
<div className="flex flex-wrap items-center gap-2">
{online ? <Wifi className="w-4 h-4 text-green-600" /> : <WifiOff className="w-4 h-4 text-red-600" />}
<span className={online ? "text-green-700" : "text-red-700"}>{online ? "Online" : "Offline"}</span>
{lock ? (
<>
<span className="text-gray-400">·</span>
<span className="font-medium text-amber-900">Offline mode: {lock.eventTitle}{lock.sectionName ? ` — ${lock.sectionName}` : " — whole event"}</span>
<span className="text-xs text-gray-600">Ticket list downloaded at {new Date(lock.snapshotAt).toLocaleTimeString()}</span>
<span className="text-xs text-gray-600">· {pending} scan(s) waiting to sync</span>
<div className="ml-auto flex gap-2">
<button onClick={syncNow} disabled={!online || syncing || pending === 0} className="inline-flex items-center gap-1 px-2.5 py-1 rounded border bg-white text-xs disabled:opacity-50"><CloudUpload className="w-3.5 h-3.5" /> {syncing ? "Syncing…" : "Sync now"}</button>
<button onClick={release} disabled={!online || busy} className="px-2.5 py-1 rounded bg-amber-600 text-white text-xs disabled:opacity-50">Release section</button>
</div>
</>
) : eventId && eventId !== "all" ? (
<div className="ml-auto">
<button onClick={enable} disabled={!online || busy || others.some(l => !l.sectionId || !sectionId || l.sectionId === sectionId)} className="px-2.5 py-1 rounded border border-brand-300 text-brand-700 text-xs hover:bg-brand-50 disabled:opacity-50" title="Lock this section to this device and download its tickets, so you can keep scanning if the connection drops">
{busy ? "Preparing…" : `Enable offline mode${sectionName ? ` for ${sectionName}` : " (whole event)"}`}
</button>
</div>
) : (
<span className="text-xs text-gray-500 ml-1">Pick an event to use offline mode.</span>
)}
</div>
{others.length > 0 && (
<ul className="mt-2 space-y-0.5">
{others.map(l => (
<li key={l.id} className="flex items-center gap-1.5 text-xs text-gray-700">
<Lock className="w-3.5 h-3.5 text-amber-600" />
{l.sectionName ? `"${l.sectionName}"` : "The whole event"} is offline-locked by {l.user.name}{l.deviceName ? ` on ${l.deviceName}` : ""} — scan it on that device.
</li>
))}
</ul>
)}
{lock && !online && <p className="mt-2 text-xs text-amber-900">Scans are saved on this device and will sync when the connection is back. Tickets bought after the download time won&apos;t be on the list.</p>}
</div>
);
}