Skip to content

Commit 91fd2fd

Browse files
authored
Merge pull request #24 from ut42tech/feat/checkin-qr-scanner
feat(checkin): opt-in QR scan with confirmation cushion
2 parents bdb7f28 + 6c2e295 commit 91fd2fd

5 files changed

Lines changed: 219 additions & 40 deletions

File tree

apps/checkin/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
},
1111
"dependencies": {
1212
"@tecnova/shared": "workspace:*",
13+
"@zxing/browser": "^0.2.0",
1314
"next": "16.2.4",
1415
"react": "19.2.4",
1516
"react-dom": "19.2.4"

apps/checkin/src/app/page.tsx

Lines changed: 156 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
'use client';
22

33
import type { ScanResponse } from '@tecnova/shared/schemas';
4+
import { BrowserMultiFormatReader, type IScannerControls } from '@zxing/browser';
45
import Link from 'next/link';
5-
import { type FormEvent, useState } from 'react';
6+
import { type FormEvent, useEffect, useRef, useState } from 'react';
67

78
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8787';
9+
const ID_PATTERN = /^\d{5}$/;
10+
11+
type Mode = 'manual' | 'qr';
812

913
type 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

2227
export 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>

docs/handoff.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@
2727
| - | Bug fix: ログイン後に admin オリジンへ戻すよう callbackURL を絶対URL化 | #10 |
2828
| - | API CORS/trustedOrigins を env 経由で設定可能化(本番デプロイ準備) | direct commit `9ad8603` |
2929
| W2 Day 10 | `/api/sessions/today` + `/api/participants` 参照系API | #11 |
30+
| W2 Day 10 | `/api/mentors` CRUD(admin role guard) | #12 |
31+
| W2 Day 10 | 管理ダッシュボード(当日セッション一覧) | #13, #16 |
32+
| W2 Day 10 | 参加者一覧 + メンター管理画面 | #14 |
33+
| W2 Day 9 | checkin の PWA 化(manifest, apple-icon, viewport) | #15 |
34+
| - | 事前登録管理ページ(admin) + grade enum 制約 | #23 |
35+
| W2 Day 9 | checkin に opt-in QR スキャン + 確認クッション追加 | feat/checkin-qr-scanner |
3036

3137
**Day 10 と Day 11 を意図的に入れ替えた**(Day 11 = Better Auth を先に)。理由は Day 10 の
3238
`/api/*` 系エンドポイントが認証必須で、後から auth を retrofit するより auth 基盤を先に
@@ -39,6 +45,8 @@
3945

4046
- iPad PWA 側(`localhost:3000`):
4147
- `/` ID 5桁手入力 → `/checkin/scan` → check-in / check-out 自動切替
48+
- `/` 「QRコードで読み取る(試験運用)」ボタン → カメラ起動 → 5桁認識
49+
→ 確認画面(やり直す / チェックイン)→ `/checkin/scan`
4250
- `/first-time` 未アクティベート一覧 → タップ → `/checkin/activate` → ID表示
4351
- 管理画面(`localhost:3001`):
4452
- `/login` → Google OAuth → mentors 許可リスト判定 → `/` でユーザー名表示
@@ -169,9 +177,11 @@ admin 専用ルートは `c.get('mentor').role !== 'admin'` を弾くチェッ
169177

170178
## まだやっていない・残作業(順不同)
171179

172-
- **W2 Day 10 残り 3 サブ PR**:上記参照
173-
- **Day 9 (PWA 化・iPad 実機テスト)**:UI/UX 調整なので最後で OK
174-
- **QR スキャナ**:今は手入力。zxing 等を使ったカメラ実装は将来別 PR
180+
- **Day 9 残り:iPad 実機での QR スキャン動作確認**:実装は入った(@zxing/browser
181+
opt-in カメラビュー、確認クッション)。iPad Safari 実機で起動・読み取り・キャンセル
182+
動線を通すのは未実施
183+
- **QR スキャナの本格運用判断**:現状は「試験運用」ラベル付きの opt-in。手入力との
184+
併用で初回開催 → 安定したらデフォルト化を検討
175185
- **Phase 1.5 系**:メンタースマホアプリ、活動ログ、CSVエクスポート等
176186
- **`docs/mvp.md` 9.1 の Day 12-14**:E2E テスト、リハーサル、本番リリース仕上げ
177187
(初回本番デプロイは済んだが、リリース時の運用手順整備は未着手)

docs/mvp.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -548,13 +548,21 @@ QR/バーコードスキャン用統合エンドポイント。スキャン値
548548

549549
### 7.1 チェックインiPadアプリ(apps/checkin)
550550

551-
#### 7.1.1 トップ画面(カメラビュー
551+
#### 7.1.1 トップ画面(手入力 + QR切替
552552

553-
- 全画面でカメラビュー(`getUserMedia` 使用)
554-
- 中央に「QRをかざしてください」のオーバーレイ
553+
- デフォルトは 5 桁の手入力フォーム(`pattern="\d{5}"` で数字のみ)
554+
- フォーム下に「QRコードで読み取る(試験運用)」ボタン → カメラビューに切替
555+
- カメラビューでは「手入力に戻る」ボタンで元のフォームに復帰
555556
- 下部に「初めての方はこちら」ボタン
556-
- スキャン成功 → `/checkin/scan` 呼び出し → 結果に応じて画面遷移
557-
- QR/バーコード読み取りライブラリ: `@zxing/browser` 推奨
557+
- QR 認識時は **即時 API を叩かず確認画面を経由**(誤読・誤タップ対策)
558+
- 「この ID で合っていますか?」+ 大きく ID 表示 + 「やり直す」/「チェックイン / アウト」
559+
- 確認後に `/checkin/scan` 呼び出し
560+
- QR/バーコード読み取りライブラリ: `@zxing/browser``BrowserMultiFormatReader.decodeFromVideoDevice`
561+
562+
**設計意図**: QR スキャナは試験運用フェーズ(Phase 1.5 で本格運用)。手入力フォームを
563+
正規ルートとして残し、QR は opt-in で並走させる。スキャナの起動/停止は React の
564+
`useEffect``mode === 'qr' && state.kind === 'idle'` のときだけ走らせ、確認画面に
565+
遷移した時点で controls.stop() を呼んで二重検出を防ぐ。
558566

559567
#### 7.1.2 初めての方一覧画面
560568

pnpm-lock.yaml

Lines changed: 36 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)