Skip to content

Commit e10de22

Browse files
authored
Merge pull request #1694 from Shelf-nu/1671-feature-request-visual-enhancement-camera-and-handheld-scanner-uxui
update: design of scanning UI for both camera and barcode scanner
2 parents 9877105 + cd136d4 commit e10de22

File tree

6 files changed

+146
-42
lines changed

6 files changed

+146
-42
lines changed

app/components/forms/input.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ export interface InputProps
2525
/** name of any icon available in icons map */
2626
icon?: IconType;
2727

28+
/** Class name for the icon */
29+
iconClassName?: string;
30+
2831
/** Add on to the input. Cannot be used together with icon */
2932
addOn?: string;
3033

@@ -62,13 +65,15 @@ const Input = forwardRef(function Input(
6265
addOn,
6366
onChange,
6467
icon,
68+
iconClassName,
6569
required = false,
6670
...rest
6771
}: InputProps,
6872
ref
6973
) {
7074
const iconClasses = tw(
71-
"pointer-events-none absolute flex h-full items-center border-gray-300 px-[14px]"
75+
"pointer-events-none absolute flex h-full items-center border-gray-300 px-[14px]",
76+
iconClassName
7277
);
7378

7479
const addonClasses = tw(

app/components/scanner/drawer.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,13 @@ import { Button } from "../shared/button";
2828

2929
import { Table, Td, Th } from "../table";
3030
import When from "../when/when";
31+
import { useGlobalModeViaObserver } from "../zxing-scanner/code-scanner";
3132

3233
type ScannedAssetsDrawerProps = {
3334
className?: string;
3435
style?: React.CSSProperties;
3536
isLoading?: boolean;
37+
defaultExpanded?: boolean;
3638
};
3739

3840
export const addScannedAssetsToBookingSchema = z.object({
@@ -49,6 +51,7 @@ export default function ScannedAssetsDrawer({
4951
className,
5052
style,
5153
isLoading,
54+
defaultExpanded = false,
5255
}: ScannedAssetsDrawerProps) {
5356
const zo = useZorm(
5457
"AddScannedAssetsToBooking",
@@ -74,7 +77,9 @@ export default function ScannedAssetsDrawer({
7477
const itemsLength = Object.keys(items).length;
7578
const hasItems = itemsLength > 0;
7679

77-
const [expanded, setExpanded] = useState(false);
80+
const [expanded, setExpanded] = useState(
81+
defaultExpanded !== undefined ? defaultExpanded : false
82+
);
7883
const { vh } = useViewportHeight();
7984

8085
const itemsListRef = useRef<HTMLDivElement>(null);
@@ -118,16 +123,27 @@ export default function ScannedAssetsDrawer({
118123
[clearList, hasItems]
119124
);
120125

126+
const mode = useGlobalModeViaObserver();
127+
useEffect(() => {
128+
setExpanded(mode === "scanner");
129+
}, [mode]);
130+
121131
return (
122132
<Portal>
123133
<div
124134
className={tw(
125-
"fixed inset-x-0 bottom-0 rounded-t-3xl border bg-white transition-all duration-300 ease-in-out",
126-
minimizedSidebar ? "lg:left-[82px]" : "lg:left-[312px]",
135+
"fixed inset-x-0 bottom-0 rounded-t-3xl border bg-white transition-all duration-300 ease-in-out lg:right-[20px]",
136+
minimizedSidebar ? "lg:left-[68px]" : "lg:left-[278px]",
127137
className
128138
)}
129139
style={{
130-
height: expanded ? vh - TOP_GAP : hasItems ? 170 : 148,
140+
height: expanded
141+
? mode === "scanner"
142+
? vh - 400
143+
: vh - TOP_GAP
144+
: hasItems
145+
? 170
146+
: 148,
131147
}}
132148
>
133149
<div className={tw("h-full")} style={style}>

app/components/shared/icons-map.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CalendarIcon, RowsIcon } from "@radix-ui/react-icons";
2-
import { CalendarCheck } from "lucide-react";
2+
import { CalendarCheck, MousePointerClick, QrCode } from "lucide-react";
33
import { Spinner } from "./spinner";
44

55
import {
@@ -121,7 +121,9 @@ export type IconType =
121121
| "unavailable"
122122
| "change"
123123
| "booking-exist"
124-
| "download-qr";
124+
| "download-qr"
125+
| "qr-code"
126+
| "mouse-pointer-click";
125127

126128
type IconsMap = {
127129
[key in IconType]: JSX.Element;
@@ -189,6 +191,8 @@ export const iconsMap: IconsMap = {
189191
change: <ChangeIcon />,
190192
"booking-exist": <CalendarCheck />,
191193
"download-qr": <DownloadIcon />,
194+
"qr-code": <QrCode />,
195+
"mouse-pointer-click": <MousePointerClick />,
192196
};
193197

194198
export default iconsMap;

app/components/zxing-scanner/code-scanner.tsx

Lines changed: 106 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { useEffect, useRef, useState } from "react";
22
import { TriangleLeftIcon } from "@radix-ui/react-icons";
33
import { Link } from "@remix-run/react";
44
import lodash from "lodash";
5+
import { Camera, CameraIcon, QrCode, ScanQrCode } from "lucide-react";
56
import Webcam from "react-webcam";
67
import { ClientOnly } from "remix-utils/client-only";
8+
import { Tabs, TabsList, TabsTrigger } from "~/components/shared/tabs";
79
import { useViewportHeight } from "~/hooks/use-viewport-height";
810
import { tw } from "~/utils/tw";
911
import SuccessAnimation from "./success-animation";
@@ -26,8 +28,10 @@ type CodeScannerProps = {
2628
/** Custom message to show when scanner is paused after detecting a code */
2729
scanMessage?: string;
2830

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);
3135

3236
/** Custom callback for the scanner mode */
3337
scannerModeCallback?: (input: HTMLInputElement, paused: boolean) => void;
@@ -55,12 +59,12 @@ export const CodeScanner = ({
5559

5660
const [mode, setMode] = useState<Mode>(isMd ? "scanner" : "camera");
5761

58-
const handleModeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
59-
if (e.target.value === "camera") {
62+
const handleModeChange = (mode: Mode) => {
63+
if (mode === "camera") {
6064
setIsLoading(true);
61-
setMode(e.target.value as Mode);
65+
setMode(mode);
6266
} else {
63-
setMode(e.target.value as Mode);
67+
setMode(mode);
6468
}
6569
};
6670

@@ -71,40 +75,38 @@ export const CodeScanner = ({
7175
"relative size-full min-h-[400px] overflow-hidden",
7276
className
7377
)}
78+
data-mode={mode}
7479
>
7580
<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">
7782
<div>
7883
{!hideBackButtonText && (
7984
<Link
8085
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] "
8287
>
8388
<TriangleLeftIcon className="size-[14px]" />
84-
<span className="mt-[-0.5px]">{backButtonText}</span>
89+
<span>{backButtonText}</span>
8590
</Link>
8691
)}
8792
</div>
8893

8994
{/* We only show option to switch to scanner on big screens. Its not possible on mobile */}
9095
{isMd && (
9196
<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)}
100100
>
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>
108110
</div>
109111
)}
110112
</div>
@@ -120,7 +122,11 @@ export const CodeScanner = ({
120122
onQrDetectionSuccess={onQrDetectionSuccess}
121123
allowNonShelfCodes={allowNonShelfCodes}
122124
paused={paused}
123-
className={scannerModeClassName}
125+
className={
126+
typeof scannerModeClassName === "function"
127+
? scannerModeClassName(mode)
128+
: scannerModeClassName
129+
}
124130
callback={scannerModeCallback}
125131
/>
126132
) : (
@@ -200,15 +206,23 @@ function ScannerMode({
200206
return (
201207
<div
202208
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 ",
204210
className
205211
)}
206212
>
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>
207221
<Input
208222
ref={inputRef}
209223
autoFocus
210224
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]"
212226
disabled={paused}
213227
name="code"
214228
label={
@@ -218,11 +232,13 @@ function ScannerMode({
218232
? "Waiting for scan..."
219233
: "Please click on the text field before scanning"
220234
}
235+
icon={inputIsFocused ? "qr-code" : "mouse-pointer-click"}
236+
iconClassName={tw("text-gray-600", !inputIsFocused && "animate-bounce")}
221237
onChange={debouncedHandleInputChange}
222238
onFocus={() => setInputIsFocused(true)}
223239
onBlur={() => setInputIsFocused(false)}
224240
/>
225-
<p className="mt-4 max-w-[260px] text-white/70">
241+
<p className="mt-4 max-w-[360px] text-white/70">
226242
Focus the field and use your barcode scanner to scan any Shelf QR code.
227243
</p>
228244
</div>
@@ -317,6 +333,9 @@ function CameraMode({
317333
{/* Error State Overlay */}
318334
{error && error !== "" && (
319335
<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>
320339
<p className="mb-4">{error}</p>
321340
<p className="mb-4">If the issue persists, please contact support.</p>
322341
<Button onClick={() => window.location.reload()} variant="secondary">
@@ -370,8 +389,9 @@ function CameraMode({
370389

371390
function InfoOverlay({ children }: { children: React.ReactNode }) {
372391
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>
375395
</div>
376396
);
377397
}
@@ -391,6 +411,9 @@ function Initializing() {
391411

392412
return (
393413
<>
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>
394417
<Spinner className="mx-auto mb-2" />
395418
{expired
396419
? "Camera initialization is taking longer than expected. Please reload the page"
@@ -409,3 +432,56 @@ function Initializing() {
409432
</>
410433
);
411434
}
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+
}

app/routes/_layout+/bookings.$bookingId.scan-assets.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
PermissionEntity,
4141
} from "~/utils/permissions/permission.data";
4242
import { requirePermission } from "~/utils/roles.server";
43+
import { tw } from "~/utils/tw";
4344

4445
export const links: LinksFunction = () => [
4546
{ rel: "stylesheet", href: scannerCss },
@@ -153,7 +154,6 @@ export default function ScanAssetsForBookings() {
153154

154155
const { vh, isMd } = useViewportHeight();
155156
const height = isMd ? vh - 67 : vh - 100;
156-
157157
function handleQrDetectionSuccess(qrId: string, error?: string) {
158158
/** WE send the error to the item. addItem will automatically handle the data based on its value */
159159
addItem(qrId, error);
@@ -173,6 +173,9 @@ export default function ScanAssetsForBookings() {
173173
allowNonShelfCodes
174174
paused={false}
175175
setPaused={() => {}}
176+
scannerModeClassName={(mode) =>
177+
tw(mode === "scanner" && "justify-start pt-[100px]")
178+
}
176179
/>
177180
</div>
178181
</>

vite.config.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ const buildHash = process.env.BUILD_HASH || createHash();
1717
export default defineConfig({
1818
server: {
1919
port: 3000,
20-
// https: {
21-
// key: "./.cert/key.pem",
22-
// cert: "./.cert/cert.pem",
23-
// },
20+
https: {
21+
key: "./.cert/key.pem",
22+
cert: "./.cert/cert.pem",
23+
},
2424
warmup: {
2525
clientFiles: [
2626
"./app/entry.client.tsx",

0 commit comments

Comments
 (0)