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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kesp-ui",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "description": "CLI to add kesp-ui components to your project",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -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],[1,2,3,4,0,6,7,8,9,5],[2,3,4,0,1,7,8,9,5,6],
11
- [3,4,0,1,2,8,9,5,6,7],[4,0,1,2,3,9,5,6,7,8],[5,9,8,7,6,0,4,3,2,1],
12
- [6,5,9,8,7,1,0,4,3,2],[7,6,5,9,8,2,1,0,4,3],[8,7,6,5,9,3,2,1,0,4],
13
- [9,8,7,6,5,4,3,2,1,0],
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],[1,5,7,6,2,8,3,0,9,4],[5,8,0,3,7,9,6,1,4,2],
18
- [8,9,1,6,0,4,3,5,2,7],[9,4,5,3,1,2,6,8,7,0],[4,2,8,6,5,7,3,9,0,1],
19
- [2,7,9,3,8,0,6,4,1,5],[7,0,4,6,9,1,3,2,5,8],
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?: (value: string) => void;
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
- error?: string;
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
- error,
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 ?? internalValue;
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
- const showError = complete && !valid;
82
- const hasExternalError = !!error;
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
- const newFormatted = formatAadhaar(digitsOnly);
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
- const borderClass = focused
105
- ? "border-primary shadow-[0_0_0_3px_hsl(var(--primary)/0.1)]"
106
- : complete && valid
107
- ? "border-green-500"
108
- : showError || hasExternalError
109
- ? "border-destructive"
110
- : "border-input hover:border-muted-foreground/40";
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
- const progressClass = complete && valid
113
- ? "bg-green-500"
114
- : showError
115
- ? "bg-destructive"
116
- : "bg-primary";
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 && error) {
122
- helperText = error;
123
- helperClass = "text-destructive";
124
- } else if (showError) {
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-destructive";
170
+ helperClass = "text-[#EF4444]";
127
171
  } else if (digits.length === 0) {
128
172
  helperText = "Enter your 12-digit Aadhaar number";
129
- helperClass = "text-muted-foreground";
130
- } else if (complete && valid) {
173
+ helperClass = "text-[#6B7280]";
174
+ } else if (complete && valid && !hasExternalError) {
131
175
  helperText = "Valid Aadhaar number";
132
- helperClass = "text-green-500";
176
+ helperClass = "text-[#10B981]";
133
177
  } else {
134
178
  helperText = `${digits.length}/12 digits`;
135
- helperClass = "text-muted-foreground";
179
+ helperClass = "text-[#6B7280]";
136
180
  }
137
181
 
138
182
  return (
139
- <div className={cn("w-full max-w-s ", className)}>
183
+ <div className={cn("w-full max-w-md", className)}>
140
184
  {label && (
141
- <label className="block text-sm font-medium text-foreground mb-1.5">
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-card px-3 py-3 transition-all duration-200 cursor-text",
149
- borderClass
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
- focused ? "text-primary" : "text-muted-foreground"
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="0000 0000 0000"
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
- className="flex-1 bg-transparent text-lg font-mono tracking-[0.15em] text-foreground placeholder:text-muted-foreground/50 outline-none"
170
- maxLength={14}
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={showError || hasExternalError}
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-green-500 text-white">
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
- ) : showError || hasExternalError ? (
181
- <AlertCircle className="h-5 w-5 text-destructive" />
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-muted">
241
+ <div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-[#F3F4F6]">
188
242
  <div
189
- className={cn("h-full rounded-full transition-all duration-300 ease-out", progressClass)}
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, or WebP images are accepted");
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
- const reader = new FileReader();
51
- reader.onload = (e) => {
52
- setPreview(e.target?.result as string);
53
- setFileName(file.name);
54
- setFileSize(formatFileSize(file.size));
55
- setShowPreview(false);
56
- onFileSelect?.(file);
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
- setShowPreview(false);
91
- if (inputRef.current) inputRef.current.value = "";
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 WebP · Max {maxSizeMB}MB
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) max5mb
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;
@@ -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 mb-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 mt-2 gap-1.5">
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 ${d ? "bg-primary scale-125" : "bg-muted-foreground/30"
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>
@@ -1,41 +1,36 @@
1
- import React, { useState, useRef, useCallback } from "react";
1
+ "use client";
2
2
 
3
- // ─── Types ───────────────────────────────────────────────────────────────────
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
- error,
78
+ placeholder = "ABCDE 1234 F",
79
+ error = false,
80
+ errorMessage,
81
+ disabled = false,
68
82
  }) => {
69
- const [internal, setInternal] = useState<string>("");
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 : internal;
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
- const showError = !!error || (!focused && cleaned.length > 0 && !valid);
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
- const next = sanitize(e.target.value);
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 setInternal(next);
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 = focused
107
- ? "border-blue-500 ring-2 ring-blue-100"
108
- : valid
109
- ? "border-green-500 ring-2 ring-green-100"
110
- : showError
111
- ? "border-red-400 ring-2 ring-red-100"
112
- : "border-gray-200 hover:border-gray-400";
113
-
114
- const helperText = (): { text: string; cls: string } => {
115
- if (showError && error) return { text: error, cls: "text-red-500" };
116
- if (cleaned.length === 0)
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
- if (valid)
171
+ }
172
+ if (valid && !hasExternalError) {
119
173
  return { text: "✓ Valid PAN number", cls: "text-green-600 font-medium" };
120
- const badSeg = segments.find((s) => s.done && !s.valid);
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 cursor-text transition-all duration-200 ${borderClass}`}
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
- focused ? "text-blue-500" : "text-gray-400"
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="ABCDE 1234 F"
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
- className="flex-1 bg-transparent text-lg font-mono tracking-widest text-gray-800 placeholder-gray-300 outline-none uppercase"
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
+ }