11'use client' ;
22
33import type { ScanResponse } from '@tecnova/shared/schemas' ;
4+ import { BrowserMultiFormatReader , type IScannerControls } from '@zxing/browser' ;
45import Link from 'next/link' ;
5- import { type FormEvent , useState } from 'react' ;
6+ import { type FormEvent , useEffect , useRef , useState } from 'react' ;
67
78const API_URL = process . env . NEXT_PUBLIC_API_URL ?? 'http://localhost:8787' ;
9+ const ID_PATTERN = / ^ \d { 5 } $ / ;
10+
11+ type Mode = 'manual' | 'qr' ;
812
913type State =
1014 | { kind : 'idle' }
11- | { kind : 'scanning' }
15+ | { kind : 'confirming' ; value : string ; source : Mode }
16+ | { kind : 'submitting' }
1217 | { kind : 'result' ; data : ScanResponse }
1318 | { kind : 'error' ; message : string } ;
1419
@@ -20,18 +25,59 @@ const formatDuration = (minutes: number): string => {
2025} ;
2126
2227export default function Home ( ) {
28+ const [ mode , setMode ] = useState < Mode > ( 'manual' ) ;
2329 const [ state , setState ] = useState < State > ( { kind : 'idle' } ) ;
2430 const [ input , setInput ] = useState ( '' ) ;
31+ const [ cameraError , setCameraError ] = useState < string | null > ( null ) ;
32+ const videoRef = useRef < HTMLVideoElement | null > ( null ) ;
33+ const controlsRef = useRef < IScannerControls | null > ( null ) ;
2534
26- const submit = async ( e : FormEvent ) => {
27- e . preventDefault ( ) ;
28- if ( state . kind === 'scanning' ) return ;
29- setState ( { kind : 'scanning' } ) ;
35+ // QR モードかつ idle のときだけスキャナを起動。
36+ // 確認画面・送信中・結果表示中は止めて誤検出と無駄な CPU を避ける。
37+ useEffect ( ( ) => {
38+ if ( mode !== 'qr' || state . kind !== 'idle' ) return ;
39+ const video = videoRef . current ;
40+ if ( ! video ) return ;
41+
42+ const reader = new BrowserMultiFormatReader ( ) ;
43+ let cancelled = false ;
44+ setCameraError ( null ) ;
45+
46+ reader
47+ . decodeFromVideoDevice ( undefined , video , ( result ) => {
48+ if ( cancelled || ! result ) return ;
49+ const value = result . getText ( ) . trim ( ) ;
50+ // 5桁の内製ID形式以外は無視(誤読防止)
51+ if ( ! ID_PATTERN . test ( value ) ) return ;
52+ // 一度認識したら即時 API は叩かず、確認画面でクッションを置く
53+ setState ( { kind : 'confirming' , value, source : 'qr' } ) ;
54+ } )
55+ . then ( ( controls ) => {
56+ if ( cancelled ) {
57+ controls . stop ( ) ;
58+ return ;
59+ }
60+ controlsRef . current = controls ;
61+ } )
62+ . catch ( ( err : unknown ) => {
63+ const msg = err instanceof Error ? err . message : String ( err ) ;
64+ setCameraError ( msg ) ;
65+ } ) ;
66+
67+ return ( ) => {
68+ cancelled = true ;
69+ controlsRef . current ?. stop ( ) ;
70+ controlsRef . current = null ;
71+ } ;
72+ } , [ mode , state . kind ] ) ;
73+
74+ const runScan = async ( value : string ) => {
75+ setState ( { kind : 'submitting' } ) ;
3076 try {
3177 const r = await fetch ( `${ API_URL } /checkin/scan` , {
3278 method : 'POST' ,
3379 headers : { 'Content-Type' : 'application/json' } ,
34- body : JSON . stringify ( { scanValue : input } ) ,
80+ body : JSON . stringify ( { scanValue : value } ) ,
3581 } ) ;
3682 const body = ( await r . json ( ) ) as ScanResponse | { error : string ; message : string } ;
3783 if ( ! r . ok ) {
@@ -47,12 +93,27 @@ export default function Home() {
4793 }
4894 } ;
4995
96+ const submitManual = ( e : FormEvent ) => {
97+ e . preventDefault ( ) ;
98+ if ( ! ID_PATTERN . test ( input ) ) return ;
99+ void runScan ( input ) ;
100+ } ;
101+
102+ const confirmSubmit = ( ) => {
103+ if ( state . kind !== 'confirming' ) return ;
104+ void runScan ( state . value ) ;
105+ } ;
106+
107+ const cancelConfirm = ( ) => {
108+ setState ( { kind : 'idle' } ) ;
109+ } ;
110+
50111 const reset = ( ) => {
51112 setInput ( '' ) ;
52113 setState ( { kind : 'idle' } ) ;
53114 } ;
54115
55- if ( state . kind === 'scanning ' ) {
116+ if ( state . kind === 'submitting ' ) {
56117 return (
57118 < main className = "flex flex-1 items-center justify-center p-8" >
58119 < p className = "text-xl" > 確認中...</ p >
@@ -97,32 +158,95 @@ export default function Home() {
97158 ) ;
98159 }
99160
161+ if ( state . kind === 'confirming' ) {
162+ return (
163+ < main className = "flex flex-1 flex-col items-center justify-center gap-8 p-8 text-center" >
164+ < h1 className = "text-2xl font-bold" > この ID で合っていますか?</ h1 >
165+ < p className = "text-6xl font-bold tracking-widest tabular-nums" > { state . value } </ p >
166+ < p className = "text-sm text-zinc-500" >
167+ { state . source === 'qr' ? 'QRコードから読み取りました' : '手入力で確認します' }
168+ </ p >
169+ < div className = "flex gap-4" >
170+ < button
171+ type = "button"
172+ onClick = { cancelConfirm }
173+ className = "rounded-lg bg-zinc-200 px-8 py-4 text-xl"
174+ >
175+ やり直す
176+ </ button >
177+ < button
178+ type = "button"
179+ onClick = { confirmSubmit }
180+ className = "rounded-lg bg-blue-600 px-8 py-4 text-xl font-semibold text-white"
181+ >
182+ チェックイン / アウト
183+ </ button >
184+ </ div >
185+ </ main >
186+ ) ;
187+ }
188+
100189 return (
101- < main className = "flex flex-1 flex-col items-center justify-center gap-8 p-8" >
190+ < main className = "flex flex-1 flex-col items-center justify-center gap-6 p-8" >
102191 < h1 className = "text-3xl font-bold" > テクノバながさき チェックイン</ h1 >
103- < form onSubmit = { submit } className = "flex w-full max-w-sm flex-col items-stretch gap-4" >
104- < label htmlFor = "participant-id" className = "text-lg text-center" >
105- IDを入力してね(5桁)
106- </ label >
107- < input
108- id = "participant-id"
109- type = "text"
110- inputMode = "numeric"
111- pattern = "\d{5}"
112- maxLength = { 5 }
113- required
114- value = { input }
115- onChange = { ( e ) => setInput ( e . target . value ) }
116- className = "rounded-lg border border-zinc-300 px-4 py-4 text-center text-3xl tracking-widest"
117- />
118- < button
119- type = "submit"
120- disabled = { input . length !== 5 }
121- className = "rounded-lg bg-blue-600 px-8 py-4 text-xl font-semibold text-white disabled:bg-zinc-300"
122- >
123- チェックイン / アウト
124- </ button >
125- </ form >
192+
193+ { mode === 'manual' ? (
194+ < >
195+ < form
196+ onSubmit = { submitManual }
197+ className = "flex w-full max-w-sm flex-col items-stretch gap-4"
198+ >
199+ < label htmlFor = "participant-id" className = "text-lg text-center" >
200+ IDを入力してね(5桁)
201+ </ label >
202+ < input
203+ id = "participant-id"
204+ type = "text"
205+ inputMode = "numeric"
206+ pattern = "\d{5}"
207+ maxLength = { 5 }
208+ required
209+ value = { input }
210+ onChange = { ( e ) => setInput ( e . target . value ) }
211+ className = "rounded-lg border border-zinc-300 px-4 py-4 text-center text-3xl tracking-widest"
212+ />
213+ < button
214+ type = "submit"
215+ disabled = { input . length !== 5 }
216+ className = "rounded-lg bg-blue-600 px-8 py-4 text-xl font-semibold text-white disabled:bg-zinc-300"
217+ >
218+ チェックイン / アウト
219+ </ button >
220+ </ form >
221+ < button
222+ type = "button"
223+ onClick = { ( ) => setMode ( 'qr' ) }
224+ className = "rounded-lg border border-zinc-400 px-6 py-3 text-base"
225+ >
226+ QRコードで読み取る(試験運用)
227+ </ button >
228+ </ >
229+ ) : (
230+ < div className = "flex w-full max-w-sm flex-col items-stretch gap-4" >
231+ < p className = "text-center text-lg" > QRコードをかざしてね</ p >
232+ < div className = "relative aspect-square w-full overflow-hidden rounded-lg bg-black" >
233+ < video ref = { videoRef } className = "h-full w-full object-cover" muted playsInline />
234+ { cameraError && (
235+ < div className = "absolute inset-0 flex items-center justify-center bg-black/70 p-4 text-center text-sm text-white" >
236+ カメラを起動できませんでした: { cameraError }
237+ </ div >
238+ ) }
239+ </ div >
240+ < button
241+ type = "button"
242+ onClick = { ( ) => setMode ( 'manual' ) }
243+ className = "rounded-lg border border-zinc-400 px-6 py-3 text-base"
244+ >
245+ 手入力に戻る
246+ </ button >
247+ </ div >
248+ ) }
249+
126250 < Link href = "/first-time" className = "text-lg text-blue-700 underline" >
127251 初めての方はこちら
128252 </ Link >
0 commit comments