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>
107 lines
5.1 KiB
TypeScript
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't be on the list.</p>}
|
|
</div>
|
|
);
|
|
}
|