@opexa/portal-components 0.0.231 → 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.
@@ -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 ref = useRef(null);
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 = ref.current;
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
- ref.current?.pause();
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 = ref.current;
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
- disableFeatureFlag: () => void;
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
- disableFeatureFlag,
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 { useFeatureFlag } from '../../client/hooks/useFeatureFlag.js';
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, disableFeatureFlag } = useFeatureFlag();
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
  }
@@ -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 field = useFieldContext();
28
- const [value, setValue] = useControllableState({
29
- value: props.value,
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("div", { ref: guideRef, className: "border-utility-warning-700 absolute left-1/2 top-1/2 aspect-[85.60/53.98] w-[calc(100%-1rem)] -translate-x-1/2 -translate-y-1/2 rounded-md border-2 border-dashed lg:w-[29.688rem]" })] }), 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: () => {
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 field = useFieldContext();
27
- const [value, setValue] = useControllableState({
28
- value: props.value,
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("div", { ref: guideRef, className: "border-utility-warning-700 absolute left-1/2 top-1/2 aspect-square w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-dashed lg:w-[29.688rem]" })] }), 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: () => {
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
- 'image/png',
6
- 'image/jpg',
7
- 'image/jpeg',
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.231",
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",