feat(clock): bigger footer tabs, session scan counter, wake lock + haptics
Footer buttons gain icons and more padding for easier tapping on a wall-mounted kiosk phone. The header now shows a running count of badges scanned since the app was opened. PersonScanner also requests a screen wake lock while its camera is active (re-acquired on visibilitychange, since the OS revokes it when backgrounded) and adds vibration feedback per scan outcome, so a kiosk phone doesn't need to be watched closely to confirm a scan landed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
a7ac99d934
commit
3ec2db9b2a
2 changed files with 62 additions and 10 deletions
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Truck, User } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PersonScanner } from "./person-scanner";
|
||||
|
||||
|
|
@ -9,6 +10,7 @@ type Tab = "person" | "fleet";
|
|||
export function ClockView() {
|
||||
const [tab, setTab] = React.useState<Tab>("person");
|
||||
const [now, setNow] = React.useState(() => new Date());
|
||||
const [scanCount, setScanCount] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 30_000);
|
||||
|
|
@ -19,19 +21,41 @@ export function ClockView() {
|
|||
<div className="flex h-dvh flex-col bg-background">
|
||||
<header className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<span className="text-lg font-bold text-foreground">MEGA Clock</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary"
|
||||
title="Badges scanned since this app was opened"
|
||||
>
|
||||
{scanCount} scans
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
{tab === "person" ? <PersonScanner /> : <FleetComingSoon />}
|
||||
{tab === "person" ? (
|
||||
<PersonScanner onScan={() => setScanCount((c) => c + 1)} />
|
||||
) : (
|
||||
<FleetComingSoon />
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="clock-footer flex border-t-2 border-primary/70 shadow-[0_-4px_12px_-4px_rgba(0,0,0,0.15)]">
|
||||
<TabButton label="Person" active={tab === "person"} onClick={() => setTab("person")} />
|
||||
<span aria-hidden className="my-3 w-px bg-border" />
|
||||
<TabButton label="Fleet" active={tab === "fleet"} onClick={() => setTab("fleet")} />
|
||||
<TabButton
|
||||
label="Person"
|
||||
icon={User}
|
||||
active={tab === "person"}
|
||||
onClick={() => setTab("person")}
|
||||
/>
|
||||
<span aria-hidden className="my-4 w-px bg-border" />
|
||||
<TabButton
|
||||
label="Fleet"
|
||||
icon={Truck}
|
||||
active={tab === "fleet"}
|
||||
onClick={() => setTab("fleet")}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -39,10 +63,12 @@ export function ClockView() {
|
|||
|
||||
function TabButton({
|
||||
label,
|
||||
icon: Icon,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
|
|
@ -51,10 +77,11 @@ function TabButton({
|
|||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex-1 py-4 text-center text-sm font-semibold",
|
||||
"flex flex-1 flex-col items-center justify-center gap-1 py-6 text-center text-base font-semibold",
|
||||
active ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-7 w-7" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ type ScanResult =
|
|||
| { kind: "out"; employeeName: string; sessionHours: number; dailyTotalHours: number }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
export function PersonScanner() {
|
||||
export function PersonScanner({ onScan }: { onScan?: () => void }) {
|
||||
const videoRef = React.useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const lastCodeRef = React.useRef<{ value: string; at: number } | null>(null);
|
||||
|
|
@ -42,6 +42,8 @@ export function PersonScanner() {
|
|||
}
|
||||
if (responseBody.direction === "in") {
|
||||
setResult({ kind: "in", employeeName: responseBody.employeeName });
|
||||
navigator.vibrate?.(150);
|
||||
onScan?.();
|
||||
} else {
|
||||
setResult({
|
||||
kind: "out",
|
||||
|
|
@ -49,21 +51,40 @@ export function PersonScanner() {
|
|||
sessionHours: responseBody.sessionHours,
|
||||
dailyTotalHours: responseBody.dailyTotalHours,
|
||||
});
|
||||
navigator.vibrate?.([100, 80, 100]);
|
||||
onScan?.();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setResult({ kind: "error", message: "Couldn't reach the server — try again" });
|
||||
navigator.vibrate?.(300);
|
||||
})
|
||||
.finally(() => {
|
||||
busyRef.current = false;
|
||||
setTimeout(() => setResult(null), BANNER_MS);
|
||||
});
|
||||
}, []);
|
||||
}, [onScan]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let stream: MediaStream | null = null;
|
||||
let rafId = 0;
|
||||
let cancelled = false;
|
||||
let wakeLock: WakeLockSentinel | null = null;
|
||||
|
||||
// A kiosk phone mounted on a wall must not let its screen lock mid-shift.
|
||||
// The OS can revoke the lock when the tab is backgrounded (app-switch,
|
||||
// screen-off from a hardware button), so it's re-requested on return.
|
||||
async function requestWakeLock() {
|
||||
try {
|
||||
wakeLock = (await navigator.wakeLock?.request("screen")) ?? null;
|
||||
} catch {
|
||||
// Unsupported or denied — not fatal, the screen may just dim on its own.
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === "visible") requestWakeLock();
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (cancelled) return;
|
||||
|
|
@ -119,14 +140,18 @@ export function PersonScanner() {
|
|||
if (window.BarcodeDetector) {
|
||||
detectorRef.current = new window.BarcodeDetector({ formats: ["qr_code"] });
|
||||
}
|
||||
await requestWakeLock();
|
||||
tick();
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
start();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(rafId);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
wakeLock?.release().catch(() => {});
|
||||
stream?.getTracks().forEach((t) => t.stop());
|
||||
};
|
||||
}, [handleDecoded]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue