kesp-ui 1.0.5 → 1.1.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.
- package/package.json +1 -1
- package/registry/aadhaar-input.tsx +122 -65
- package/registry/maskedAadhar.tsx +61 -25
- package/registry/otp-input.tsx +5 -5
- package/registry/pan-input.tsx +112 -52
- package/registry/phoneNumberInput.tsx +298 -0
package/package.json
CHANGED
|
@@ -1,22 +1,34 @@
|
|
|
1
|
-
"use client"
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useCallback, useEffect } from "react";
|
|
2
4
|
|
|
3
|
-
import React, { useState, useRef, useCallback } from "react";
|
|
4
|
-
import { cn } from "./utils";
|
|
5
5
|
import { Check, AlertCircle, CreditCard } from "lucide-react";
|
|
6
|
+
import { cn } from "./utils";
|
|
6
7
|
|
|
7
8
|
// ─── Verhoeff Algorithm ───────────────────────────────────────────────────────
|
|
8
9
|
|
|
9
10
|
const D: number[][] = [
|
|
10
|
-
[0,1,2,3,4,5,6,7,8,9],
|
|
11
|
-
[
|
|
12
|
-
[
|
|
13
|
-
[
|
|
11
|
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
|
12
|
+
[1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
|
|
13
|
+
[2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
|
|
14
|
+
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
|
|
15
|
+
[4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
|
|
16
|
+
[5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
|
|
17
|
+
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
|
|
18
|
+
[7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
|
|
19
|
+
[8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
|
|
20
|
+
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
|
|
14
21
|
];
|
|
15
22
|
|
|
16
23
|
const P: number[][] = [
|
|
17
|
-
[0,1,2,3,4,5,6,7,8,9],
|
|
18
|
-
[
|
|
19
|
-
[
|
|
24
|
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
|
25
|
+
[1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
|
|
26
|
+
[5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
|
|
27
|
+
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
|
|
28
|
+
[9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
|
|
29
|
+
[4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
|
|
30
|
+
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
|
|
31
|
+
[7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
|
|
20
32
|
];
|
|
21
33
|
|
|
22
34
|
function verhoeff(num: string): boolean {
|
|
@@ -31,31 +43,36 @@ function verhoeff(num: string): boolean {
|
|
|
31
43
|
function isValidAadhaar(digits: string): boolean {
|
|
32
44
|
if (digits.length !== 12) return false;
|
|
33
45
|
if (!/^\d+$/.test(digits)) return false;
|
|
34
|
-
if (!/^[2-9]/.test(digits)) return false;
|
|
35
|
-
if (/^(\d)\1{11}$/.test(digits)) return false;
|
|
36
|
-
if (!verhoeff(digits)) return false;
|
|
46
|
+
if (!/^[2-9]/.test(digits)) return false; // must not start with 0 or 1
|
|
47
|
+
if (/^(\d)\1{11}$/.test(digits)) return false; // no repeating sequence
|
|
48
|
+
if (!verhoeff(digits)) return false; // Verhoeff checksum
|
|
37
49
|
return true;
|
|
38
50
|
}
|
|
39
51
|
|
|
40
52
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
41
53
|
|
|
42
|
-
const formatAadhaar = (raw: string): string => {
|
|
54
|
+
const formatAadhaar = (raw: string, separator = " "): string => {
|
|
43
55
|
const digits = raw.replace(/\D/g, "").slice(0, 12);
|
|
44
56
|
const parts: string[] = [];
|
|
45
57
|
for (let i = 0; i < digits.length; i += 4) {
|
|
46
58
|
parts.push(digits.slice(i, i + 4));
|
|
47
59
|
}
|
|
48
|
-
return parts.join(
|
|
60
|
+
return parts.join(separator);
|
|
49
61
|
};
|
|
50
62
|
|
|
51
63
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
52
64
|
|
|
53
65
|
interface AadhaarInputProps {
|
|
54
66
|
value?: string;
|
|
55
|
-
onChange?: (
|
|
67
|
+
onChange?: (digits: string) => void;
|
|
68
|
+
onComplete?: (digits: string) => void;
|
|
69
|
+
error?: boolean;
|
|
70
|
+
errorMessage?: string;
|
|
71
|
+
disabled?: boolean;
|
|
56
72
|
className?: string;
|
|
57
73
|
label?: string;
|
|
58
|
-
|
|
74
|
+
placeholder?: string;
|
|
75
|
+
separator?: string;
|
|
59
76
|
}
|
|
60
77
|
|
|
61
78
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
@@ -63,97 +80,129 @@ interface AadhaarInputProps {
|
|
|
63
80
|
const AadhaarInput: React.FC<AadhaarInputProps> = ({
|
|
64
81
|
value: controlledValue,
|
|
65
82
|
onChange,
|
|
83
|
+
onComplete,
|
|
84
|
+
error = false,
|
|
85
|
+
errorMessage,
|
|
86
|
+
disabled = false,
|
|
66
87
|
className,
|
|
67
88
|
label = "Aadhaar Number",
|
|
68
|
-
|
|
89
|
+
placeholder = "0000 0000 0000",
|
|
90
|
+
separator = " ",
|
|
69
91
|
}) => {
|
|
70
|
-
const [internalValue, setInternalValue] = useState("");
|
|
71
|
-
const [focused, setFocused] = useState(false);
|
|
92
|
+
const [internalValue, setInternalValue] = useState<string>("");
|
|
93
|
+
const [focused, setFocused] = useState<boolean>(false);
|
|
94
|
+
const [hasCompletedOnce, setHasCompletedOnce] = useState<boolean>(false);
|
|
72
95
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
73
96
|
|
|
74
|
-
const raw = controlledValue
|
|
97
|
+
const raw = controlledValue !== undefined ? controlledValue : internalValue;
|
|
75
98
|
const digits = raw.replace(/\D/g, "");
|
|
76
|
-
const formatted = formatAadhaar(raw);
|
|
99
|
+
const formatted = formatAadhaar(raw, separator);
|
|
77
100
|
|
|
78
101
|
const complete = digits.length === 12;
|
|
79
102
|
const valid = isValidAadhaar(digits);
|
|
80
103
|
|
|
81
|
-
|
|
82
|
-
const
|
|
104
|
+
// Show error state only once all 12 digits are entered and they fail validation
|
|
105
|
+
const showInternalError = complete && !valid;
|
|
106
|
+
const hasExternalError = error || !!errorMessage;
|
|
83
107
|
|
|
84
108
|
const progress = Math.min((digits.length / 12) * 100, 100);
|
|
85
109
|
|
|
110
|
+
// Fire onComplete when 12 digits are entered
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (complete && !hasCompletedOnce && onComplete) {
|
|
113
|
+
onComplete(digits);
|
|
114
|
+
setHasCompletedOnce(true);
|
|
115
|
+
}
|
|
116
|
+
if (!complete && hasCompletedOnce) {
|
|
117
|
+
setHasCompletedOnce(false);
|
|
118
|
+
}
|
|
119
|
+
}, [complete, digits, onComplete, hasCompletedOnce]);
|
|
120
|
+
|
|
86
121
|
const handleChange = useCallback(
|
|
87
122
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
123
|
+
if (disabled) return;
|
|
124
|
+
|
|
88
125
|
const input = e.target.value;
|
|
89
126
|
const digitsOnly = input.replace(/\D/g, "").slice(0, 12);
|
|
90
|
-
|
|
127
|
+
|
|
91
128
|
if (onChange) onChange(digitsOnly);
|
|
92
129
|
else setInternalValue(digitsOnly);
|
|
93
130
|
|
|
94
131
|
requestAnimationFrame(() => {
|
|
95
132
|
if (inputRef.current) {
|
|
133
|
+
const newFormatted = formatAadhaar(digitsOnly, separator);
|
|
96
134
|
const cursorPos = newFormatted.length;
|
|
97
135
|
inputRef.current.setSelectionRange(cursorPos, cursorPos);
|
|
98
136
|
}
|
|
99
137
|
});
|
|
100
138
|
},
|
|
101
|
-
[onChange]
|
|
139
|
+
[onChange, disabled, separator],
|
|
102
140
|
);
|
|
103
141
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
142
|
+
// Border color logic
|
|
143
|
+
const borderClass = disabled
|
|
144
|
+
? "border-[#E5E7EB] bg-[#F9FAFB]"
|
|
145
|
+
: focused
|
|
146
|
+
? "border-[#2563EB] shadow-[0_0_0_3px_rgba(37,99,235,0.1)]"
|
|
147
|
+
: complete && valid && !hasExternalError
|
|
148
|
+
? "border-[#10B981]"
|
|
149
|
+
: showInternalError || hasExternalError
|
|
150
|
+
? "border-[#EF4444]"
|
|
151
|
+
: "border-[#E5E7EB] hover:border-[#9CA3AF]";
|
|
111
152
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
153
|
+
// Progress bar color
|
|
154
|
+
const progressClass =
|
|
155
|
+
complete && valid && !hasExternalError
|
|
156
|
+
? "bg-[#10B981]"
|
|
157
|
+
: showInternalError || hasExternalError
|
|
158
|
+
? "bg-[#EF4444]"
|
|
159
|
+
: "bg-[#2563EB]";
|
|
117
160
|
|
|
161
|
+
// Helper text
|
|
118
162
|
let helperText: string;
|
|
119
163
|
let helperClass: string;
|
|
120
164
|
|
|
121
|
-
if (hasExternalError &&
|
|
122
|
-
helperText =
|
|
123
|
-
helperClass = "text-
|
|
124
|
-
} else if (
|
|
165
|
+
if (hasExternalError && errorMessage) {
|
|
166
|
+
helperText = errorMessage;
|
|
167
|
+
helperClass = "text-[#EF4444]";
|
|
168
|
+
} else if (showInternalError && !hasExternalError) {
|
|
125
169
|
helperText = "Invalid Aadhaar number";
|
|
126
|
-
helperClass = "text-
|
|
170
|
+
helperClass = "text-[#EF4444]";
|
|
127
171
|
} else if (digits.length === 0) {
|
|
128
172
|
helperText = "Enter your 12-digit Aadhaar number";
|
|
129
|
-
helperClass = "text-
|
|
130
|
-
} else if (complete && valid) {
|
|
173
|
+
helperClass = "text-[#6B7280]";
|
|
174
|
+
} else if (complete && valid && !hasExternalError) {
|
|
131
175
|
helperText = "Valid Aadhaar number";
|
|
132
|
-
helperClass = "text-
|
|
176
|
+
helperClass = "text-[#10B981]";
|
|
133
177
|
} else {
|
|
134
178
|
helperText = `${digits.length}/12 digits`;
|
|
135
|
-
helperClass = "text-
|
|
179
|
+
helperClass = "text-[#6B7280]";
|
|
136
180
|
}
|
|
137
181
|
|
|
138
182
|
return (
|
|
139
|
-
<div className={cn("w-full max-w-
|
|
183
|
+
<div className={cn("w-full max-w-md", className)}>
|
|
140
184
|
{label && (
|
|
141
|
-
<label className="block text-sm font-medium text-
|
|
185
|
+
<label className="block text-sm font-medium text-[#111827] mb-1.5">
|
|
142
186
|
{label}
|
|
143
187
|
</label>
|
|
144
188
|
)}
|
|
145
189
|
|
|
146
190
|
<div
|
|
147
191
|
className={cn(
|
|
148
|
-
"relative flex items-center rounded-lg border-2 bg-
|
|
149
|
-
|
|
192
|
+
"relative flex items-center rounded-lg border-2 bg-white px-3 py-3 transition-all duration-200",
|
|
193
|
+
disabled ? "cursor-not-allowed" : "cursor-text",
|
|
194
|
+
borderClass,
|
|
150
195
|
)}
|
|
151
|
-
onClick={() => inputRef.current?.focus()}
|
|
196
|
+
onClick={() => !disabled && inputRef.current?.focus()}
|
|
152
197
|
>
|
|
153
198
|
<CreditCard
|
|
154
199
|
className={cn(
|
|
155
200
|
"mr-3 h-5 w-5 shrink-0 transition-colors duration-200",
|
|
156
|
-
|
|
201
|
+
disabled
|
|
202
|
+
? "text-[#9CA3AF]"
|
|
203
|
+
: focused
|
|
204
|
+
? "text-[#2563EB]"
|
|
205
|
+
: "text-[#6B7280]",
|
|
157
206
|
)}
|
|
158
207
|
/>
|
|
159
208
|
<input
|
|
@@ -161,32 +210,40 @@ const AadhaarInput: React.FC<AadhaarInputProps> = ({
|
|
|
161
210
|
type="text"
|
|
162
211
|
inputMode="numeric"
|
|
163
212
|
autoComplete="off"
|
|
164
|
-
placeholder=
|
|
213
|
+
placeholder={placeholder}
|
|
165
214
|
value={formatted}
|
|
166
215
|
onChange={handleChange}
|
|
167
|
-
onFocus={() => setFocused(true)}
|
|
216
|
+
onFocus={() => !disabled && setFocused(true)}
|
|
168
217
|
onBlur={() => setFocused(false)}
|
|
169
|
-
|
|
170
|
-
|
|
218
|
+
disabled={disabled}
|
|
219
|
+
className={cn(
|
|
220
|
+
"flex-1 bg-transparent text-lg font-mono tracking-[0.15em] text-[#111827] placeholder:text-[#9CA3AF] outline-none",
|
|
221
|
+
disabled && "cursor-not-allowed text-[#9CA3AF]",
|
|
222
|
+
)}
|
|
223
|
+
maxLength={14 + (separator.length - 1) * 2}
|
|
171
224
|
aria-label="Aadhaar Number"
|
|
172
|
-
aria-invalid={
|
|
225
|
+
aria-invalid={showInternalError || hasExternalError}
|
|
226
|
+
aria-disabled={disabled}
|
|
173
227
|
/>
|
|
174
228
|
|
|
175
229
|
<div className="ml-2 shrink-0">
|
|
176
|
-
{complete && valid ? (
|
|
177
|
-
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-
|
|
230
|
+
{complete && valid && !hasExternalError && !disabled ? (
|
|
231
|
+
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-[#10B981] text-white">
|
|
178
232
|
<Check className="h-3.5 w-3.5" />
|
|
179
233
|
</div>
|
|
180
|
-
) :
|
|
181
|
-
<AlertCircle className="h-5 w-5 text-
|
|
234
|
+
) : (showInternalError || hasExternalError) && !disabled ? (
|
|
235
|
+
<AlertCircle className="h-5 w-5 text-[#EF4444]" />
|
|
182
236
|
) : null}
|
|
183
237
|
</div>
|
|
184
238
|
</div>
|
|
185
239
|
|
|
186
240
|
{/* Progress bar */}
|
|
187
|
-
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-
|
|
241
|
+
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-[#F3F4F6]">
|
|
188
242
|
<div
|
|
189
|
-
className={cn(
|
|
243
|
+
className={cn(
|
|
244
|
+
"h-full rounded-full transition-all duration-300 ease-out",
|
|
245
|
+
progressClass,
|
|
246
|
+
)}
|
|
190
247
|
style={{ width: `${progress}%` }}
|
|
191
248
|
/>
|
|
192
249
|
</div>
|
|
@@ -199,4 +256,4 @@ const AadhaarInput: React.FC<AadhaarInputProps> = ({
|
|
|
199
256
|
);
|
|
200
257
|
};
|
|
201
258
|
|
|
202
|
-
export default AadhaarInput;
|
|
259
|
+
export default AadhaarInput;
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
"use client"
|
|
1
|
+
"use client";
|
|
2
2
|
|
|
3
|
-
import React, { useState, useRef, useCallback } from "react";
|
|
4
|
-
import { cn } from "./utils";
|
|
3
|
+
import React, { useState, useRef, useCallback, useEffect } from "react";
|
|
5
4
|
import { Upload, X, Eye, EyeOff, ShieldCheck, AlertCircle } from "lucide-react";
|
|
5
|
+
import { cn } from "./utils";
|
|
6
6
|
|
|
7
7
|
interface MaskedAadhaarUploadProps {
|
|
8
|
+
// --- Controlled mode ---
|
|
9
|
+
value?: File | null;
|
|
10
|
+
onChange?: (file: File | null) => void;
|
|
11
|
+
// --- Uncontrolled mode ---
|
|
12
|
+
defaultValue?: File;
|
|
8
13
|
onFileSelect?: (file: File | null) => void;
|
|
14
|
+
// --- Shared ---
|
|
9
15
|
className?: string;
|
|
10
16
|
label?: string;
|
|
11
17
|
maxSizeMB?: number;
|
|
@@ -14,11 +20,19 @@ interface MaskedAadhaarUploadProps {
|
|
|
14
20
|
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
|
|
15
21
|
|
|
16
22
|
const MaskedAadhaarUpload: React.FC<MaskedAadhaarUploadProps> = ({
|
|
23
|
+
value,
|
|
24
|
+
onChange,
|
|
25
|
+
defaultValue,
|
|
17
26
|
onFileSelect,
|
|
18
27
|
className,
|
|
19
28
|
label = "Upload Masked Aadhaar",
|
|
20
29
|
maxSizeMB = 5,
|
|
21
30
|
}) => {
|
|
31
|
+
const isControlled = value !== undefined;
|
|
32
|
+
|
|
33
|
+
const [internalFile, setInternalFile] = useState<File | null>(defaultValue ?? null);
|
|
34
|
+
const currentFile = isControlled ? value : internalFile;
|
|
35
|
+
|
|
22
36
|
const [preview, setPreview] = useState<string | null>(null);
|
|
23
37
|
const [fileName, setFileName] = useState<string | null>(null);
|
|
24
38
|
const [fileSize, setFileSize] = useState<string | null>(null);
|
|
@@ -33,12 +47,36 @@ const MaskedAadhaarUpload: React.FC<MaskedAadhaarUploadProps> = ({
|
|
|
33
47
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
34
48
|
};
|
|
35
49
|
|
|
50
|
+
// Sync preview when currentFile changes (handles controlled clears like value={null})
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!currentFile) {
|
|
53
|
+
setPreview(null);
|
|
54
|
+
setFileName(null);
|
|
55
|
+
setFileSize(null);
|
|
56
|
+
setShowPreview(false);
|
|
57
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Avoid re-reading if same file is already previewed
|
|
62
|
+
if (currentFile.name === fileName && currentFile.size === Number(fileSize?.replace(/[^0-9]/g, ""))) return;
|
|
63
|
+
|
|
64
|
+
const reader = new FileReader();
|
|
65
|
+
reader.onload = (e) => {
|
|
66
|
+
setPreview(e.target?.result as string);
|
|
67
|
+
setFileName(currentFile.name);
|
|
68
|
+
setFileSize(formatFileSize(currentFile.size));
|
|
69
|
+
setShowPreview(false);
|
|
70
|
+
};
|
|
71
|
+
reader.readAsDataURL(currentFile);
|
|
72
|
+
}, [currentFile]);
|
|
73
|
+
|
|
36
74
|
const processFile = useCallback(
|
|
37
75
|
(file: File) => {
|
|
38
76
|
setError(null);
|
|
39
77
|
|
|
40
78
|
if (!ACCEPTED_TYPES.includes(file.type)) {
|
|
41
|
-
setError("Only JPG, PNG,
|
|
79
|
+
setError("Only JPG, PNG, WebP images or PDF are accepted");
|
|
42
80
|
return;
|
|
43
81
|
}
|
|
44
82
|
|
|
@@ -47,17 +85,16 @@ const MaskedAadhaarUpload: React.FC<MaskedAadhaarUploadProps> = ({
|
|
|
47
85
|
return;
|
|
48
86
|
}
|
|
49
87
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
reader.readAsDataURL(file);
|
|
88
|
+
// In uncontrolled mode, update internal state
|
|
89
|
+
if (!isControlled) {
|
|
90
|
+
setInternalFile(file);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Always notify parent
|
|
94
|
+
onChange?.(file);
|
|
95
|
+
onFileSelect?.(file);
|
|
59
96
|
},
|
|
60
|
-
[maxSizeMB, onFileSelect]
|
|
97
|
+
[maxSizeMB, isControlled, onChange, onFileSelect]
|
|
61
98
|
);
|
|
62
99
|
|
|
63
100
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
@@ -83,12 +120,14 @@ const MaskedAadhaarUpload: React.FC<MaskedAadhaarUploadProps> = ({
|
|
|
83
120
|
const handleDragLeave = () => setDragging(false);
|
|
84
121
|
|
|
85
122
|
const removeFile = () => {
|
|
86
|
-
setPreview(null);
|
|
87
|
-
setFileName(null);
|
|
88
|
-
setFileSize(null);
|
|
89
123
|
setError(null);
|
|
90
|
-
|
|
91
|
-
if (
|
|
124
|
+
|
|
125
|
+
if (!isControlled) {
|
|
126
|
+
setInternalFile(null);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Notify parent — in controlled mode, parent must set value={null} to clear
|
|
130
|
+
onChange?.(null);
|
|
92
131
|
onFileSelect?.(null);
|
|
93
132
|
};
|
|
94
133
|
|
|
@@ -136,13 +175,12 @@ const MaskedAadhaarUpload: React.FC<MaskedAadhaarUploadProps> = ({
|
|
|
136
175
|
{dragging ? "Drop your file here" : "Click or drag to upload"}
|
|
137
176
|
</p>
|
|
138
177
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
139
|
-
JPG, PNG, or
|
|
178
|
+
JPG, PNG, WebP or PDF · Max {maxSizeMB}MB
|
|
140
179
|
</p>
|
|
141
180
|
</div>
|
|
142
181
|
</div>
|
|
143
182
|
) : (
|
|
144
183
|
<div className="relative rounded-lg border-2 border-aadhaar-success bg-card overflow-hidden transition-all duration-200">
|
|
145
|
-
{/* Blurred / visible preview */}
|
|
146
184
|
<div className="relative aspect-16/10 w-full bg-muted overflow-hidden">
|
|
147
185
|
<img
|
|
148
186
|
src={preview}
|
|
@@ -159,7 +197,6 @@ const MaskedAadhaarUpload: React.FC<MaskedAadhaarUploadProps> = ({
|
|
|
159
197
|
)}
|
|
160
198
|
</div>
|
|
161
199
|
|
|
162
|
-
{/* File info bar */}
|
|
163
200
|
<div className="flex items-center gap-2 px-3 py-2.5 border-t border-border">
|
|
164
201
|
<ShieldCheck className="h-4 w-4 shrink-0 text-aadhaar-success" />
|
|
165
202
|
<div className="flex-1 min-w-0">
|
|
@@ -186,7 +223,6 @@ const MaskedAadhaarUpload: React.FC<MaskedAadhaarUploadProps> = ({
|
|
|
186
223
|
</div>
|
|
187
224
|
)}
|
|
188
225
|
|
|
189
|
-
{/* Helper / error text */}
|
|
190
226
|
<div className="mt-1.5 text-xs">
|
|
191
227
|
{error ? (
|
|
192
228
|
<span className="flex items-center gap-1 text-destructive">
|
|
@@ -197,7 +233,7 @@ const MaskedAadhaarUpload: React.FC<MaskedAadhaarUploadProps> = ({
|
|
|
197
233
|
<span className="text-aadhaar-success">Aadhaar photo uploaded</span>
|
|
198
234
|
) : (
|
|
199
235
|
<span className="text-muted-foreground">
|
|
200
|
-
Upload a masked Aadhaar card image (number partially hidden)
|
|
236
|
+
Upload a masked Aadhaar card image (number partially hidden) max {maxSizeMB}MB
|
|
201
237
|
</span>
|
|
202
238
|
)}
|
|
203
239
|
</div>
|
|
@@ -205,4 +241,4 @@ const MaskedAadhaarUpload: React.FC<MaskedAadhaarUploadProps> = ({
|
|
|
205
241
|
);
|
|
206
242
|
};
|
|
207
243
|
|
|
208
|
-
export default MaskedAadhaarUpload;
|
|
244
|
+
export default MaskedAadhaarUpload;
|
package/registry/otp-input.tsx
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
"use client"
|
|
2
1
|
|
|
3
2
|
import { useState, useRef, useCallback, useEffect, KeyboardEvent, ClipboardEvent } from "react";
|
|
4
3
|
import { Timer, RotateCcw, CheckCircle2, ShieldCheck } from "lucide-react";
|
|
@@ -150,7 +149,7 @@ const OtpInput = ({
|
|
|
150
149
|
return (
|
|
151
150
|
<div className={`space-y-4${className ? ` ${className}` : ""}`}>
|
|
152
151
|
{/* Header */}
|
|
153
|
-
<div className="flex items-center gap-2
|
|
152
|
+
<div className="flex items-center gap-2">
|
|
154
153
|
<ShieldCheck className="h-5 w-5 text-primary" />
|
|
155
154
|
<span className="text-sm font-medium text-foreground">
|
|
156
155
|
Enter {length}-digit OTP
|
|
@@ -186,12 +185,13 @@ const OtpInput = ({
|
|
|
186
185
|
</div>
|
|
187
186
|
|
|
188
187
|
{/* Progress dots */}
|
|
189
|
-
<div className="flex justify-center
|
|
188
|
+
<div className="flex justify-center gap-1.5">
|
|
190
189
|
{digits.map((d, i) => (
|
|
191
190
|
<div
|
|
192
191
|
key={i}
|
|
193
|
-
className={`h-1.5 w-1.5 rounded-full transition-all duration-200 ${
|
|
194
|
-
|
|
192
|
+
className={`h-1.5 w-1.5 rounded-full transition-all duration-200 ${
|
|
193
|
+
d ? "bg-primary scale-125" : "bg-muted-foreground/30"
|
|
194
|
+
}`}
|
|
195
195
|
/>
|
|
196
196
|
))}
|
|
197
197
|
</div>
|
package/registry/pan-input.tsx
CHANGED
|
@@ -1,41 +1,36 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
interface Segment {
|
|
6
|
-
label: string;
|
|
7
|
-
done: boolean;
|
|
8
|
-
valid: boolean;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface PanCardInputProps {
|
|
12
|
-
value?: string;
|
|
13
|
-
onChange?: (value: string) => void;
|
|
14
|
-
onValid?: (value: string) => void;
|
|
15
|
-
label?: string;
|
|
16
|
-
error?: string;
|
|
17
|
-
className?: string;
|
|
18
|
-
}
|
|
3
|
+
import React, { useState, useRef, useCallback, useEffect } from "react";
|
|
19
4
|
|
|
20
5
|
// ─── Validation utilities ────────────────────────────────────────────────────
|
|
21
6
|
|
|
22
7
|
const PAN_REGEX = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
|
|
23
8
|
|
|
9
|
+
/** 1. Remove spaces/special chars 2. Convert to uppercase 3. Enforce max 10 chars */
|
|
24
10
|
const sanitize = (input: string): string =>
|
|
25
11
|
input
|
|
26
12
|
.toUpperCase()
|
|
27
13
|
.replace(/[^A-Z0-9]/g, "")
|
|
28
14
|
.slice(0, 10);
|
|
29
15
|
|
|
16
|
+
/** Format for display: "ABCDE 1234 F" */
|
|
30
17
|
const format = (cleaned: string): string => {
|
|
31
18
|
if (cleaned.length <= 5) return cleaned;
|
|
32
19
|
if (cleaned.length <= 9) return `${cleaned.slice(0, 5)} ${cleaned.slice(5)}`;
|
|
33
20
|
return `${cleaned.slice(0, 5)} ${cleaned.slice(5, 9)} ${cleaned.slice(9)}`;
|
|
34
21
|
};
|
|
35
22
|
|
|
23
|
+
/** 3. Check length === 10 4. Test regex pattern */
|
|
36
24
|
const isValid = (cleaned: string): boolean =>
|
|
37
25
|
cleaned.length === 10 && PAN_REGEX.test(cleaned);
|
|
38
26
|
|
|
27
|
+
interface Segment {
|
|
28
|
+
label: string;
|
|
29
|
+
done: boolean;
|
|
30
|
+
valid: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Segment-level validation hints */
|
|
39
34
|
const getSegments = (cleaned: string): Segment[] => [
|
|
40
35
|
{
|
|
41
36
|
label: "Letters",
|
|
@@ -56,42 +51,81 @@ const getSegments = (cleaned: string): Segment[] => [
|
|
|
56
51
|
},
|
|
57
52
|
];
|
|
58
53
|
|
|
54
|
+
// ─── Props ───────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
interface PanCardInputProps {
|
|
57
|
+
value?: string;
|
|
58
|
+
onChange?: (value: string) => void;
|
|
59
|
+
onComplete?: (value: string) => void;
|
|
60
|
+
onValid?: (value: string) => void;
|
|
61
|
+
className?: string;
|
|
62
|
+
label?: string;
|
|
63
|
+
placeholder?: string;
|
|
64
|
+
error?: boolean;
|
|
65
|
+
errorMessage?: string;
|
|
66
|
+
disabled?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
59
69
|
// ─── Component ───────────────────────────────────────────────────────────────
|
|
60
70
|
|
|
61
71
|
const PanCardInput: React.FC<PanCardInputProps> = ({
|
|
62
72
|
value: controlledValue,
|
|
63
73
|
onChange,
|
|
74
|
+
onComplete,
|
|
64
75
|
onValid,
|
|
65
76
|
className = "",
|
|
66
77
|
label = "PAN Card Number",
|
|
67
|
-
|
|
78
|
+
placeholder = "ABCDE 1234 F",
|
|
79
|
+
error = false,
|
|
80
|
+
errorMessage,
|
|
81
|
+
disabled = false,
|
|
68
82
|
}) => {
|
|
69
|
-
const [
|
|
83
|
+
const [internalValue, setInternalValue] = useState<string>("");
|
|
70
84
|
const [focused, setFocused] = useState<boolean>(false);
|
|
85
|
+
const [hasCompletedOnce, setHasCompletedOnce] = useState<boolean>(false);
|
|
71
86
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
72
87
|
|
|
73
|
-
const raw = controlledValue !== undefined ? controlledValue :
|
|
88
|
+
const raw = controlledValue !== undefined ? controlledValue : internalValue;
|
|
74
89
|
const cleaned = sanitize(raw);
|
|
75
90
|
const displayed = format(cleaned);
|
|
76
91
|
const valid = isValid(cleaned);
|
|
77
92
|
const segments = getSegments(cleaned);
|
|
78
|
-
|
|
93
|
+
|
|
94
|
+
const complete = cleaned.length === 10;
|
|
95
|
+
const hasExternalError = error || !!errorMessage;
|
|
96
|
+
const showInternalError = !focused && cleaned.length > 0 && !valid;
|
|
97
|
+
const showError = hasExternalError || showInternalError;
|
|
98
|
+
|
|
99
|
+
// Fire onComplete when 10 characters are entered (once)
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (complete && !hasCompletedOnce && onComplete) {
|
|
102
|
+
onComplete(cleaned);
|
|
103
|
+
setHasCompletedOnce(true);
|
|
104
|
+
}
|
|
105
|
+
if (!complete && hasCompletedOnce) {
|
|
106
|
+
setHasCompletedOnce(false);
|
|
107
|
+
}
|
|
108
|
+
}, [complete, cleaned, onComplete, hasCompletedOnce]);
|
|
79
109
|
|
|
80
110
|
const barClass = (seg: Segment): string => {
|
|
111
|
+
if (disabled) return "bg-gray-200";
|
|
81
112
|
if (!seg.done) return "bg-gray-200";
|
|
82
113
|
if (!seg.valid) return "bg-red-400";
|
|
83
114
|
if (valid) return "bg-green-500";
|
|
84
115
|
return "bg-blue-500";
|
|
85
116
|
};
|
|
86
117
|
|
|
118
|
+
// 5. onChange / onValid callbacks
|
|
87
119
|
const handleChange = useCallback(
|
|
88
120
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
89
|
-
|
|
121
|
+
if (disabled) return;
|
|
122
|
+
|
|
123
|
+
const next = sanitize(e.target.value); // steps 1, 2, 3
|
|
90
124
|
|
|
91
125
|
if (onChange) onChange(next);
|
|
92
|
-
else
|
|
126
|
+
else setInternalValue(next);
|
|
93
127
|
|
|
94
|
-
if (isValid(next) && onValid) onValid(next);
|
|
128
|
+
if (isValid(next) && onValid) onValid(next); // step 4 + 5
|
|
95
129
|
|
|
96
130
|
requestAnimationFrame(() => {
|
|
97
131
|
if (inputRef.current) {
|
|
@@ -100,29 +134,44 @@ const PanCardInput: React.FC<PanCardInputProps> = ({
|
|
|
100
134
|
}
|
|
101
135
|
});
|
|
102
136
|
},
|
|
103
|
-
[onChange, onValid]
|
|
137
|
+
[onChange, onValid, disabled],
|
|
104
138
|
);
|
|
105
139
|
|
|
106
|
-
const borderClass =
|
|
107
|
-
? "border-
|
|
108
|
-
:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
140
|
+
const borderClass = disabled
|
|
141
|
+
? "border-gray-200 bg-gray-50"
|
|
142
|
+
: focused
|
|
143
|
+
? "border-blue-500 ring-2 ring-blue-100"
|
|
144
|
+
: valid && !hasExternalError
|
|
145
|
+
? "border-green-500 ring-2 ring-green-100"
|
|
146
|
+
: showError
|
|
147
|
+
? "border-red-400 ring-2 ring-red-100"
|
|
148
|
+
: "border-gray-200 hover:border-gray-400";
|
|
149
|
+
|
|
150
|
+
interface HelperText {
|
|
151
|
+
text: string;
|
|
152
|
+
cls: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const helperText = (): HelperText => {
|
|
156
|
+
if (hasExternalError && errorMessage) {
|
|
157
|
+
return { text: errorMessage, cls: "text-red-500" };
|
|
158
|
+
}
|
|
159
|
+
if (showInternalError && !hasExternalError) {
|
|
160
|
+
const badSeg = segments.find((s) => s.done && !s.valid);
|
|
161
|
+
if (badSeg) {
|
|
162
|
+
return {
|
|
163
|
+
text: `Invalid characters in ${badSeg.label} section`,
|
|
164
|
+
cls: "text-red-500",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return { text: "Invalid PAN number", cls: "text-red-500" };
|
|
168
|
+
}
|
|
169
|
+
if (cleaned.length === 0) {
|
|
117
170
|
return { text: "Format: ABCDE1234F", cls: "text-gray-400" };
|
|
118
|
-
|
|
171
|
+
}
|
|
172
|
+
if (valid && !hasExternalError) {
|
|
119
173
|
return { text: "✓ Valid PAN number", cls: "text-green-600 font-medium" };
|
|
120
|
-
|
|
121
|
-
if (badSeg)
|
|
122
|
-
return {
|
|
123
|
-
text: `Invalid characters in ${badSeg.label} section`,
|
|
124
|
-
cls: "text-red-500",
|
|
125
|
-
};
|
|
174
|
+
}
|
|
126
175
|
return { text: `${cleaned.length}/10 characters`, cls: "text-gray-400" };
|
|
127
176
|
};
|
|
128
177
|
|
|
@@ -137,12 +186,17 @@ const PanCardInput: React.FC<PanCardInputProps> = ({
|
|
|
137
186
|
)}
|
|
138
187
|
|
|
139
188
|
<div
|
|
140
|
-
className={`relative flex items-center rounded-xl border-2 bg-white px-4 py-3
|
|
141
|
-
onClick={() => inputRef.current?.focus()}
|
|
189
|
+
className={`relative flex items-center rounded-xl border-2 bg-white px-4 py-3 transition-all duration-200 ${disabled ? "cursor-not-allowed" : "cursor-text"} ${borderClass}`}
|
|
190
|
+
onClick={() => !disabled && inputRef.current?.focus()}
|
|
142
191
|
>
|
|
192
|
+
{/* File icon */}
|
|
143
193
|
<svg
|
|
144
194
|
className={`mr-3 h-5 w-5 shrink-0 transition-colors duration-200 ${
|
|
145
|
-
|
|
195
|
+
disabled
|
|
196
|
+
? "text-gray-300"
|
|
197
|
+
: focused
|
|
198
|
+
? "text-blue-500"
|
|
199
|
+
: "text-gray-400"
|
|
146
200
|
}`}
|
|
147
201
|
viewBox="0 0 24 24"
|
|
148
202
|
fill="none"
|
|
@@ -163,20 +217,26 @@ const PanCardInput: React.FC<PanCardInputProps> = ({
|
|
|
163
217
|
type="text"
|
|
164
218
|
autoComplete="off"
|
|
165
219
|
spellCheck={false}
|
|
166
|
-
placeholder=
|
|
220
|
+
placeholder={placeholder}
|
|
167
221
|
value={displayed}
|
|
168
222
|
onChange={handleChange}
|
|
169
|
-
onFocus={() => setFocused(true)}
|
|
223
|
+
onFocus={() => !disabled && setFocused(true)}
|
|
170
224
|
onBlur={() => setFocused(false)}
|
|
171
|
-
|
|
225
|
+
disabled={disabled}
|
|
226
|
+
className={`flex-1 bg-transparent text-lg font-mono tracking-widest outline-none uppercase ${
|
|
227
|
+
disabled
|
|
228
|
+
? "text-gray-400 placeholder-gray-300 cursor-not-allowed"
|
|
229
|
+
: "text-gray-800 placeholder-gray-300"
|
|
230
|
+
}`}
|
|
172
231
|
maxLength={12}
|
|
173
232
|
aria-label="PAN Card Number"
|
|
174
233
|
aria-invalid={showError}
|
|
175
234
|
aria-describedby="pan-helper"
|
|
235
|
+
aria-disabled={disabled}
|
|
176
236
|
/>
|
|
177
237
|
|
|
178
238
|
<div className="ml-2 shrink-0">
|
|
179
|
-
{valid ? (
|
|
239
|
+
{valid && !hasExternalError && !disabled ? (
|
|
180
240
|
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-blue-500 text-white pan-bounce">
|
|
181
241
|
<svg
|
|
182
242
|
className="h-4 w-4"
|
|
@@ -188,7 +248,7 @@ const PanCardInput: React.FC<PanCardInputProps> = ({
|
|
|
188
248
|
<polyline points="20 6 9 17 4 12" />
|
|
189
249
|
</svg>
|
|
190
250
|
</span>
|
|
191
|
-
) : showError ? (
|
|
251
|
+
) : showError && !disabled ? (
|
|
192
252
|
<svg
|
|
193
253
|
className="h-5 w-5 text-red-400"
|
|
194
254
|
viewBox="0 0 24 24"
|
|
@@ -235,4 +295,4 @@ const PanCardInput: React.FC<PanCardInputProps> = ({
|
|
|
235
295
|
);
|
|
236
296
|
};
|
|
237
297
|
|
|
238
|
-
export default PanCardInput;
|
|
298
|
+
export default PanCardInput;
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { useState, useRef } from "react";
|
|
2
|
+
import { Phone, Check, AlertCircle, MessageCircle } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface PhoneInputProps {
|
|
5
|
+
className?: string;
|
|
6
|
+
/** Controlled: phone number value (10 raw digits). Omit for uncontrolled. */
|
|
7
|
+
value?: string;
|
|
8
|
+
/** Controlled: callback with raw 10-digit string */
|
|
9
|
+
onChange?: (value: string) => void;
|
|
10
|
+
/** Show WhatsApp verification toggle */
|
|
11
|
+
enableWhatsApp?: boolean;
|
|
12
|
+
/** Controlled: WhatsApp toggle state */
|
|
13
|
+
whatsAppEnabled?: boolean;
|
|
14
|
+
/** WhatsApp toggle callback */
|
|
15
|
+
onWhatsAppToggle?: (enabled: boolean) => void;
|
|
16
|
+
/** External error message */
|
|
17
|
+
error?: string;
|
|
18
|
+
/** Disable the input */
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
/** Input placeholder */
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── Pure helpers (no hooks) ────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
function formatPhoneNumber(digits: string): string {
|
|
27
|
+
const d = digits.replace(/\D/g, "").slice(0, 10);
|
|
28
|
+
return d.length <= 5 ? d : `${d.slice(0, 5)} ${d.slice(5)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validatePhone(digits: string): string {
|
|
32
|
+
const d = digits.replace(/\D/g, "");
|
|
33
|
+
if (!d) return "";
|
|
34
|
+
if (d.length < 10) return "Phone number must be 10 digits";
|
|
35
|
+
if (!["6", "7", "8", "9"].includes(d[0]))
|
|
36
|
+
return "Phone number must start with 6, 7, 8, or 9";
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Component ───────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export default function PhoneInput({
|
|
43
|
+
value: controlledValue,
|
|
44
|
+
onChange,
|
|
45
|
+
enableWhatsApp = false,
|
|
46
|
+
whatsAppEnabled: controlledWhatsApp,
|
|
47
|
+
onWhatsAppToggle,
|
|
48
|
+
error: externalError,
|
|
49
|
+
disabled = false,
|
|
50
|
+
placeholder = "98765 43210",
|
|
51
|
+
}: PhoneInputProps) {
|
|
52
|
+
const isControlled = controlledValue !== undefined;
|
|
53
|
+
const isWhatsAppControlled = controlledWhatsApp !== undefined;
|
|
54
|
+
|
|
55
|
+
// Uncontrolled internal state
|
|
56
|
+
const [internalPhone, setInternalPhone] = useState("");
|
|
57
|
+
const [internalWhatsApp, setInternalWhatsApp] = useState(false);
|
|
58
|
+
|
|
59
|
+
// Interaction state
|
|
60
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
61
|
+
const [internalError, setInternalError] = useState("");
|
|
62
|
+
const [touched, setTouched] = useState(false);
|
|
63
|
+
|
|
64
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
65
|
+
|
|
66
|
+
// Resolved values
|
|
67
|
+
const value = isControlled ? (controlledValue ?? "") : internalPhone;
|
|
68
|
+
const whatsAppEnabled = isWhatsAppControlled
|
|
69
|
+
? (controlledWhatsApp ?? false)
|
|
70
|
+
: internalWhatsApp;
|
|
71
|
+
|
|
72
|
+
// ── Handlers ────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
function commitValue(raw: string) {
|
|
75
|
+
const clean = raw.replace(/\D/g, "").slice(0, 10);
|
|
76
|
+
if (!isControlled) setInternalPhone(clean);
|
|
77
|
+
onChange?.(clean);
|
|
78
|
+
if (touched) setInternalError(validatePhone(clean));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
82
|
+
commitValue(e.target.value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function handleBlur() {
|
|
86
|
+
setIsFocused(false);
|
|
87
|
+
setTouched(true);
|
|
88
|
+
setInternalError(validatePhone(value));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
commitValue(e.clipboardData.getData("text"));
|
|
94
|
+
setTouched(true);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function handleWhatsAppToggle() {
|
|
98
|
+
const next = !whatsAppEnabled;
|
|
99
|
+
if (!isWhatsAppControlled) setInternalWhatsApp(next);
|
|
100
|
+
onWhatsAppToggle?.(next);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Derived state ────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
const currentError = externalError || internalError;
|
|
106
|
+
const isValid = value.length === 10 && !currentError;
|
|
107
|
+
const hasError = touched && !!currentError;
|
|
108
|
+
const showSuccess = isValid && touched;
|
|
109
|
+
|
|
110
|
+
// ── Styles ───────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
const borderClass = hasError
|
|
113
|
+
? "border-red-500 dark:border-red-400"
|
|
114
|
+
: isValid
|
|
115
|
+
? "border-green-500 dark:border-green-400"
|
|
116
|
+
: isFocused
|
|
117
|
+
? "border-black dark:border-white"
|
|
118
|
+
: "border-neutral-300 dark:border-neutral-700 hover:border-neutral-400 dark:hover:border-neutral-500";
|
|
119
|
+
|
|
120
|
+
const iconClass = hasError
|
|
121
|
+
? "text-red-500 dark:text-red-400"
|
|
122
|
+
: isValid
|
|
123
|
+
? "text-green-500 dark:text-green-400"
|
|
124
|
+
: "text-neutral-500 dark:text-neutral-400";
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className="w-full">
|
|
128
|
+
<style>{`
|
|
129
|
+
@keyframes phone-fade-in {
|
|
130
|
+
from { opacity: 0; transform: translateY(-4px); }
|
|
131
|
+
to { opacity: 1; transform: translateY(0); }
|
|
132
|
+
}
|
|
133
|
+
.phone-fade-in { animation: phone-fade-in 0.2s ease-out both; }
|
|
134
|
+
`}</style>
|
|
135
|
+
|
|
136
|
+
{/* ── Input row ── */}
|
|
137
|
+
<div
|
|
138
|
+
className={[
|
|
139
|
+
"flex items-center rounded-lg border transition-all duration-200",
|
|
140
|
+
disabled
|
|
141
|
+
? "opacity-50 cursor-not-allowed bg-neutral-100 dark:bg-neutral-900"
|
|
142
|
+
: "bg-white dark:bg-neutral-900",
|
|
143
|
+
borderClass,
|
|
144
|
+
].join(" ")}
|
|
145
|
+
>
|
|
146
|
+
{/* Phone icon */}
|
|
147
|
+
<div className="pl-4 pr-3 flex items-center shrink-0">
|
|
148
|
+
<Phone
|
|
149
|
+
size={18}
|
|
150
|
+
className={`transition-colors duration-200 ${iconClass}`}
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{/* +91 prefix */}
|
|
155
|
+
<div className="flex items-center pr-2 shrink-0">
|
|
156
|
+
<span className="text-[15px] font-semibold text-black dark:text-white select-none">
|
|
157
|
+
+91
|
|
158
|
+
</span>
|
|
159
|
+
<div className="w-px h-6 bg-neutral-200 dark:bg-neutral-700 ml-3" />
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
{/* Text input */}
|
|
163
|
+
<input
|
|
164
|
+
ref={inputRef}
|
|
165
|
+
type="tel"
|
|
166
|
+
inputMode="numeric"
|
|
167
|
+
value={formatPhoneNumber(value)}
|
|
168
|
+
onChange={handleChange}
|
|
169
|
+
onFocus={() => setIsFocused(true)}
|
|
170
|
+
onBlur={handleBlur}
|
|
171
|
+
onPaste={handlePaste}
|
|
172
|
+
disabled={disabled}
|
|
173
|
+
placeholder={placeholder}
|
|
174
|
+
aria-invalid={hasError}
|
|
175
|
+
aria-describedby={hasError ? "phone-error" : undefined}
|
|
176
|
+
className={[
|
|
177
|
+
"flex-1 py-3 pr-4 bg-transparent outline-none",
|
|
178
|
+
"text-[15px] text-black dark:text-white",
|
|
179
|
+
"placeholder-neutral-400 dark:placeholder-neutral-600",
|
|
180
|
+
disabled ? "cursor-not-allowed" : "",
|
|
181
|
+
].join(" ")}
|
|
182
|
+
/>
|
|
183
|
+
|
|
184
|
+
{/* Trailing icon */}
|
|
185
|
+
{isValid && !hasError && (
|
|
186
|
+
<div className="pr-4 flex items-center shrink-0">
|
|
187
|
+
<div className="w-5 h-5 rounded-full bg-green-500 dark:bg-green-400 flex items-center justify-center">
|
|
188
|
+
<Check size={12} className="text-white" strokeWidth={3} />
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
{hasError && (
|
|
193
|
+
<div className="pr-4 flex items-center shrink-0">
|
|
194
|
+
<AlertCircle
|
|
195
|
+
size={20}
|
|
196
|
+
className="text-red-500 dark:text-red-400"
|
|
197
|
+
/>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{/* ── Messages ── */}
|
|
203
|
+
{hasError && (
|
|
204
|
+
<div
|
|
205
|
+
id="phone-error"
|
|
206
|
+
role="alert"
|
|
207
|
+
className="mt-2 flex items-start gap-1.5 phone-fade-in"
|
|
208
|
+
>
|
|
209
|
+
<AlertCircle
|
|
210
|
+
size={14}
|
|
211
|
+
className="text-red-500 dark:text-red-400 mt-0.5 shrink-0"
|
|
212
|
+
/>
|
|
213
|
+
<p className="text-[13px] text-red-500 dark:text-red-400">
|
|
214
|
+
{currentError}
|
|
215
|
+
</p>
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
{showSuccess && !hasError && (
|
|
220
|
+
<div className="mt-2 flex items-start gap-1.5 phone-fade-in">
|
|
221
|
+
<Check
|
|
222
|
+
size={14}
|
|
223
|
+
className="text-green-500 dark:text-green-400 mt-0.5 shrink-0"
|
|
224
|
+
/>
|
|
225
|
+
<p className="text-[13px] text-green-500 dark:text-green-400">
|
|
226
|
+
Valid phone number
|
|
227
|
+
</p>
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
|
|
231
|
+
{/* ── WhatsApp toggle ── */}
|
|
232
|
+
{enableWhatsApp && (
|
|
233
|
+
<button
|
|
234
|
+
type="button"
|
|
235
|
+
onClick={handleWhatsAppToggle}
|
|
236
|
+
disabled={disabled || !isValid}
|
|
237
|
+
className={[
|
|
238
|
+
"mt-4 flex items-center gap-3 p-4 rounded-lg border w-full",
|
|
239
|
+
"transition-all duration-200 text-left",
|
|
240
|
+
disabled || !isValid
|
|
241
|
+
? "opacity-50 cursor-not-allowed"
|
|
242
|
+
: "cursor-pointer",
|
|
243
|
+
whatsAppEnabled
|
|
244
|
+
? "bg-green-50 dark:bg-green-900/20 border-green-500 dark:border-green-400"
|
|
245
|
+
: "bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700",
|
|
246
|
+
!disabled && isValid && !whatsAppEnabled
|
|
247
|
+
? "hover:bg-neutral-50 dark:hover:bg-neutral-800 hover:border-neutral-400 dark:hover:border-neutral-500 active:scale-[0.99]"
|
|
248
|
+
: "",
|
|
249
|
+
].join(" ")}
|
|
250
|
+
>
|
|
251
|
+
{/* Pill toggle */}
|
|
252
|
+
<div
|
|
253
|
+
className={[
|
|
254
|
+
"relative w-11 h-6 rounded-full transition-all duration-200 shrink-0",
|
|
255
|
+
whatsAppEnabled
|
|
256
|
+
? "bg-green-500 dark:bg-green-400"
|
|
257
|
+
: "bg-neutral-300 dark:bg-neutral-600",
|
|
258
|
+
].join(" ")}
|
|
259
|
+
>
|
|
260
|
+
<div
|
|
261
|
+
className={[
|
|
262
|
+
"absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-sm transition-all duration-200",
|
|
263
|
+
whatsAppEnabled ? "left-5.5" : "left-0.5",
|
|
264
|
+
].join(" ")}
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
{/* Label */}
|
|
269
|
+
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
270
|
+
<MessageCircle
|
|
271
|
+
size={20}
|
|
272
|
+
className={
|
|
273
|
+
whatsAppEnabled
|
|
274
|
+
? "text-green-500 dark:text-green-400 shrink-0"
|
|
275
|
+
: "text-neutral-500 dark:text-neutral-400 shrink-0"
|
|
276
|
+
}
|
|
277
|
+
/>
|
|
278
|
+
<div className="flex flex-col items-start min-w-0">
|
|
279
|
+
<span className="text-[14px] font-medium text-black dark:text-white">
|
|
280
|
+
WhatsApp Verification
|
|
281
|
+
</span>
|
|
282
|
+
<span className="text-[12px] text-neutral-500 dark:text-neutral-400">
|
|
283
|
+
{whatsAppEnabled ? "Enabled" : "Tap to enable"}
|
|
284
|
+
</span>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
{whatsAppEnabled && (
|
|
289
|
+
<Check
|
|
290
|
+
size={18}
|
|
291
|
+
className="text-green-500 dark:text-green-400 shrink-0"
|
|
292
|
+
/>
|
|
293
|
+
)}
|
|
294
|
+
</button>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
);
|
|
298
|
+
}
|