@skalfa/skalfa-app 1.0.0

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.
Files changed (112) hide show
  1. package/.env.example +44 -0
  2. package/README.md +28 -0
  3. package/app/auth/edit/page.tsx +65 -0
  4. package/app/auth/login/page.tsx +63 -0
  5. package/app/auth/me/page.tsx +58 -0
  6. package/app/auth/register/page.tsx +69 -0
  7. package/app/auth/verify/page.tsx +53 -0
  8. package/app/dashboard/layout.tsx +47 -0
  9. package/app/dashboard/page.tsx +9 -0
  10. package/app/dashboard/user/page.tsx +77 -0
  11. package/app/index.ts +14 -0
  12. package/app/layout.tsx +38 -0
  13. package/app/page.tsx +13 -0
  14. package/barrels.json +6 -0
  15. package/blueprints/starter.blueprint.json +103 -0
  16. package/components/base.components/accordion/Accordion.component.tsx +82 -0
  17. package/components/base.components/breadcrumb/Breadcrumb.component.tsx +80 -0
  18. package/components/base.components/button/Button.component.tsx +91 -0
  19. package/components/base.components/button/IconButton.component.tsx +88 -0
  20. package/components/base.components/button/button.decorate.ts +82 -0
  21. package/components/base.components/card/AlertCard.component.tsx +69 -0
  22. package/components/base.components/card/Card.component.tsx +25 -0
  23. package/components/base.components/card/DashboardCard.component.tsx +44 -0
  24. package/components/base.components/card/GalleryCard.component.tsx +50 -0
  25. package/components/base.components/card/ProductCard.component.tsx +65 -0
  26. package/components/base.components/card/ProfileCard.component.tsx +71 -0
  27. package/components/base.components/carousel/Carousel.component.tsx +113 -0
  28. package/components/base.components/chip/Chip.component.tsx +39 -0
  29. package/components/base.components/document/DocumentViewer.component.tsx +164 -0
  30. package/components/base.components/document/ExportExcel.component.tsx +340 -0
  31. package/components/base.components/document/ImportExcel.component.tsx +315 -0
  32. package/components/base.components/document/PrintTable.component.tsx +204 -0
  33. package/components/base.components/document/RenderPDF.component.tsx +416 -0
  34. package/components/base.components/index.ts +85 -0
  35. package/components/base.components/input/Checkbox.component.tsx +109 -0
  36. package/components/base.components/input/Input.component.tsx +332 -0
  37. package/components/base.components/input/InputCheckbox.component.tsx +174 -0
  38. package/components/base.components/input/InputCurrency.component.tsx +163 -0
  39. package/components/base.components/input/InputDate.component.tsx +352 -0
  40. package/components/base.components/input/InputDatetime.component.tsx +260 -0
  41. package/components/base.components/input/InputDocument.component.tsx +352 -0
  42. package/components/base.components/input/InputImage.component.tsx +533 -0
  43. package/components/base.components/input/InputMap.component.tsx +318 -0
  44. package/components/base.components/input/InputNumber.component.tsx +192 -0
  45. package/components/base.components/input/InputOtp.component.tsx +169 -0
  46. package/components/base.components/input/InputPassword.component.tsx +236 -0
  47. package/components/base.components/input/InputRadio.component.tsx +175 -0
  48. package/components/base.components/input/InputTime.component.tsx +276 -0
  49. package/components/base.components/input/InputValues.component.tsx +68 -0
  50. package/components/base.components/input/Radio.component.tsx +102 -0
  51. package/components/base.components/input/Select.component.tsx +541 -0
  52. package/components/base.components/modal/BottomSheet.component.tsx +246 -0
  53. package/components/base.components/modal/FloatingPage.component.tsx +104 -0
  54. package/components/base.components/modal/Modal.component.tsx +96 -0
  55. package/components/base.components/modal/ModalConfirm.component.tsx +218 -0
  56. package/components/base.components/modal/Toast.component.tsx +126 -0
  57. package/components/base.components/nav/Bottombar.component.tsx +116 -0
  58. package/components/base.components/nav/Footer.component.tsx +144 -0
  59. package/components/base.components/nav/Headbar.component.tsx +104 -0
  60. package/components/base.components/nav/Navbar.component.tsx +100 -0
  61. package/components/base.components/nav/Sidebar.component.tsx +301 -0
  62. package/components/base.components/nav/Tabbar.component.tsx +60 -0
  63. package/components/base.components/nav/Wizard.component.tsx +73 -0
  64. package/components/base.components/supervision/FormSupervision.component.tsx +434 -0
  65. package/components/base.components/supervision/TableSupervision.component.tsx +697 -0
  66. package/components/base.components/table/ControlBar.component.tsx +497 -0
  67. package/components/base.components/table/FilterComponent.tsx +518 -0
  68. package/components/base.components/table/Pagination.component.tsx +159 -0
  69. package/components/base.components/table/Table.component.tsx +469 -0
  70. package/components/base.components/typography/TypographyArticle.component.tsx +26 -0
  71. package/components/base.components/typography/TypographyColumn.component.tsx +20 -0
  72. package/components/base.components/typography/TypographyContent.component.tsx +20 -0
  73. package/components/base.components/typography/TypographyTips.component.tsx +20 -0
  74. package/components/base.components/wrap/Draggable.component.tsx +303 -0
  75. package/components/base.components/wrap/IDBProvider.tsx +12 -0
  76. package/components/base.components/wrap/Image.component.tsx +10 -0
  77. package/components/base.components/wrap/OutsideClick.component.tsx +48 -0
  78. package/components/base.components/wrap/ScrollContainer.component.tsx +104 -0
  79. package/components/base.components/wrap/ShortcutProvider.tsx +57 -0
  80. package/components/base.components/wrap/Swipe.component.tsx +93 -0
  81. package/components/construct.components/example.tsx +1 -0
  82. package/components/construct.components/index.ts +5 -0
  83. package/components/index.ts +3 -0
  84. package/components/structure.components/example.tsx +1 -0
  85. package/components/structure.components/index.ts +5 -0
  86. package/contexts/AppProvider.tsx +12 -0
  87. package/contexts/Auth.context.tsx +64 -0
  88. package/contexts/Toggle.context.tsx +44 -0
  89. package/contexts/index.ts +7 -0
  90. package/eslint.config.mjs +34 -0
  91. package/langs/index.ts +1 -0
  92. package/langs/validation.langs.ts +17 -0
  93. package/next.config.ts +17 -0
  94. package/package.json +43 -0
  95. package/postcss.config.mjs +12 -0
  96. package/public/204.svg +19 -0
  97. package/public/500.svg +39 -0
  98. package/public/images/avatar.jpg +0 -0
  99. package/public/images/example.png +0 -0
  100. package/schema/idb/app.schema.ts +9 -0
  101. package/schema/index.ts +5 -0
  102. package/styles/globals.css +231 -0
  103. package/styles/tailwind.safelist +69 -0
  104. package/tailwind.config.ts +10 -0
  105. package/tsconfig.json +35 -0
  106. package/utils/commands/barrels.ts +28 -0
  107. package/utils/commands/blueprint.ts +421 -0
  108. package/utils/commands/light.ts +21 -0
  109. package/utils/commands/logger.ts +42 -0
  110. package/utils/commands/stubs/table-blueprint.stub +13 -0
  111. package/utils/commands/use-pdf.ts +29 -0
  112. package/utils/index.ts +3 -0
@@ -0,0 +1,318 @@
1
+ "use client"
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import axios from "axios";
5
+ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
6
+ import { faLocationDot, faLocationCrosshairs } from "@fortawesome/free-solid-svg-icons";
7
+ import { GoogleMap, LoadScript } from "@react-google-maps/api";
8
+ import { cn, pcn, useInputHandler, useInputRandomId, useResponsive, useValidation, validation, ValidationRules } from "@utils";
9
+ import { BottomSheetComponent, ButtonComponent, OutsideClickComponent } from "@components";
10
+
11
+
12
+
13
+ type CT = "label" | "tip" | "error" | "input" | "icon";
14
+
15
+ export interface ValueMapProps {
16
+ lat : number | null;
17
+ lng : number | null;
18
+ address ?: string;
19
+ }
20
+
21
+ export interface InputMapProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> {
22
+ label ?: string;
23
+ tip ?: string | React.ReactNode;
24
+ leftIcon ?: any;
25
+ rightIcon ?: any;
26
+
27
+ value ?: any;
28
+ invalid ?: string;
29
+ validations ?: ValidationRules;
30
+
31
+ onChange ?: (value: any) => any;
32
+ register ?: (name: string, validations?: ValidationRules) => void;
33
+ unregister ?: (name: string) => void;
34
+
35
+ className ?: string;
36
+ }
37
+
38
+
39
+
40
+ export function InputMapComponent({
41
+ label,
42
+ tip,
43
+ leftIcon,
44
+ rightIcon,
45
+
46
+ value,
47
+ invalid,
48
+ validations,
49
+
50
+ register,
51
+ unregister,
52
+ onChange,
53
+
54
+ className = "",
55
+ ...props
56
+ }: InputMapProps) {
57
+ const { isSm } = useResponsive();
58
+
59
+ // =========================>
60
+ // ## Invalid handler
61
+ // =========================>
62
+ const inputHandler = useInputHandler(props.name, value, validations, register, unregister, false)
63
+ const randomId = useInputRandomId()
64
+
65
+
66
+ // =========================>
67
+ // ## Invalid handler
68
+ // =========================>
69
+ const [invalidMessage] = useValidation(inputHandler.value, validations, invalid, inputHandler.idle);
70
+
71
+
72
+ return (
73
+ <>
74
+ <div className="relative flex flex-col gap-y-0.5">
75
+ {label && (
76
+ <label
77
+ htmlFor={randomId}
78
+ className={cn(
79
+ "input-label",
80
+ pcn<CT>(className, "label"),
81
+ props.disabled && "opacity-50",
82
+ inputHandler.focus && "text-primary",
83
+ !!invalidMessage && "text-danger",
84
+ )}
85
+ >
86
+ {label}
87
+ {validations && validation.hasRules(validations, "required") && <span className="text-danger ml-1">*</span>}
88
+ </label>
89
+ )}
90
+
91
+ {tip && (
92
+ <small
93
+ className={cn(
94
+ "input-tip",
95
+ pcn<CT>(className, "tip"),
96
+ props.disabled && "opacity-60",
97
+ )}
98
+ >{tip}</small>
99
+ )}
100
+
101
+ <OutsideClickComponent onOutsideClick={!isSm ? () => inputHandler.setFocus(false) : undefined}>
102
+ <div className="relative">
103
+ <input
104
+ {...props}
105
+ id={randomId}
106
+ className={cn(
107
+ "input",
108
+ leftIcon && "pl-12",
109
+ rightIcon && "pr-12",
110
+ pcn<CT>(className, "input"),
111
+ !!invalidMessage && "input-error",
112
+ )}
113
+ value={inputHandler.value?.address || ""}
114
+ readOnly
115
+ onFocus={(e) => {
116
+ props.onFocus?.(e);
117
+ inputHandler.setFocus(true);
118
+ inputHandler.setIdle(false);
119
+ }}
120
+ autoComplete="off"
121
+ />
122
+
123
+ {leftIcon && (
124
+ <FontAwesomeIcon
125
+ className={cn(
126
+ "left-4 input-icon",
127
+ pcn<CT>(className, "icon"),
128
+ props.disabled && "opacity-60",
129
+ inputHandler.focus && "text-primary",
130
+ )}
131
+ icon={leftIcon}
132
+ />
133
+ )}
134
+
135
+ {rightIcon && (
136
+ <FontAwesomeIcon
137
+ className={cn(
138
+ "right-4 input-icon",
139
+ pcn<CT>(className, "icon"),
140
+ props.disabled && "opacity-60",
141
+ inputHandler.focus && "text-primary",
142
+ )}
143
+ icon={rightIcon}
144
+ />
145
+ )}
146
+
147
+ {!isSm && inputHandler.focus && (
148
+ <div
149
+ className="absolute top-full left-0 mt-2 w-full z-50 bg-background border border-light-border rounded-md overflow-hidden shadow-lg"
150
+ style={{ height: 300 }}
151
+ >
152
+ <InputMapPickerComponent
153
+ value={inputHandler.value}
154
+ onChange={(e) => {
155
+ inputHandler.setValue(e)
156
+ onChange?.(e)
157
+ }}
158
+ />
159
+ </div>
160
+ )}
161
+ </div>
162
+ </OutsideClickComponent>
163
+
164
+ {invalidMessage && (
165
+ <small className={cn("input-error-message", pcn<CT>(className, "error"))}>{invalidMessage}</small>
166
+ )}
167
+ </div>
168
+
169
+ {isSm && (
170
+ <BottomSheetComponent
171
+ show={inputHandler.focus}
172
+ onClose={() => inputHandler.setFocus(false)}
173
+ size={450}
174
+ footer={
175
+ <div className="p-4">
176
+ <ButtonComponent
177
+ label="Selesai"
178
+ variant="outline"
179
+ onClick={() => inputHandler.setFocus(false)}
180
+ block
181
+ />
182
+ </div>
183
+ }
184
+ >
185
+ <div className="p-4">
186
+ <InputMapPickerComponent
187
+ onChange={(e) => {
188
+ inputHandler.setValue(e);
189
+ onChange?.(e);
190
+ }}
191
+ />
192
+ </div>
193
+ </BottomSheetComponent>
194
+ )}
195
+ </>
196
+ );
197
+ }
198
+
199
+
200
+ export interface MapPickerProps {
201
+ value ?: any;
202
+ onChange ?: (value: any) => any;
203
+ }
204
+
205
+ export const InputMapPickerComponent: React.FC<MapPickerProps> = ({
206
+ onChange,
207
+ value
208
+ }) => {
209
+ const mapRef = useRef<google.maps.Map | null>(null);
210
+
211
+ const [addressLoading, setAddressLoading] = useState(false);
212
+ const [drag, setDrag] = useState(false);
213
+
214
+
215
+ // =========================>
216
+ // ## Map Events
217
+ // =========================>
218
+ const setCurrentPosition = () => {
219
+ if (navigator.geolocation) {
220
+ navigator.geolocation.getCurrentPosition((pos) => {
221
+ const newPos = {
222
+ lat: pos.coords.latitude,
223
+ lng: pos.coords.longitude,
224
+ address: "",
225
+ };
226
+
227
+ onChange?.(newPos);
228
+ mapRef.current?.panTo(new google.maps.LatLng(newPos.lat, newPos.lng));
229
+ });
230
+ }
231
+ };
232
+
233
+
234
+ const handleDragEnd = useCallback(() => {
235
+ if (mapRef.current) {
236
+ const center = mapRef.current.getCenter();
237
+
238
+ if (center) {
239
+ onChange?.({
240
+ lat: center.lat(),
241
+ lng: center.lng(),
242
+ address: "",
243
+ });
244
+ }
245
+
246
+ setDrag(false);
247
+ }
248
+ }, []);
249
+
250
+
251
+ // =========================>
252
+ // ## Reverse Geocode
253
+ // =========================>
254
+ useEffect(() => {
255
+ if (value?.lat && value?.lng) {
256
+ setAddressLoading(true);
257
+ onChange?.((prev: any) => ({ ...prev, address: "" }));
258
+
259
+ axios.get(`https://api.geoapify.com/v1/geocode/reverse?lat=${value?.lat}&lon=${value?.lng}&apiKey=${process.env.NEXT_PUBLIC_GEOAPIFY_KEY}`)
260
+ .then((res: any) => {
261
+ if (res.status === 200 && !res.data.error) {
262
+ const data = res.data.features?.at(0)?.properties;
263
+ const address =(data?.address_line1 || "") + " " + (data?.address_line2 || "");
264
+
265
+ onChange?.((prev: any) => ({ ...prev, address }));
266
+ onChange?.({ ...value, address });
267
+ }
268
+ })
269
+ .finally(() => setAddressLoading(false));
270
+ }
271
+ }, [value?.lat, value?.lng]);
272
+
273
+ return (
274
+ <div className="relative w-full">
275
+ <LoadScript googleMapsApiKey={process.env.NEXT_PUBLIC_MAP_KEY || ""}>
276
+ <GoogleMap
277
+ mapContainerStyle={{ width: "100%", height: "100%" }}
278
+ center={{
279
+ lat: value?.lat || -6.208,
280
+ lng: value?.lng || 106.689,
281
+ }}
282
+ zoom={18}
283
+ options={{
284
+ streetViewControl: false,
285
+ mapTypeControl: false,
286
+ fullscreenControl: false,
287
+ }}
288
+ onLoad={(map) => {mapRef.current = map}}
289
+ onDrag={() => setDrag(true)}
290
+ onDragEnd={handleDragEnd}
291
+ />
292
+ </LoadScript>
293
+
294
+ <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
295
+ <FontAwesomeIcon
296
+ icon={faLocationDot}
297
+ className={cn(
298
+ "text-3xl text-primary drop-shadow-md transition-transform",
299
+ drag && "scale-125 -translate-y-2",
300
+ )}
301
+ />
302
+ </div>
303
+
304
+ <div className="absolute top-3 left-3 bg-background px-3 py-2 rounded-lg shadow">
305
+ {addressLoading && !value?.address ? (
306
+ <div className="py-4 w-[200px]" />
307
+ ) : (
308
+ <span className="text-sm">{value?.address}</span>
309
+ )}
310
+ </div>
311
+
312
+ <div
313
+ className="absolute top-3 right-3 bg-background p-3 rounded-lg cursor-pointer shadow"
314
+ onClick={() => setCurrentPosition()}
315
+ ><FontAwesomeIcon icon={faLocationCrosshairs} className="text-lg" /></div>
316
+ </div>
317
+ )
318
+ }
@@ -0,0 +1,192 @@
1
+ "use client"
2
+
3
+ import React, { InputHTMLAttributes, ReactNode } from "react";
4
+ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
5
+ import { faSortDown, faSortUp } from "@fortawesome/free-solid-svg-icons";
6
+ import { cn, pcn, useInputHandler, useInputRandomId, useValidation, validation, ValidationRules } from "@utils";
7
+
8
+
9
+
10
+ type CT = "label" | "tip" | "error" | "input" | "icon";
11
+
12
+ export interface InputNumberProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "onChange"> {
13
+ label ?: string;
14
+ tip ?: string | ReactNode;
15
+ leftIcon ?: any;
16
+ rightIcon ?: any;
17
+
18
+ value ?: number;
19
+ invalid ?: string;
20
+ validations ?: ValidationRules;
21
+ min ?: number;
22
+ max ?: number;
23
+
24
+ onChange ?: (value: number) => any;
25
+ register ?: (name: string, validations?: ValidationRules) => void;
26
+ unregister ?: (name: string) => void;
27
+
28
+ /** Use custom class with: "label::", "tip::", "error::", "icon::", "suggest::", "suggest-item::". */
29
+ className ?: string;
30
+ }
31
+
32
+
33
+
34
+ export function InputNumberComponent({
35
+ label,
36
+ tip,
37
+ leftIcon,
38
+ rightIcon,
39
+
40
+ value,
41
+ invalid,
42
+ validations,
43
+ min,
44
+ max,
45
+
46
+ onChange,
47
+ register,
48
+ unregister,
49
+
50
+ className = "",
51
+ ...props
52
+ }: InputNumberProps) {
53
+
54
+ // =========================>
55
+ // ## Initial
56
+ // =========================>
57
+ const inputHandler = useInputHandler(props.name, value, validations, register, unregister, false)
58
+ const randomId = useInputRandomId()
59
+
60
+
61
+ // =========================>
62
+ // ## Invalid handler
63
+ // =========================>
64
+ const [invalidMessage] = useValidation(inputHandler.value, validations, invalid, inputHandler.idle);
65
+
66
+
67
+ // =========================>
68
+ // ## Change value handler
69
+ // =========================>
70
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
71
+ const newValue = e.target.value;
72
+
73
+ const regex = /^-?\d*\.?\d*$/;
74
+ if (regex.test(newValue)) {
75
+ inputHandler.setValue(newValue);
76
+ inputHandler.setIdle(false);
77
+ onChange?.(Number(newValue));
78
+ }
79
+ };
80
+
81
+
82
+ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
83
+ props.onBlur?.(e);
84
+ setTimeout(() => inputHandler.setFocus(false), 100);
85
+ };
86
+
87
+
88
+ const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
89
+ props.onFocus?.(e);
90
+ inputHandler.setFocus(true);
91
+ };
92
+
93
+
94
+ return (
95
+ <>
96
+ <div className="relative flex flex-col gap-y-0.5">
97
+ <label
98
+ htmlFor={randomId}
99
+ className={cn(
100
+ "input-label",
101
+ pcn<CT>(className, "label"),
102
+ props.disabled && "opacity-50",
103
+ props.disabled && pcn<CT>(className, "label", "disabled"),
104
+ inputHandler.focus && "text-primary",
105
+ inputHandler.focus && pcn<CT>(className, "label", "focus"),
106
+ !!invalidMessage && "text-danger",
107
+ !!invalidMessage && pcn<CT>(className, "label", "focus"),
108
+ )}
109
+ >
110
+ {label}
111
+ {validations && validation.hasRules(validations, "required") && <span className="text-danger ml-1">*</span>}
112
+ </label>
113
+
114
+ {tip && (
115
+ <small
116
+ className={cn(
117
+ "input-tip",
118
+ pcn<CT>(className, "tip"),
119
+ props.disabled && "opacity-60",
120
+ props.disabled && pcn<CT>(className, "tip", "disabled"),
121
+ )}
122
+ >{tip}</small>
123
+ )}
124
+
125
+ <div className="relative">
126
+ <input
127
+ {...props}
128
+ id={randomId}
129
+ className={cn(
130
+ "input",
131
+ leftIcon && "pl-12",
132
+ rightIcon && "pr-12",
133
+ pcn<CT>(className, "input"),
134
+ !!invalidMessage && "input-error",
135
+ !!invalidMessage && pcn<CT>(className, "input", "error"),
136
+ )}
137
+ value={inputHandler.value}
138
+ onChange={handleInputChange}
139
+ onFocus={handleFocus}
140
+ onBlur={handleBlur}
141
+ autoComplete="off"
142
+ min={min}
143
+ max={max}
144
+ />
145
+
146
+ {leftIcon && (
147
+ <FontAwesomeIcon
148
+ className={cn(
149
+ "left-4 input-icon ",
150
+ pcn<CT>(className, "icon"),
151
+ props.disabled && "opacity-60",
152
+ props.disabled && pcn<CT>(className, "icon", "disabled"),
153
+ inputHandler.focus && "text-primary",
154
+ inputHandler.focus && pcn<CT>(className, "icon", "focus"),
155
+ )}
156
+ icon={leftIcon}
157
+ />
158
+ )}
159
+
160
+ <label
161
+ htmlFor={randomId}
162
+ className={cn(
163
+ "right-4 input-icon",
164
+ pcn<CT>(className, "icon"),
165
+ props.disabled && "opacity-60",
166
+ props.disabled && pcn<CT>(className, "icon", "disabled"),
167
+ inputHandler.focus && "text-primary",
168
+ inputHandler.focus && pcn<CT>(className, "icon", "focus"),
169
+ )}
170
+ >
171
+ <div className="flex flex-col">
172
+ <FontAwesomeIcon
173
+ className={`text-light-foreground hover:text-primary -mb-1`}
174
+ icon={faSortUp}
175
+ onClick={() => inputHandler.setValue(String(Number(inputHandler.value) + 1))}
176
+ />
177
+ <FontAwesomeIcon
178
+ className={`text-light-foreground hover:text-primary -mt-1`}
179
+ icon={faSortDown}
180
+ onClick={() => inputHandler.setValue(String(Number(inputHandler.value) - 1))}
181
+ />
182
+ </div>
183
+ </label>
184
+ </div>
185
+
186
+ {invalidMessage && (
187
+ <small className={cn("input-error-message", pcn<CT>(className, "error"))}>{invalidMessage}</small>
188
+ )}
189
+ </div>
190
+ </>
191
+ );
192
+ }
@@ -0,0 +1,169 @@
1
+ "use client"
2
+
3
+ import { ClipboardEvent, FC, KeyboardEvent, ReactNode, useEffect, useRef, useState } from "react";
4
+ import { cn, pcn } from "@utils";
5
+
6
+
7
+
8
+ type CT = "label" | "tip" | "error" | "base" | "icon";
9
+
10
+ export interface InputOtpProps {
11
+ label ?: string;
12
+ tip ?: string | ReactNode;
13
+ name : string;
14
+ disabled ?: boolean;
15
+
16
+ value ?: string;
17
+ invalid ?: string;
18
+ length ?: number;
19
+
20
+ onChange ?: (value: string) => any;
21
+
22
+ /** Use custom class with: "label::", "tip::", "error::". */
23
+ className ?: string;
24
+ }
25
+
26
+
27
+
28
+ export const InputOtpComponent: FC<InputOtpProps> = ({
29
+ label,
30
+ tip,
31
+ name,
32
+ disabled,
33
+
34
+ value,
35
+ invalid,
36
+ length = 6,
37
+
38
+ onChange,
39
+
40
+ className = "",
41
+ }) => {
42
+ const [isFocus, setIsFocus] = useState(false);
43
+ const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
44
+ const [otp, setOtp] = useState<string[]>((value || "").split("").concat(Array(length).fill("")).slice(0, length));
45
+
46
+
47
+ useEffect(() => {
48
+ const handleFocusChange = () => {
49
+ const anyFocused = inputsRef.current.some((input) => input === document.activeElement);
50
+ setIsFocus(anyFocused);
51
+ };
52
+
53
+ window.addEventListener("focusin", handleFocusChange);
54
+ window.addEventListener("focusout", handleFocusChange);
55
+
56
+ return () => {
57
+ window.removeEventListener("focusin", handleFocusChange);
58
+ window.removeEventListener("focusout", handleFocusChange);
59
+ };
60
+ }, []);
61
+
62
+
63
+ const emitChange = (newOtp: string[]) => {
64
+ const val = newOtp.join("");
65
+ onChange?.(val);
66
+ };
67
+
68
+
69
+ const handleChange = (val: string, index: number) => {
70
+ if (!/^[0-9]?$/.test(val)) return;
71
+ const newOtp = [...otp];
72
+ newOtp[index] = val;
73
+
74
+ setOtp(newOtp);
75
+ emitChange(newOtp);
76
+
77
+ (val && index < length - 1) && inputsRef.current[index + 1]?.focus();
78
+ };
79
+
80
+
81
+ const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>, index: number) => {
82
+ if (e.key === "Backspace") {
83
+ if (otp[index]) {
84
+ const newOtp = [...otp];
85
+ newOtp[index] = "";
86
+ setOtp(newOtp);
87
+ emitChange(newOtp);
88
+ } else if (index > 0) {
89
+ inputsRef.current[index - 1]?.focus();
90
+ }
91
+ }
92
+ };
93
+
94
+
95
+ const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
96
+ e.preventDefault();
97
+ const paste = e.clipboardData.getData("text").trim();
98
+ if (!/^[0-9]+$/.test(paste)) return;
99
+ const pasted = paste.slice(0, length).split("");
100
+ const newOtp = [...otp];
101
+ for (let i = 0; i < pasted.length; i++) newOtp[i] = pasted[i];
102
+ setOtp(newOtp);
103
+ emitChange(newOtp);
104
+ inputsRef.current[Math.min(pasted.length, length - 1)]?.focus();
105
+ };
106
+
107
+
108
+ return (
109
+ <>
110
+ <div className="relative flex flex-col gap-y-0.5">
111
+ <label
112
+ className={cn(
113
+ "input-label",
114
+ pcn<CT>(className, "label"),
115
+ disabled && "opacity-50",
116
+ disabled && pcn<CT>(className, "label", "disabled"),
117
+ isFocus && "text-primary",
118
+ isFocus && pcn<CT>(className, "label", "focus"),
119
+ !!invalid && "text-danger",
120
+ !!invalid && pcn<CT>(className, "label", "focus"),
121
+ )}
122
+ >
123
+ {label}
124
+ </label>
125
+
126
+ {tip && (
127
+ <small
128
+ className={cn(
129
+ "input-tip",
130
+ pcn<CT>(className, "tip"),
131
+ disabled && "opacity-60",
132
+ disabled && pcn<CT>(className, "tip", "disabled"),
133
+ )}
134
+ >{tip}</small>
135
+ )}
136
+ <div className={cn(
137
+ "input pb-1",
138
+ isFocus && "!border-primary",
139
+ !!invalid && "input-error"
140
+ )}
141
+ >
142
+ <div className="flex gap-2 justify-between">
143
+ {otp.map((digit, index) => (
144
+ <input
145
+ key={index}
146
+ type="text"
147
+ ref={(el) => {inputsRef.current[index] = el }}
148
+ inputMode="numeric"
149
+ maxLength={1}
150
+ value={digit}
151
+ onChange={(e) => handleChange(e.target.value, index)}
152
+ onKeyDown={(e) => handleKeyDown(e, index)}
153
+ onPaste={handlePaste}
154
+ className={`w-8 h-full pb-1 text-center border-b outline-none border-foreground focus:!border-primary placeholder:text-light-foreground`}
155
+ placeholder="-"
156
+ autoFocus={index === 0}
157
+ />
158
+ ))}
159
+ </div>
160
+ <input type="hidden" name={name} value={otp.join("")} />
161
+ </div>
162
+
163
+ {invalid && (
164
+ <small className={cn("input-error-message", pcn<CT>(className, "error"))}>{invalid}</small>
165
+ )}
166
+ </div>
167
+ </>
168
+ );
169
+ };