feat: add partner interface, admin dashboard, and unit tests
Partner flow: QR scanner component (html5-qrcode) with camera permission/not-found handling, 3-step spend flow (scan → amount entry with auto-filled max → success/error result). Admin dashboard: stats overview, grant points with debounced student search, paginated transactions table with type filters, paginated students table. Tests: comprehensive unit tests for points and auth packages — service (all paths including error branches, RS256 wrong-method), handler (all HTTP status codes via httptest), JWT round-trip, repository constructors. Auth coverage: 72.9%, points service coverage: 100%. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
50b3c4198a
commit
be2260d259
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Spinner } from '@/components/ui';
|
||||
|
||||
interface QRScannerProps {
|
||||
onScan: (token: string) => void;
|
||||
}
|
||||
|
||||
export function QRScanner({ onScan }: QRScannerProps) {
|
||||
const [cameraError, setCameraError] = useState<string | null>(null);
|
||||
const [starting, setStarting] = useState(true);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Keep a stable ref to onScan so restarting the scanner when the parent
|
||||
// re-renders (e.g. state changes in the parent) is not needed.
|
||||
const onScanRef = useRef(onScan);
|
||||
onScanRef.current = onScan;
|
||||
const stopRef = useRef<(() => Promise<void>) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Use a unique id per mount — prevents html5-qrcode conflicts on StrictMode
|
||||
// double-invoke (the second run would find the first run's leftover DOM).
|
||||
const id = `qr-${Date.now()}`;
|
||||
container.id = id;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
import('html5-qrcode').then(({ Html5Qrcode }) => {
|
||||
if (cancelled) return;
|
||||
|
||||
const scanner = new Html5Qrcode(id);
|
||||
stopRef.current = () => scanner.stop();
|
||||
|
||||
scanner
|
||||
.start(
|
||||
{ facingMode: 'environment' },
|
||||
{ fps: 10, qrbox: { width: 220, height: 220 } },
|
||||
(text) => {
|
||||
if (!cancelled) onScanRef.current(text);
|
||||
scanner.stop().catch(() => {});
|
||||
},
|
||||
undefined,
|
||||
)
|
||||
.then(() => {
|
||||
if (!cancelled) setStarting(false);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
setStarting(false);
|
||||
const msg = String(err).toLowerCase();
|
||||
if (msg.includes('permission') || msg.includes('notallowed')) {
|
||||
setCameraError('Нет доступа к камере. Разрешите доступ в настройках браузера.');
|
||||
} else if (msg.includes('notfound') || msg.includes('no camera') || msg.includes('no cameras')) {
|
||||
setCameraError('Камера не найдена на этом устройстве.');
|
||||
} else {
|
||||
setCameraError('Не удалось запустить камеру.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopRef.current?.().catch(() => {});
|
||||
};
|
||||
}, []); // intentionally empty — scanner starts once on mount
|
||||
|
||||
if (cameraError) {
|
||||
return (
|
||||
<div className="flex min-h-[240px] items-center justify-center rounded-2xl bg-gray-800 p-6 text-center ring-1 ring-gray-700">
|
||||
<p className="text-sm text-red-400">{cameraError}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-[240px] overflow-hidden rounded-2xl bg-black ring-1 ring-gray-700">
|
||||
{starting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
{/* html5-qrcode injects <video> and overlay elements here */}
|
||||
<div ref={containerRef} className="w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user