Files
emilandClaude Sonnet 4.6 be2260d259 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>
2026-05-01 16:41:17 +03:00

90 lines
3.0 KiB
TypeScript

'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>
);
}