@@ -2,8 +2,10 @@ import { useEffect, useRef, useState } from "react";
2
2
import { TriangleLeftIcon } from "@radix-ui/react-icons" ;
3
3
import { Link } from "@remix-run/react" ;
4
4
import lodash from "lodash" ;
5
+ import { Camera , CameraIcon , QrCode , ScanQrCode } from "lucide-react" ;
5
6
import Webcam from "react-webcam" ;
6
7
import { ClientOnly } from "remix-utils/client-only" ;
8
+ import { Tabs , TabsList , TabsTrigger } from "~/components/shared/tabs" ;
7
9
import { useViewportHeight } from "~/hooks/use-viewport-height" ;
8
10
import { tw } from "~/utils/tw" ;
9
11
import SuccessAnimation from "./success-animation" ;
@@ -26,8 +28,10 @@ type CodeScannerProps = {
26
28
/** Custom message to show when scanner is paused after detecting a code */
27
29
scanMessage ?: string ;
28
30
29
- /** Custom class for the scanner mode */
30
- scannerModeClassName ?: string ;
31
+ /** Custom class for the scanner mode.
32
+ * Can be a string or a function that receives the mode and returns a string
33
+ */
34
+ scannerModeClassName ?: string | ( ( mode : Mode ) => string ) ;
31
35
32
36
/** Custom callback for the scanner mode */
33
37
scannerModeCallback ?: ( input : HTMLInputElement , paused : boolean ) => void ;
@@ -55,12 +59,12 @@ export const CodeScanner = ({
55
59
56
60
const [ mode , setMode ] = useState < Mode > ( isMd ? "scanner" : "camera" ) ;
57
61
58
- const handleModeChange = ( e : React . ChangeEvent < HTMLSelectElement > ) => {
59
- if ( e . target . value === "camera" ) {
62
+ const handleModeChange = ( mode : Mode ) => {
63
+ if ( mode === "camera" ) {
60
64
setIsLoading ( true ) ;
61
- setMode ( e . target . value as Mode ) ;
65
+ setMode ( mode ) ;
62
66
} else {
63
- setMode ( e . target . value as Mode ) ;
67
+ setMode ( mode ) ;
64
68
}
65
69
} ;
66
70
@@ -71,40 +75,38 @@ export const CodeScanner = ({
71
75
"relative size-full min-h-[400px] overflow-hidden" ,
72
76
className
73
77
) }
78
+ data-mode = { mode }
74
79
>
75
80
< div className = "relative size-full overflow-hidden" >
76
- < div className = "absolute inset-x-0 top-0 z-30 flex w-full items-center justify-between bg-transparent text-white " >
81
+ < div className = "absolute inset-x-0 top-0 z-30 flex w-full items-center justify-between bg-white px-4 py-2 text-gray-900 " >
77
82
< div >
78
83
{ ! hideBackButtonText && (
79
84
< Link
80
85
to = ".."
81
- className = "inline-flex items-center justify-start p-2 text-[11px] leading-[11px] text-white "
86
+ className = "inline-flex items-center justify-start text-[11px] leading-[11px] "
82
87
>
83
88
< TriangleLeftIcon className = "size-[14px]" />
84
- < span className = "mt-[-0.5px]" > { backButtonText } </ span >
89
+ < span > { backButtonText } </ span >
85
90
</ Link >
86
91
) }
87
92
</ div >
88
93
89
94
{ /* We only show option to switch to scanner on big screens. Its not possible on mobile */ }
90
95
{ isMd && (
91
96
< div >
92
- < select
93
- value = { mode }
94
- onChange = { handleModeChange }
95
- className = { tw (
96
- "z-10 rounded border py-1 text-sm backdrop-blur-sm" ,
97
- "bg-black/20 text-white"
98
- ) }
99
- disabled = { isLoading || paused }
97
+ < Tabs
98
+ defaultValue = { mode }
99
+ onValueChange = { ( mode ) => handleModeChange ( mode as Mode ) }
100
100
>
101
- < option value = "camera" className = "p-1 text-black" >
102
- Mode: camera
103
- </ option >
104
- < option value = "scanner" className = "p-1 text-black" >
105
- Mode: Barcode scanner
106
- </ option >
107
- </ select >
101
+ < TabsList >
102
+ < TabsTrigger value = "scanner" disabled = { isLoading || paused } >
103
+ < ScanQrCode className = "mr-2 size-5" /> Scanner
104
+ </ TabsTrigger >
105
+ < TabsTrigger value = "camera" disabled = { isLoading || paused } >
106
+ < CameraIcon className = "mr-2 size-5" /> Camera
107
+ </ TabsTrigger >
108
+ </ TabsList >
109
+ </ Tabs >
108
110
</ div >
109
111
) }
110
112
</ div >
@@ -120,7 +122,11 @@ export const CodeScanner = ({
120
122
onQrDetectionSuccess = { onQrDetectionSuccess }
121
123
allowNonShelfCodes = { allowNonShelfCodes }
122
124
paused = { paused }
123
- className = { scannerModeClassName }
125
+ className = {
126
+ typeof scannerModeClassName === "function"
127
+ ? scannerModeClassName ( mode )
128
+ : scannerModeClassName
129
+ }
124
130
callback = { scannerModeCallback }
125
131
/>
126
132
) : (
@@ -200,15 +206,23 @@ function ScannerMode({
200
206
return (
201
207
< div
202
208
className = { tw (
203
- "flex h-full flex-col items-center bg-gray-600 pt-[20px] text-center" ,
209
+ "flex h-full flex-col items-center justify-center bg-slate-800 text-center " ,
204
210
className
205
211
) }
206
212
>
213
+ < RadialBg />
214
+ { /* Pulsating QR Icon */ }
215
+ < div className = "relative mx-auto mb-4 size-16" >
216
+ < div className = "absolute inset-0 flex items-center justify-center" >
217
+ < QrCode className = "size-8 text-white/90" />
218
+ </ div >
219
+ < div className = "animate-ping absolute inset-0 rounded-full border-4 text-white/80 opacity-30" > </ div >
220
+ </ div >
207
221
< Input
208
222
ref = { inputRef }
209
223
autoFocus
210
224
className = "items-center [&_.inner-label]:font-normal [&_.inner-label]:text-white"
211
- inputClassName = "scanner-mode-input max-w-[260px ]"
225
+ inputClassName = "scanner-mode-input max-w-[460px] min-w-[360px ]"
212
226
disabled = { paused }
213
227
name = "code"
214
228
label = {
@@ -218,11 +232,13 @@ function ScannerMode({
218
232
? "Waiting for scan..."
219
233
: "Please click on the text field before scanning"
220
234
}
235
+ icon = { inputIsFocused ? "qr-code" : "mouse-pointer-click" }
236
+ iconClassName = { tw ( "text-gray-600" , ! inputIsFocused && "animate-bounce" ) }
221
237
onChange = { debouncedHandleInputChange }
222
238
onFocus = { ( ) => setInputIsFocused ( true ) }
223
239
onBlur = { ( ) => setInputIsFocused ( false ) }
224
240
/>
225
- < p className = "mt-4 max-w-[260px ] text-white/70" >
241
+ < p className = "mt-4 max-w-[360px ] text-white/70" >
226
242
Focus the field and use your barcode scanner to scan any Shelf QR code.
227
243
</ p >
228
244
</ div >
@@ -317,6 +333,9 @@ function CameraMode({
317
333
{ /* Error State Overlay */ }
318
334
{ error && error !== "" && (
319
335
< InfoOverlay >
336
+ < div className = "mx-auto mb-6 flex size-32 items-center justify-center rounded-lg border-2 border-dashed border-white/30" >
337
+ < Camera className = "size-12 text-white/50" />
338
+ </ div >
320
339
< p className = "mb-4" > { error } </ p >
321
340
< p className = "mb-4" > If the issue persists, please contact support.</ p >
322
341
< Button onClick = { ( ) => window . location . reload ( ) } variant = "secondary" >
@@ -370,8 +389,9 @@ function CameraMode({
370
389
371
390
function InfoOverlay ( { children } : { children : React . ReactNode } ) {
372
391
return (
373
- < div className = "info-overlay absolute inset-0 z-20 flex items-center justify-center bg-gray-900/80 px-5" >
374
- < div className = "text-center text-white " > { children } </ div >
392
+ < div className = "info-overlay absolute inset-0 z-20 flex items-center justify-center bg-slate-800 px-5" >
393
+ < RadialBg />
394
+ < div className = "z-10 text-center text-white" > { children } </ div >
375
395
</ div >
376
396
) ;
377
397
}
@@ -391,6 +411,9 @@ function Initializing() {
391
411
392
412
return (
393
413
< >
414
+ < div className = "mx-auto mb-6 flex size-32 items-center justify-center rounded-lg border-2 border-dashed border-white/30" >
415
+ < Camera className = "size-12 text-white/50" />
416
+ </ div >
394
417
< Spinner className = "mx-auto mb-2" />
395
418
{ expired
396
419
? "Camera initialization is taking longer than expected. Please reload the page"
@@ -409,3 +432,56 @@ function Initializing() {
409
432
</ >
410
433
) ;
411
434
}
435
+
436
+ function RadialBg ( ) {
437
+ return (
438
+ < div className = "absolute inset-0" >
439
+ < div className = "absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(59,130,246,0.3)_0,rgba(59,130,246,0)_50%)]" > </ div >
440
+ < div className = "absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(59,130,246,0.2)_0,rgba(59,130,246,0)_70%)] " > </ div >
441
+ < div className = "absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48Y2lyY2xlIHN0cm9rZT0icmdiYSgyNTUsMjU1LDI1NSwwLjA1KSIgY3g9IjEwIiBjeT0iMTAiIHI9IjEiLz48L2c+PC9zdmc+')] opacity-30" > </ div >
442
+ </ div >
443
+ ) ;
444
+ }
445
+
446
+ export function useGlobalModeViaObserver ( ) : Mode {
447
+ /** Observer to watch for changes in the data-mode attribute */
448
+ const { isMd } = useViewportHeight ( ) ;
449
+ const [ mode , setMode ] = useState < Mode > ( isMd ? "scanner" : "camera" ) ;
450
+ const observerRef = useRef < MutationObserver | null > ( null ) ;
451
+
452
+ useEffect ( ( ) => {
453
+ const targetNode = document . querySelector ( "div[data-mode]" ) ;
454
+
455
+ if ( targetNode ) {
456
+ const config : MutationObserverInit = {
457
+ attributes : true ,
458
+ attributeFilter : [ "data-mode" ] ,
459
+ } ;
460
+
461
+ const callback : MutationCallback = ( mutations ) => {
462
+ mutations . forEach ( ( mutation ) => {
463
+ if (
464
+ mutation . type === "attributes" &&
465
+ mutation . attributeName === "data-mode"
466
+ ) {
467
+ const dataMode = targetNode . getAttribute ( "data-mode" ) ;
468
+ if ( dataMode ) {
469
+ setMode ( dataMode as Mode ) ;
470
+ }
471
+ }
472
+ } ) ;
473
+ } ;
474
+
475
+ observerRef . current = new MutationObserver ( callback ) ;
476
+ observerRef . current . observe ( targetNode , config ) ;
477
+
478
+ return ( ) => {
479
+ if ( observerRef . current ) {
480
+ observerRef . current . disconnect ( ) ;
481
+ }
482
+ } ;
483
+ }
484
+ } , [ ] ) ;
485
+
486
+ return mode ;
487
+ }
0 commit comments