@opexa/portal-components 0.0.230 → 0.0.232
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/hooks/useCamera.d.ts +1 -0
- package/dist/client/hooks/useCamera.js +12 -7
- package/dist/client/hooks/useFeatureFlag.d.ts +1 -1
- package/dist/client/hooks/useFeatureFlag.js +1 -19
- package/dist/components/FeatureFlag/FeatureFlag.js +21 -2
- package/dist/components/FeatureFlag/index.d.ts +1 -0
- package/dist/components/FeatureFlag/index.js +1 -0
- package/dist/components/shared/IdDocumentField.js +117 -48
- package/dist/components/shared/SelfieField.js +105 -43
- package/dist/utils/validateFile.js +8 -5
- package/package.json +4 -1
|
@@ -28,6 +28,7 @@ export interface UseCameraReturn {
|
|
|
28
28
|
data: CameraData | null;
|
|
29
29
|
error: CameraError | null;
|
|
30
30
|
loading: boolean;
|
|
31
|
+
videoRef: React.RefObject<HTMLVideoElement | null>;
|
|
31
32
|
videoProps: ComponentPropsWithRef<'video'>;
|
|
32
33
|
}
|
|
33
34
|
export interface UseCameraOptions extends MediaStreamConstraints {
|
|
@@ -3,7 +3,7 @@ import { useCallback, useMemo, useRef, useState, } from 'react';
|
|
|
3
3
|
import invariant from 'tiny-invariant';
|
|
4
4
|
export function useCamera(options = {}) {
|
|
5
5
|
const { validate, ...constraints } = options;
|
|
6
|
-
const
|
|
6
|
+
const videoRef = useRef(null);
|
|
7
7
|
const stream = useRef(null);
|
|
8
8
|
const [data, setData] = useState(null);
|
|
9
9
|
const [error, setError] = useState(null);
|
|
@@ -13,7 +13,7 @@ export function useCamera(options = {}) {
|
|
|
13
13
|
setError(null);
|
|
14
14
|
setLoading(true);
|
|
15
15
|
await sleep();
|
|
16
|
-
const node =
|
|
16
|
+
const node = videoRef.current;
|
|
17
17
|
if (!node) {
|
|
18
18
|
setLoading(false);
|
|
19
19
|
setError({
|
|
@@ -74,18 +74,18 @@ export function useCamera(options = {}) {
|
|
|
74
74
|
setError(null);
|
|
75
75
|
return new Promise((resolve) => {
|
|
76
76
|
stream.current?.getTracks().forEach((track) => track.stop());
|
|
77
|
-
|
|
77
|
+
videoRef.current?.pause();
|
|
78
78
|
resolve();
|
|
79
79
|
});
|
|
80
80
|
}, [setData, setError]);
|
|
81
81
|
const snap = useCallback(async (opts = {}) => {
|
|
82
82
|
setData(null);
|
|
83
83
|
setError(null);
|
|
84
|
-
const video =
|
|
84
|
+
const video = videoRef.current;
|
|
85
85
|
const canvas = document.createElement('canvas');
|
|
86
86
|
const context = canvas.getContext('2d');
|
|
87
|
-
invariant(video);
|
|
88
|
-
invariant(context);
|
|
87
|
+
invariant(video, 'Could not find video element');
|
|
88
|
+
invariant(context, 'Could not get canvas context');
|
|
89
89
|
video.currentTime = 1;
|
|
90
90
|
const videoRect = video.getBoundingClientRect();
|
|
91
91
|
const lowResolution = video.videoWidth < 640 || video.videoHeight < 480;
|
|
@@ -149,14 +149,18 @@ export function useCamera(options = {}) {
|
|
|
149
149
|
await open();
|
|
150
150
|
}, [close, open]);
|
|
151
151
|
const videoProps = useMemo(() => ({
|
|
152
|
-
ref,
|
|
152
|
+
ref: videoRef,
|
|
153
153
|
muted: true,
|
|
154
154
|
autoPlay: true,
|
|
155
155
|
playsInline: true,
|
|
156
|
+
preload: 'none',
|
|
156
157
|
style: {
|
|
157
158
|
width: '100%',
|
|
158
159
|
height: 'auto',
|
|
159
160
|
display: 'block',
|
|
161
|
+
position: 'relative',
|
|
162
|
+
boxSizing: 'border-box',
|
|
163
|
+
background: 'transparent',
|
|
160
164
|
},
|
|
161
165
|
}), []);
|
|
162
166
|
return {
|
|
@@ -168,6 +172,7 @@ export function useCamera(options = {}) {
|
|
|
168
172
|
data,
|
|
169
173
|
error,
|
|
170
174
|
loading,
|
|
175
|
+
videoRef,
|
|
171
176
|
videoProps,
|
|
172
177
|
};
|
|
173
178
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export declare const FEATURE_FLAG_KEY = "FEATURE_FLAG_ENABLED";
|
|
2
2
|
export declare const useFeatureFlag: () => {
|
|
3
3
|
featureFlagEnabled: boolean;
|
|
4
|
-
|
|
4
|
+
setFeatureFlagEnabled: import("react").Dispatch<import("react").SetStateAction<boolean>>;
|
|
5
5
|
};
|
|
@@ -1,34 +1,16 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { useRouter, useSearchParams } from 'next/navigation';
|
|
3
2
|
import { useEffect, useState } from 'react';
|
|
4
3
|
export const FEATURE_FLAG_KEY = 'FEATURE_FLAG_ENABLED';
|
|
5
4
|
export const useFeatureFlag = () => {
|
|
6
5
|
const [featureFlagEnabled, setFeatureFlagEnabled] = useState(false);
|
|
7
|
-
const searchParams = useSearchParams();
|
|
8
|
-
const router = useRouter();
|
|
9
|
-
useEffect(() => {
|
|
10
|
-
const futureFlagParam = searchParams.get('future');
|
|
11
|
-
if (futureFlagParam !== null) {
|
|
12
|
-
const isEnabled = futureFlagParam === 'true';
|
|
13
|
-
setFeatureFlagEnabled(isEnabled);
|
|
14
|
-
localStorage.setItem(FEATURE_FLAG_KEY, isEnabled.toString());
|
|
15
|
-
}
|
|
16
|
-
}, [searchParams]);
|
|
17
6
|
useEffect(() => {
|
|
18
7
|
const storedFlag = localStorage.getItem(FEATURE_FLAG_KEY);
|
|
19
8
|
if (storedFlag !== null) {
|
|
20
9
|
setFeatureFlagEnabled(storedFlag === 'true');
|
|
21
10
|
}
|
|
22
11
|
}, []);
|
|
23
|
-
function disableFeatureFlag() {
|
|
24
|
-
setFeatureFlagEnabled(false);
|
|
25
|
-
localStorage.removeItem(FEATURE_FLAG_KEY);
|
|
26
|
-
const params = new URLSearchParams(searchParams.toString());
|
|
27
|
-
params.delete('future');
|
|
28
|
-
router.replace(`?${params.toString()}`, { scroll: false });
|
|
29
|
-
}
|
|
30
12
|
return {
|
|
31
13
|
featureFlagEnabled,
|
|
32
|
-
|
|
14
|
+
setFeatureFlagEnabled,
|
|
33
15
|
};
|
|
34
16
|
};
|
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
3
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
4
|
+
import { useEffect } from 'react';
|
|
5
|
+
import { FEATURE_FLAG_KEY, useFeatureFlag, } from '../../client/hooks/useFeatureFlag.js';
|
|
4
6
|
export function FeatureFlag() {
|
|
5
|
-
const { featureFlagEnabled,
|
|
7
|
+
const { featureFlagEnabled, setFeatureFlagEnabled } = useFeatureFlag();
|
|
8
|
+
const searchParams = useSearchParams();
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const futureFlagParam = searchParams.get('future');
|
|
12
|
+
if (futureFlagParam !== null) {
|
|
13
|
+
const isEnabled = futureFlagParam === 'true';
|
|
14
|
+
setFeatureFlagEnabled(isEnabled);
|
|
15
|
+
localStorage.setItem(FEATURE_FLAG_KEY, isEnabled.toString());
|
|
16
|
+
}
|
|
17
|
+
}, [searchParams, setFeatureFlagEnabled]);
|
|
18
|
+
function disableFeatureFlag() {
|
|
19
|
+
setFeatureFlagEnabled(false);
|
|
20
|
+
localStorage.removeItem(FEATURE_FLAG_KEY);
|
|
21
|
+
const params = new URLSearchParams(searchParams.toString());
|
|
22
|
+
params.delete('future');
|
|
23
|
+
router.replace(`?${params.toString()}`, { scroll: false });
|
|
24
|
+
}
|
|
6
25
|
if (!featureFlagEnabled) {
|
|
7
26
|
return null;
|
|
8
27
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './FeatureFlag';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './FeatureFlag.js';
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useFieldContext } from '@ark-ui/react';
|
|
4
4
|
import Image from 'next/image';
|
|
5
|
-
import { useRef } from 'react';
|
|
5
|
+
import { useId, useLayoutEffect, useRef, useState, } from 'react';
|
|
6
6
|
import { twMerge } from 'tailwind-merge';
|
|
7
7
|
import { useMediaQuery } from 'usehooks-ts';
|
|
8
8
|
import { useCamera } from '../../client/hooks/useCamera.js';
|
|
@@ -10,6 +10,7 @@ import { useControllableState } from '../../client/hooks/useControllableState.js
|
|
|
10
10
|
import { useDisclosure } from '../../client/hooks/useDisclosure.js';
|
|
11
11
|
import { useFileQuery } from '../../client/hooks/useFileQuery.js';
|
|
12
12
|
import { useUploadImageFileMutation } from '../../client/hooks/useUploadImageFileMutation.js';
|
|
13
|
+
import { createContext } from '../../client/utils/createContext.js';
|
|
13
14
|
import { toaster } from '../../client/utils/toaster.js';
|
|
14
15
|
import { Camera01Icon } from '../../icons/Camera01Icon.js';
|
|
15
16
|
import { CameraOffIcon } from '../../icons/CameraOffIcon.js';
|
|
@@ -24,52 +25,9 @@ import { Portal } from '../../ui/Portal/index.js';
|
|
|
24
25
|
import { dataAttr } from '../../utils/dataAttr.js';
|
|
25
26
|
import { validateFile } from '../../utils/validateFile.js';
|
|
26
27
|
export function IdDocumentField(props) {
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
defaultValue: props.defaultValue ?? '',
|
|
31
|
-
onChange: props.onChange,
|
|
32
|
-
});
|
|
33
|
-
const query = useFileQuery(value, {
|
|
34
|
-
refetchInterval({ state }) {
|
|
35
|
-
return !state.data || state.data.status === 'UPLOADING' ? 1000 : false;
|
|
36
|
-
},
|
|
37
|
-
});
|
|
38
|
-
const mutation = useUploadImageFileMutation({
|
|
39
|
-
onSuccess(id) {
|
|
40
|
-
setValue(id);
|
|
41
|
-
},
|
|
42
|
-
onMutate() {
|
|
43
|
-
setValue('');
|
|
44
|
-
},
|
|
45
|
-
onError(error) {
|
|
46
|
-
toaster.error({
|
|
47
|
-
title: 'Upload Failed',
|
|
48
|
-
description: error.message,
|
|
49
|
-
});
|
|
50
|
-
},
|
|
51
|
-
onSettled() {
|
|
52
|
-
if (inputRef.current) {
|
|
53
|
-
inputRef.current.value = '';
|
|
54
|
-
}
|
|
55
|
-
},
|
|
56
|
-
});
|
|
57
|
-
const inputRef = useRef(null);
|
|
58
|
-
const guideRef = useRef(null);
|
|
59
|
-
const desktop = useMediaQuery('(min-width: 1024px)');
|
|
60
|
-
const camera = useCamera({
|
|
61
|
-
video: {
|
|
62
|
-
facingMode: desktop ? 'user' : 'environment',
|
|
63
|
-
},
|
|
64
|
-
});
|
|
65
|
-
const disclosure = useDisclosure({
|
|
66
|
-
onOpenChange(open) {
|
|
67
|
-
if (open) {
|
|
68
|
-
camera.open();
|
|
69
|
-
}
|
|
70
|
-
},
|
|
71
|
-
});
|
|
72
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: props.ref, id: props.id, style: props.style, className: twMerge('border-border-primary bg-bg-primary group relative aspect-[352/180] w-full shrink-0 rounded-xl border p-xl', props.className), onDragOver: (e) => e.preventDefault(), onDragEnter: (e) => e.preventDefault(), onDragLeave: (e) => e.preventDefault(), onDrop: (e) => {
|
|
28
|
+
const context = useIdDocumentField(props);
|
|
29
|
+
const { field, setValue, query, mutation, inputRef, guideRef, camera, disclosure, } = context;
|
|
30
|
+
return (_jsxs(IdDocumentFieldProvider, { value: context, children: [_jsxs("div", { ref: props.ref, id: props.id, style: props.style, className: twMerge('border-border-primary bg-bg-primary group relative aspect-[352/180] w-full shrink-0 rounded-xl border p-xl', props.className), onDragOver: (e) => e.preventDefault(), onDragEnter: (e) => e.preventDefault(), onDragLeave: (e) => e.preventDefault(), onDrop: (e) => {
|
|
73
31
|
e.preventDefault();
|
|
74
32
|
const file = e.dataTransfer.files?.[0];
|
|
75
33
|
if (!file)
|
|
@@ -109,7 +67,7 @@ export function IdDocumentField(props) {
|
|
|
109
67
|
disclosure.setOpen(details.open);
|
|
110
68
|
}, onExitComplete: camera.close, children: _jsxs(Portal, { children: [_jsx(Dialog.Backdrop, { className: "!z-[calc(var(--z-dialog)+1)]" }), _jsx(Dialog.Positioner, { className: "!z-[calc(var(--z-dialog)+2)] flex items-center justify-center overflow-y-auto py-4", children: _jsxs(Dialog.Content, { className: "bg-bg-primary-alt mx-auto w-[calc(100dvw-1rem)] max-w-[calc(100dvw-1rem)] rounded-lg px-4 py-5 lg:w-[747px] lg:max-w-[747px] lg:px-3xl lg:py-4xl", children: [_jsx(Dialog.CloseTrigger, { children: _jsx(XIcon, {}) }), _jsx(Dialog.Title, { className: "text-center font-semibold lg:text-xl", children: "Take a Picture of Your Front ID" }), _jsxs(Dialog.Description, { className: "text-text-tertiary-600 mt-md text-center text-xs lg:text-base", children: ["Make sure your ID is clearly visible, well-lit, and not blurry.", ' ', _jsx("br", { className: "hidden lg:block" }), "Avoid glare or reflections, and ensure all corners are within\u00A0the\u00A0frame."] }), _jsxs("div", { className: "relative mt-5 lg:mt-10 lg:px-3xl", children: [_jsxs("div", { className: twMerge('border-border-disabled relative w-full overflow-hidden rounded-md border bg-black lg:rounded-lg', !camera.data && !camera.error && !camera.loading
|
|
111
69
|
? 'block'
|
|
112
|
-
: 'hidden'), children: [_jsx("video", { ...camera.videoProps }), _jsx(
|
|
70
|
+
: 'hidden'), children: [_jsx("video", { ...camera.videoProps }), _jsx(Mask, {})] }), camera.error && (_jsxs("div", { className: "border-border-disabled flex aspect-[85.60/53.98] flex-col items-center justify-center rounded-md border bg-black px-4 lg:rounded-lg", children: [_jsx(CameraOffIcon, { className: "text-text-placeholder-subtle size-10 text-center lg:size-12" }), _jsx("h2", { className: "mt-3 text-sm font-semibold lg:mt-4 lg:text-base", children: camera.error.name }), _jsx("p", { className: "text-text-tertiary-600 mt-0.5 text-center text-xs lg:mt-1 lg:text-sm", children: camera.error.message }), _jsx(Button, { size: "xs", variant: "outline", colorScheme: "gray", fullWidth: false, className: "mt-4 lg:mt-5", onClick: () => {
|
|
113
71
|
camera.reopen();
|
|
114
72
|
}, children: "Try again" })] })), camera.loading && (_jsx("div", { className: "border-border-disabled flex aspect-[85.60/53.98] flex-col items-center justify-center rounded-md border bg-black px-4 lg:rounded-lg", children: _jsx(Spinner02Icon, { className: "text-text-placeholder size-8" }) })), camera.data && (_jsx("div", { className: "border-border-disabled h-auto w-full overflow-hidden rounded-md border bg-black lg:rounded-lg", children: _jsx(Image, { src: camera.data.url, alt: "", width: 600, height: 378.08, unoptimized: true, className: "block h-auto w-full" }) }))] }), !camera.data && !camera.error && !camera.loading && (_jsx("div", { className: "mt-5 lg:mt-10", children: _jsxs(Button, { className: "disabled:bg-bg-disabled disabled:text-text-disabled mx-auto w-full px-xl lg:w-auto", onClick: () => {
|
|
115
73
|
const rect = guideRef.current?.getBoundingClientRect();
|
|
@@ -132,3 +90,114 @@ export function IdDocumentField(props) {
|
|
|
132
90
|
camera.reset();
|
|
133
91
|
}, children: [_jsx(RefreshCcw01Icon, { className: "size-5" }), " Retake"] })] }))] }) })] }) })] }));
|
|
134
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* -------------------------------------
|
|
95
|
+
* Mask
|
|
96
|
+
* -------------------------------------
|
|
97
|
+
*/
|
|
98
|
+
function Mask() {
|
|
99
|
+
const id = useId();
|
|
100
|
+
const { guideRef, maskRef } = useIdDocumentFieldContext();
|
|
101
|
+
const [maskBox, setMaskBox] = useState({
|
|
102
|
+
x: 0,
|
|
103
|
+
y: 0,
|
|
104
|
+
width: 0,
|
|
105
|
+
height: 0,
|
|
106
|
+
});
|
|
107
|
+
useLayoutEffect(() => {
|
|
108
|
+
const update = () => {
|
|
109
|
+
const guide = guideRef.current;
|
|
110
|
+
const mask = maskRef.current;
|
|
111
|
+
if (!guide || !mask)
|
|
112
|
+
return;
|
|
113
|
+
const guideRect = guide.getBoundingClientRect();
|
|
114
|
+
const maskRect = mask.getBoundingClientRect();
|
|
115
|
+
const x = guideRect.left - maskRect.left;
|
|
116
|
+
const y = guideRect.top - maskRect.top;
|
|
117
|
+
const width = guideRect.width;
|
|
118
|
+
const height = guideRect.height;
|
|
119
|
+
if (width && height) {
|
|
120
|
+
setMaskBox({
|
|
121
|
+
x,
|
|
122
|
+
y,
|
|
123
|
+
width,
|
|
124
|
+
height,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const observer = new ResizeObserver(update);
|
|
129
|
+
if (guideRef.current)
|
|
130
|
+
observer.observe(guideRef.current);
|
|
131
|
+
if (maskRef.current)
|
|
132
|
+
observer.observe(maskRef.current);
|
|
133
|
+
requestAnimationFrame(update);
|
|
134
|
+
return () => observer.disconnect();
|
|
135
|
+
}, [guideRef, maskRef]);
|
|
136
|
+
return (_jsxs(_Fragment, { children: [_jsx("svg", { className: "pointer-events-none absolute h-full w-full", children: _jsx("defs", { children: _jsxs("mask", { id: id, maskUnits: "userSpaceOnUse", children: [_jsx("rect", { x: "0", y: "0", width: "100%", height: "100%", fill: "white" }), _jsx("rect", { x: maskBox.x, y: maskBox.y, width: maskBox.width, height: maskBox.height, fill: "black", rx: "8", ry: "8" })] }) }) }), _jsx("div", { ref: maskRef, className: "absolute inset-0 bg-black/60 backdrop-blur-sm", style: {
|
|
137
|
+
mask: `url(#${id})`,
|
|
138
|
+
WebkitMask: `url(#${id})`,
|
|
139
|
+
maskRepeat: 'no-repeat',
|
|
140
|
+
WebkitMaskRepeat: 'no-repeat',
|
|
141
|
+
} }), _jsx("div", { ref: guideRef, className: "border-utility-warning-700 pointer-events-none absolute left-1/2 top-1/2 z-10 aspect-[85.6/53.98] w-[calc(100%-1rem)] max-w-[24.688rem] -translate-x-1/2 -translate-y-1/2 rounded-md border-2 border-dashed" })] }));
|
|
142
|
+
}
|
|
143
|
+
function useIdDocumentField(props) {
|
|
144
|
+
const field = useFieldContext();
|
|
145
|
+
const [value, setValue] = useControllableState({
|
|
146
|
+
value: props.value,
|
|
147
|
+
defaultValue: props.defaultValue ?? '',
|
|
148
|
+
onChange: props.onChange,
|
|
149
|
+
});
|
|
150
|
+
const query = useFileQuery(value, {
|
|
151
|
+
refetchInterval({ state }) {
|
|
152
|
+
return !state.data || state.data.status === 'UPLOADING' ? 1000 : false;
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
const mutation = useUploadImageFileMutation({
|
|
156
|
+
onSuccess(id) {
|
|
157
|
+
setValue(id);
|
|
158
|
+
},
|
|
159
|
+
onMutate() {
|
|
160
|
+
setValue('');
|
|
161
|
+
},
|
|
162
|
+
onError(error) {
|
|
163
|
+
toaster.error({
|
|
164
|
+
title: 'Upload Failed',
|
|
165
|
+
description: error.message,
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
onSettled() {
|
|
169
|
+
if (inputRef.current) {
|
|
170
|
+
inputRef.current.value = '';
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
const inputRef = useRef(null);
|
|
175
|
+
const maskRef = useRef(null);
|
|
176
|
+
const guideRef = useRef(null);
|
|
177
|
+
const desktop = useMediaQuery('(min-width: 1024px)');
|
|
178
|
+
const camera = useCamera({
|
|
179
|
+
video: {
|
|
180
|
+
facingMode: desktop ? 'user' : 'environment',
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
const disclosure = useDisclosure({
|
|
184
|
+
onOpenChange(open) {
|
|
185
|
+
if (open) {
|
|
186
|
+
camera.open();
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
return {
|
|
191
|
+
field,
|
|
192
|
+
value,
|
|
193
|
+
setValue,
|
|
194
|
+
query,
|
|
195
|
+
mutation,
|
|
196
|
+
camera,
|
|
197
|
+
disclosure,
|
|
198
|
+
inputRef,
|
|
199
|
+
guideRef,
|
|
200
|
+
maskRef,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const [IdDocumentFieldProvider, useIdDocumentFieldContext] = createContext();
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useFieldContext } from '@ark-ui/react';
|
|
4
4
|
import Image from 'next/image';
|
|
5
|
-
import { useRef } from 'react';
|
|
5
|
+
import { useId, useLayoutEffect, useRef, useState, } from 'react';
|
|
6
6
|
import { twMerge } from 'tailwind-merge';
|
|
7
7
|
import { useCamera } from '../../client/hooks/useCamera.js';
|
|
8
8
|
import { useControllableState } from '../../client/hooks/useControllableState.js';
|
|
9
9
|
import { useDisclosure } from '../../client/hooks/useDisclosure.js';
|
|
10
10
|
import { useFileQuery } from '../../client/hooks/useFileQuery.js';
|
|
11
11
|
import { useUploadImageFileMutation } from '../../client/hooks/useUploadImageFileMutation.js';
|
|
12
|
+
import { createContext } from '../../client/utils/createContext.js';
|
|
12
13
|
import { toaster } from '../../client/utils/toaster.js';
|
|
13
14
|
import { Camera01Icon } from '../../icons/Camera01Icon.js';
|
|
14
15
|
import { CameraOffIcon } from '../../icons/CameraOffIcon.js';
|
|
@@ -23,47 +24,9 @@ import { Portal } from '../../ui/Portal/index.js';
|
|
|
23
24
|
import { dataAttr } from '../../utils/dataAttr.js';
|
|
24
25
|
import { validateFile } from '../../utils/validateFile.js';
|
|
25
26
|
export function SelfieField(props) {
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
defaultValue: props.defaultValue ?? '',
|
|
30
|
-
onChange: props.onChange,
|
|
31
|
-
});
|
|
32
|
-
const query = useFileQuery(value, {
|
|
33
|
-
refetchInterval({ state }) {
|
|
34
|
-
return !state.data || state.data.status === 'UPLOADING' ? 1000 : false;
|
|
35
|
-
},
|
|
36
|
-
});
|
|
37
|
-
const mutation = useUploadImageFileMutation({
|
|
38
|
-
onSuccess(id) {
|
|
39
|
-
setValue(id);
|
|
40
|
-
},
|
|
41
|
-
onMutate() {
|
|
42
|
-
setValue('');
|
|
43
|
-
},
|
|
44
|
-
onError(error) {
|
|
45
|
-
toaster.error({
|
|
46
|
-
title: 'Upload Failed',
|
|
47
|
-
description: error.message,
|
|
48
|
-
});
|
|
49
|
-
},
|
|
50
|
-
onSettled() {
|
|
51
|
-
if (inputRef.current) {
|
|
52
|
-
inputRef.current.value = '';
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
|
-
});
|
|
56
|
-
const inputRef = useRef(null);
|
|
57
|
-
const guideRef = useRef(null);
|
|
58
|
-
const camera = useCamera();
|
|
59
|
-
const disclosure = useDisclosure({
|
|
60
|
-
onOpenChange(open) {
|
|
61
|
-
if (open) {
|
|
62
|
-
camera.open();
|
|
63
|
-
}
|
|
64
|
-
},
|
|
65
|
-
});
|
|
66
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: props.ref, id: props.id, style: props.style, className: twMerge('border-border-primary bg-bg-primary group relative aspect-[352/180] w-full shrink-0 rounded-xl border p-xl', props.className), onDragOver: (e) => e.preventDefault(), onDragEnter: (e) => e.preventDefault(), onDragLeave: (e) => e.preventDefault(), onDrop: (e) => {
|
|
27
|
+
const context = useSelfieField(props);
|
|
28
|
+
const { field, setValue, query, mutation, camera, disclosure, inputRef, guideRef, } = context;
|
|
29
|
+
return (_jsxs(SelfieFieldProvider, { value: context, children: [_jsxs("div", { ref: props.ref, id: props.id, style: props.style, className: twMerge('border-border-primary bg-bg-primary group relative aspect-[352/180] w-full shrink-0 rounded-xl border p-xl', props.className), onDragOver: (e) => e.preventDefault(), onDragEnter: (e) => e.preventDefault(), onDragLeave: (e) => e.preventDefault(), onDrop: (e) => {
|
|
67
30
|
e.preventDefault();
|
|
68
31
|
const file = e.dataTransfer.files?.[0];
|
|
69
32
|
if (!file)
|
|
@@ -103,7 +66,7 @@ export function SelfieField(props) {
|
|
|
103
66
|
disclosure.setOpen(details.open);
|
|
104
67
|
}, onExitComplete: camera.close, children: _jsxs(Portal, { children: [_jsx(Dialog.Backdrop, { className: "!z-[calc(var(--z-dialog)+1)]" }), _jsx(Dialog.Positioner, { className: "!z-[calc(var(--z-dialog)+2)] flex items-center justify-center overflow-y-auto py-4", children: _jsxs(Dialog.Content, { className: "bg-bg-primary-alt mx-auto w-[calc(100dvw-1rem)] max-w-[calc(100dvw-1rem)] rounded-lg px-4 py-5 lg:w-[747px] lg:max-w-[747px] lg:px-3xl lg:py-4xl", children: [_jsx(Dialog.CloseTrigger, { children: _jsx(XIcon, {}) }), _jsx(Dialog.Title, { className: "text-center font-semibold lg:text-xl", children: "Take a Selfie Holding Your ID" }), _jsx(Dialog.Description, { className: "text-text-tertiary-600 mt-md text-center text-xs lg:text-base", children: "Make sure your face and ID are clearly visible, with no glare\u00A0or\u00A0blur." }), _jsxs("div", { className: "relative mt-5 lg:mt-10 lg:px-3xl", children: [_jsxs("div", { className: twMerge('border-border-disabled relative w-full overflow-hidden rounded-md border bg-black lg:rounded-lg', !camera.data && !camera.error && !camera.loading
|
|
105
68
|
? 'block'
|
|
106
|
-
: 'hidden'), children: [_jsx("video", { ...camera.videoProps }), _jsx(
|
|
69
|
+
: 'hidden'), children: [_jsx("video", { ...camera.videoProps }), _jsx(Mask, {})] }), camera.error && (_jsxs("div", { className: "border-border-disabled flex aspect-[85.60/53.98] flex-col items-center justify-center rounded-md border bg-black px-4 lg:rounded-lg", children: [_jsx(CameraOffIcon, { className: "text-text-placeholder-subtle size-10 text-center lg:size-12" }), _jsx("h2", { className: "mt-3 text-sm font-semibold lg:mt-4 lg:text-base", children: camera.error.name }), _jsx("p", { className: "text-text-tertiary-600 mt-0.5 text-center text-xs lg:mt-1 lg:text-sm", children: camera.error.message }), _jsx(Button, { size: "xs", variant: "outline", colorScheme: "gray", fullWidth: false, className: "mt-4 lg:mt-5", onClick: () => {
|
|
107
70
|
camera.reopen();
|
|
108
71
|
}, children: "Try again" })] })), camera.loading && (_jsx("div", { className: "border-border-disabled flex aspect-[85.60/53.98] flex-col items-center justify-center rounded-md border bg-black px-4 lg:rounded-lg", children: _jsx(Spinner02Icon, { className: "text-text-placeholder size-8" }) })), camera.data && (_jsx("div", { className: "border-border-disabled h-auto w-full overflow-hidden rounded-md border bg-black lg:rounded-lg", children: _jsx(Image, { src: camera.data.url, alt: "", width: 600, height: 600, unoptimized: true, className: "block h-auto w-full" }) }))] }), !camera.data && !camera.error && !camera.loading && (_jsx("div", { className: "mt-5 lg:mt-10", children: _jsxs(Button, { className: "disabled:bg-bg-disabled disabled:text-text-disabled mx-auto w-full px-xl lg:w-auto", onClick: () => {
|
|
109
72
|
const rect = guideRef.current?.getBoundingClientRect();
|
|
@@ -126,3 +89,102 @@ export function SelfieField(props) {
|
|
|
126
89
|
camera.reset();
|
|
127
90
|
}, children: [_jsx(RefreshCcw01Icon, { className: "size-5" }), " Retake"] })] }))] }) })] }) })] }));
|
|
128
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* -------------------------------------
|
|
94
|
+
* Mask
|
|
95
|
+
* -------------------------------------
|
|
96
|
+
*/
|
|
97
|
+
function Mask() {
|
|
98
|
+
const id = useId();
|
|
99
|
+
const { guideRef, maskRef } = useSelfieFieldContext();
|
|
100
|
+
const [maskBox, setMaskBox] = useState({
|
|
101
|
+
cx: 0,
|
|
102
|
+
cy: 0,
|
|
103
|
+
r: 0,
|
|
104
|
+
});
|
|
105
|
+
useLayoutEffect(() => {
|
|
106
|
+
const update = () => {
|
|
107
|
+
const guide = guideRef.current;
|
|
108
|
+
const mask = maskRef.current;
|
|
109
|
+
if (!guide || !mask)
|
|
110
|
+
return;
|
|
111
|
+
const guideRect = guide.getBoundingClientRect();
|
|
112
|
+
const maskRect = mask.getBoundingClientRect();
|
|
113
|
+
const cx = guideRect.left + guideRect.width / 2 - maskRect.left;
|
|
114
|
+
const cy = guideRect.top + guideRect.height / 2 - maskRect.top;
|
|
115
|
+
const r = guideRect.width / 2;
|
|
116
|
+
setMaskBox({
|
|
117
|
+
cx,
|
|
118
|
+
cy,
|
|
119
|
+
r,
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
update();
|
|
123
|
+
const observer = new ResizeObserver(update);
|
|
124
|
+
if (guideRef.current)
|
|
125
|
+
observer.observe(guideRef.current);
|
|
126
|
+
if (maskRef.current)
|
|
127
|
+
observer.observe(maskRef.current);
|
|
128
|
+
return () => observer.disconnect();
|
|
129
|
+
}, [guideRef, maskRef]);
|
|
130
|
+
return (_jsxs(_Fragment, { children: [_jsx("svg", { className: "absolute h-full w-full", children: _jsxs("mask", { id: id, maskUnits: "userSpaceOnUse", children: [_jsx("rect", { width: "100%", height: "100%", fill: "white" }), _jsx("circle", { cx: maskBox.cx, cy: maskBox.cy, r: maskBox.r, fill: "black" })] }) }), _jsx("div", { ref: maskRef, className: "absolute inset-0 bg-black/60 backdrop-blur-sm", style: {
|
|
131
|
+
mask: `url(#${id})`,
|
|
132
|
+
WebkitMask: `url(#${id})`,
|
|
133
|
+
} }), _jsx("div", { ref: guideRef, className: "border-utility-warning-700 absolute left-1/2 top-1/2 z-20 aspect-square w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-dashed lg:w-[29.688rem]" })] }));
|
|
134
|
+
}
|
|
135
|
+
function useSelfieField(props) {
|
|
136
|
+
const field = useFieldContext();
|
|
137
|
+
const [value, setValue] = useControllableState({
|
|
138
|
+
value: props.value,
|
|
139
|
+
defaultValue: props.defaultValue ?? '',
|
|
140
|
+
onChange: props.onChange,
|
|
141
|
+
});
|
|
142
|
+
const query = useFileQuery(value, {
|
|
143
|
+
refetchInterval({ state }) {
|
|
144
|
+
return !state.data || state.data.status === 'UPLOADING' ? 1000 : false;
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
const mutation = useUploadImageFileMutation({
|
|
148
|
+
onSuccess(id) {
|
|
149
|
+
setValue(id);
|
|
150
|
+
},
|
|
151
|
+
onMutate() {
|
|
152
|
+
setValue('');
|
|
153
|
+
},
|
|
154
|
+
onError(error) {
|
|
155
|
+
toaster.error({
|
|
156
|
+
title: 'Upload Failed',
|
|
157
|
+
description: error.message,
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
onSettled() {
|
|
161
|
+
if (inputRef.current) {
|
|
162
|
+
inputRef.current.value = '';
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
const inputRef = useRef(null);
|
|
167
|
+
const maskRef = useRef(null);
|
|
168
|
+
const guideRef = useRef(null);
|
|
169
|
+
const camera = useCamera();
|
|
170
|
+
const disclosure = useDisclosure({
|
|
171
|
+
onOpenChange(open) {
|
|
172
|
+
if (open) {
|
|
173
|
+
camera.open();
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
field,
|
|
179
|
+
value,
|
|
180
|
+
setValue,
|
|
181
|
+
query,
|
|
182
|
+
mutation,
|
|
183
|
+
camera,
|
|
184
|
+
disclosure,
|
|
185
|
+
inputRef,
|
|
186
|
+
guideRef,
|
|
187
|
+
maskRef,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const [SelfieFieldProvider, useSelfieFieldContext] = createContext();
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
export function validateFile(file, options) {
|
|
2
2
|
const maxFileSize = options?.maxFileSize ?? 10;
|
|
3
3
|
const maxFileName = options?.maxFileName ?? 48;
|
|
4
|
-
const supportedFiles = options?.supportedFiles
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
const supportedFiles = options?.supportedFiles?.length
|
|
5
|
+
? options.supportedFiles
|
|
6
|
+
: [
|
|
7
|
+
/**/
|
|
8
|
+
'image/png',
|
|
9
|
+
'image/jpg',
|
|
10
|
+
'image/jpeg',
|
|
11
|
+
];
|
|
9
12
|
if (!supportedFiles.includes(file.type)) {
|
|
10
13
|
return {
|
|
11
14
|
name: 'FILE_TYPE_NOT_SUPPORTED',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opexa/portal-components",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.232",
|
|
4
4
|
"exports": {
|
|
5
5
|
"./ui/*": {
|
|
6
6
|
"types": "./dist/ui/*/index.d.ts",
|
|
@@ -81,6 +81,9 @@
|
|
|
81
81
|
"@date-fns/tz": "^1.2.0",
|
|
82
82
|
"@next/third-parties": "^15.3.4",
|
|
83
83
|
"@tailwindcss/typography": "^0.5.16",
|
|
84
|
+
"@tensorflow-models/face-detection": "^1.0.3",
|
|
85
|
+
"@tensorflow/tfjs": "^4.22.0",
|
|
86
|
+
"@tensorflow/tfjs-backend-webgl": "^4.22.0",
|
|
84
87
|
"date-fns": "^4.1.0",
|
|
85
88
|
"embla-carousel-autoplay": "^8.6.0",
|
|
86
89
|
"embla-carousel-react": "^8.6.0",
|