kesp-ui 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.
- package/README.md +47 -0
- package/aadhaar-input.tsx +200 -0
- package/index.js +103 -0
- package/maskedAadhar.tsx +206 -0
- package/otp-input.tsx +223 -0
- package/package.json +30 -0
- package/pan-input.tsx +238 -0
- package/registry.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# kesp-ui
|
|
2
|
+
|
|
3
|
+
A collection of Indian-specific UI components for Next.js + Tailwind CSS projects.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx kesp-ui add aadhaar-input
|
|
9
|
+
npx kesp-ui add pan-input
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Available Components
|
|
13
|
+
|
|
14
|
+
| Component | Description |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `aadhaar-input` | Validated Aadhaar number input with Verhoeff checksum |
|
|
17
|
+
| `pan-input` | Validated PAN Card input with segment progress bars |
|
|
18
|
+
|
|
19
|
+
## List all components
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx kesp-ui list
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Custom output path
|
|
26
|
+
|
|
27
|
+
By default components are added to `components/ui/`. You can change this:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx kesp-ui add aadhaar-input --path src/components/forms
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Example
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import AadhaarInput from "@/components/ui/aadhaar-input";
|
|
37
|
+
import PanCardInput from "@/components/ui/pan-input";
|
|
38
|
+
|
|
39
|
+
export default function Page() {
|
|
40
|
+
return (
|
|
41
|
+
<div>
|
|
42
|
+
<AadhaarInput label="Enter Aadhaar" onChange={(val) => console.log(val)} />
|
|
43
|
+
<PanCardInput label="Enter PAN" onValid={(val) => console.log(val)} />
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
```
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import React, { useState, useRef, useCallback } from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
import { Check, AlertCircle, CreditCard } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
// ─── Verhoeff Algorithm ───────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const D: number[][] = [
|
|
8
|
+
[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],
|
|
9
|
+
[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],
|
|
10
|
+
[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],
|
|
11
|
+
[9,8,7,6,5,4,3,2,1,0],
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const P: number[][] = [
|
|
15
|
+
[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],
|
|
16
|
+
[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],
|
|
17
|
+
[2,7,9,3,8,0,6,4,1,5],[7,0,4,6,9,1,3,2,5,8],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function verhoeff(num: string): boolean {
|
|
21
|
+
let c = 0;
|
|
22
|
+
const arr = num.split("").reverse().map(Number);
|
|
23
|
+
for (let i = 0; i < arr.length; i++) c = D[c][P[i % 8][arr[i]]];
|
|
24
|
+
return c === 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ─── Full Validation ──────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function isValidAadhaar(digits: string): boolean {
|
|
30
|
+
if (digits.length !== 12) return false;
|
|
31
|
+
if (!/^\d+$/.test(digits)) return false;
|
|
32
|
+
if (!/^[2-9]/.test(digits)) return false;
|
|
33
|
+
if (/^(\d)\1{11}$/.test(digits)) return false;
|
|
34
|
+
if (!verhoeff(digits)) return false;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const formatAadhaar = (raw: string): string => {
|
|
41
|
+
const digits = raw.replace(/\D/g, "").slice(0, 12);
|
|
42
|
+
const parts: string[] = [];
|
|
43
|
+
for (let i = 0; i < digits.length; i += 4) {
|
|
44
|
+
parts.push(digits.slice(i, i + 4));
|
|
45
|
+
}
|
|
46
|
+
return parts.join(" ");
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
interface AadhaarInputProps {
|
|
52
|
+
value?: string;
|
|
53
|
+
onChange?: (value: string) => void;
|
|
54
|
+
className?: string;
|
|
55
|
+
label?: string;
|
|
56
|
+
error?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Component ────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
const AadhaarInput: React.FC<AadhaarInputProps> = ({
|
|
62
|
+
value: controlledValue,
|
|
63
|
+
onChange,
|
|
64
|
+
className,
|
|
65
|
+
label = "Aadhaar Number",
|
|
66
|
+
error,
|
|
67
|
+
}) => {
|
|
68
|
+
const [internalValue, setInternalValue] = useState("");
|
|
69
|
+
const [focused, setFocused] = useState(false);
|
|
70
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
71
|
+
|
|
72
|
+
const raw = controlledValue ?? internalValue;
|
|
73
|
+
const digits = raw.replace(/\D/g, "");
|
|
74
|
+
const formatted = formatAadhaar(raw);
|
|
75
|
+
|
|
76
|
+
const complete = digits.length === 12;
|
|
77
|
+
const valid = isValidAadhaar(digits);
|
|
78
|
+
|
|
79
|
+
const showError = complete && !valid;
|
|
80
|
+
const hasExternalError = !!error;
|
|
81
|
+
|
|
82
|
+
const progress = Math.min((digits.length / 12) * 100, 100);
|
|
83
|
+
|
|
84
|
+
const handleChange = useCallback(
|
|
85
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
86
|
+
const input = e.target.value;
|
|
87
|
+
const digitsOnly = input.replace(/\D/g, "").slice(0, 12);
|
|
88
|
+
const newFormatted = formatAadhaar(digitsOnly);
|
|
89
|
+
if (onChange) onChange(digitsOnly);
|
|
90
|
+
else setInternalValue(digitsOnly);
|
|
91
|
+
|
|
92
|
+
requestAnimationFrame(() => {
|
|
93
|
+
if (inputRef.current) {
|
|
94
|
+
const cursorPos = newFormatted.length;
|
|
95
|
+
inputRef.current.setSelectionRange(cursorPos, cursorPos);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
[onChange]
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const borderClass = focused
|
|
103
|
+
? "border-primary shadow-[0_0_0_3px_hsl(var(--primary)/0.1)]"
|
|
104
|
+
: complete && valid
|
|
105
|
+
? "border-green-500"
|
|
106
|
+
: showError || hasExternalError
|
|
107
|
+
? "border-destructive"
|
|
108
|
+
: "border-input hover:border-muted-foreground/40";
|
|
109
|
+
|
|
110
|
+
const progressClass = complete && valid
|
|
111
|
+
? "bg-green-500"
|
|
112
|
+
: showError
|
|
113
|
+
? "bg-destructive"
|
|
114
|
+
: "bg-primary";
|
|
115
|
+
|
|
116
|
+
let helperText: string;
|
|
117
|
+
let helperClass: string;
|
|
118
|
+
|
|
119
|
+
if (hasExternalError && error) {
|
|
120
|
+
helperText = error;
|
|
121
|
+
helperClass = "text-destructive";
|
|
122
|
+
} else if (showError) {
|
|
123
|
+
helperText = "Invalid Aadhaar number";
|
|
124
|
+
helperClass = "text-destructive";
|
|
125
|
+
} else if (digits.length === 0) {
|
|
126
|
+
helperText = "Enter your 12-digit Aadhaar number";
|
|
127
|
+
helperClass = "text-muted-foreground";
|
|
128
|
+
} else if (complete && valid) {
|
|
129
|
+
helperText = "Valid Aadhaar number";
|
|
130
|
+
helperClass = "text-green-500";
|
|
131
|
+
} else {
|
|
132
|
+
helperText = `${digits.length}/12 digits`;
|
|
133
|
+
helperClass = "text-muted-foreground";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className={cn("w-full max-w-s ", className)}>
|
|
138
|
+
{label && (
|
|
139
|
+
<label className="block text-sm font-medium text-foreground mb-1.5">
|
|
140
|
+
{label}
|
|
141
|
+
</label>
|
|
142
|
+
)}
|
|
143
|
+
|
|
144
|
+
<div
|
|
145
|
+
className={cn(
|
|
146
|
+
"relative flex items-center rounded-lg border-2 bg-card px-3 py-3 transition-all duration-200 cursor-text",
|
|
147
|
+
borderClass
|
|
148
|
+
)}
|
|
149
|
+
onClick={() => inputRef.current?.focus()}
|
|
150
|
+
>
|
|
151
|
+
<CreditCard
|
|
152
|
+
className={cn(
|
|
153
|
+
"mr-3 h-5 w-5 shrink-0 transition-colors duration-200",
|
|
154
|
+
focused ? "text-primary" : "text-muted-foreground"
|
|
155
|
+
)}
|
|
156
|
+
/>
|
|
157
|
+
<input
|
|
158
|
+
ref={inputRef}
|
|
159
|
+
type="text"
|
|
160
|
+
inputMode="numeric"
|
|
161
|
+
autoComplete="off"
|
|
162
|
+
placeholder="0000 0000 0000"
|
|
163
|
+
value={formatted}
|
|
164
|
+
onChange={handleChange}
|
|
165
|
+
onFocus={() => setFocused(true)}
|
|
166
|
+
onBlur={() => setFocused(false)}
|
|
167
|
+
className="flex-1 bg-transparent text-lg font-mono tracking-[0.15em] text-foreground placeholder:text-muted-foreground/50 outline-none"
|
|
168
|
+
maxLength={14}
|
|
169
|
+
aria-label="Aadhaar Number"
|
|
170
|
+
aria-invalid={showError || hasExternalError}
|
|
171
|
+
/>
|
|
172
|
+
|
|
173
|
+
<div className="ml-2 shrink-0">
|
|
174
|
+
{complete && valid ? (
|
|
175
|
+
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-white">
|
|
176
|
+
<Check className="h-3.5 w-3.5" />
|
|
177
|
+
</div>
|
|
178
|
+
) : showError || hasExternalError ? (
|
|
179
|
+
<AlertCircle className="h-5 w-5 text-destructive" />
|
|
180
|
+
) : null}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{/* Progress bar */}
|
|
185
|
+
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-muted">
|
|
186
|
+
<div
|
|
187
|
+
className={cn("h-full rounded-full transition-all duration-300 ease-out", progressClass)}
|
|
188
|
+
style={{ width: `${progress}%` }}
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
{/* Helper text */}
|
|
193
|
+
<div className="mt-1.5 text-xs">
|
|
194
|
+
<span className={helperClass}>{helperText}</span>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export default AadhaarInput;
|
package/index.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { program } = require("commander");
|
|
4
|
+
const axios = require("axios");
|
|
5
|
+
const fs = require("fs-extra");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const chalk = require("chalk");
|
|
8
|
+
|
|
9
|
+
// ── Replace with your actual GitHub username ──────────────────────────────────
|
|
10
|
+
const GITHUB_USER = "Sankalp-Pradhan";
|
|
11
|
+
const REPO_NAME = "kespUI";
|
|
12
|
+
const BRANCH = "main";
|
|
13
|
+
|
|
14
|
+
const REGISTRY_URL = `https://raw.githubusercontent.com/${GITHUB_USER}/${REPO_NAME}/${BRANCH}/registry.json`;
|
|
15
|
+
const BASE_URL = `https://raw.githubusercontent.com/${GITHUB_USER}/${REPO_NAME}/${BRANCH}/registry`;
|
|
16
|
+
|
|
17
|
+
program
|
|
18
|
+
.name("kesp-ui")
|
|
19
|
+
.description("Add kesp-ui components to your project")
|
|
20
|
+
.version("1.0.0");
|
|
21
|
+
|
|
22
|
+
// ── LIST command ──────────────────────────────────────────────────────────────
|
|
23
|
+
program
|
|
24
|
+
.command("list")
|
|
25
|
+
.description("List all available components")
|
|
26
|
+
.action(async () => {
|
|
27
|
+
try {
|
|
28
|
+
console.log(chalk.cyan("\nFetching available components...\n"));
|
|
29
|
+
const { data: registry } = await axios.get(REGISTRY_URL);
|
|
30
|
+
|
|
31
|
+
registry.items.forEach((item) => {
|
|
32
|
+
console.log(` ${chalk.green("◆")} ${chalk.bold(item.name)}`);
|
|
33
|
+
console.log(` ${chalk.gray(item.description)}`);
|
|
34
|
+
if (item.dependencies.length > 0) {
|
|
35
|
+
console.log(` ${chalk.yellow("deps:")} ${item.dependencies.join(", ")}`);
|
|
36
|
+
}
|
|
37
|
+
console.log();
|
|
38
|
+
});
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error(chalk.red("Error fetching registry:"), err.message);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ── ADD command ───────────────────────────────────────────────────────────────
|
|
45
|
+
program
|
|
46
|
+
.command("add <component>")
|
|
47
|
+
.description("Add a component to your project")
|
|
48
|
+
.option("-p, --path <path>", "Destination path", "components/ui")
|
|
49
|
+
.action(async (component, options) => {
|
|
50
|
+
try {
|
|
51
|
+
console.log(chalk.cyan(`\nFetching registry...\n`));
|
|
52
|
+
const { data: registry } = await axios.get(REGISTRY_URL);
|
|
53
|
+
const found = registry.items.find((c) => c.name === component);
|
|
54
|
+
|
|
55
|
+
if (!found) {
|
|
56
|
+
console.log(chalk.red(`✖ Component "${component}" not found.\n`));
|
|
57
|
+
console.log(chalk.gray(`Run ${chalk.white("npx kesp-ui list")} to see available components.`));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Download each file
|
|
62
|
+
for (const file of found.files) {
|
|
63
|
+
const fileName = path.basename(file.path);
|
|
64
|
+
const fileUrl = `${BASE_URL}/${fileName}`;
|
|
65
|
+
console.log(chalk.gray(` Downloading ${fileName}...`));
|
|
66
|
+
|
|
67
|
+
const { data: content } = await axios.get(fileUrl);
|
|
68
|
+
|
|
69
|
+
const destPath = path.join(process.cwd(), options.path, fileName);
|
|
70
|
+
await fs.ensureDir(path.dirname(destPath));
|
|
71
|
+
await fs.writeFile(destPath, content, "utf8");
|
|
72
|
+
|
|
73
|
+
console.log(chalk.green(` ✔ ${fileName}`) + chalk.gray(` → ${options.path}/${fileName}`));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Show dependency install hint
|
|
77
|
+
if (found.dependencies && found.dependencies.length > 0) {
|
|
78
|
+
console.log(
|
|
79
|
+
chalk.yellow(`\n Install dependencies:\n`) +
|
|
80
|
+
chalk.white(` npm install ${found.dependencies.join(" ")}\n`)
|
|
81
|
+
);
|
|
82
|
+
} else {
|
|
83
|
+
console.log();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log(chalk.green(`✔ Done! `) + chalk.gray(`Import with:`));
|
|
87
|
+
console.log(
|
|
88
|
+
chalk.white(` import ${toPascalCase(component)} from "@/components/ui/${component}";\n`)
|
|
89
|
+
);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.error(chalk.red("Error:"), err.message);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── Helper ────────────────────────────────────────────────────────────────────
|
|
96
|
+
function toPascalCase(str) {
|
|
97
|
+
return str
|
|
98
|
+
.split("-")
|
|
99
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
100
|
+
.join("");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
program.parse();
|
package/maskedAadhar.tsx
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import React, { useState, useRef, useCallback } from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
import { Upload, X, Eye, EyeOff, ShieldCheck, AlertCircle } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
interface MaskedAadhaarUploadProps {
|
|
6
|
+
onFileSelect?: (file: File | null) => void;
|
|
7
|
+
className?: string;
|
|
8
|
+
label?: string;
|
|
9
|
+
maxSizeMB?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
|
|
13
|
+
|
|
14
|
+
const MaskedAadhaarUpload: React.FC<MaskedAadhaarUploadProps> = ({
|
|
15
|
+
onFileSelect,
|
|
16
|
+
className,
|
|
17
|
+
label = "Upload Masked Aadhaar",
|
|
18
|
+
maxSizeMB = 5,
|
|
19
|
+
}) => {
|
|
20
|
+
const [preview, setPreview] = useState<string | null>(null);
|
|
21
|
+
const [fileName, setFileName] = useState<string | null>(null);
|
|
22
|
+
const [fileSize, setFileSize] = useState<string | null>(null);
|
|
23
|
+
const [error, setError] = useState<string | null>(null);
|
|
24
|
+
const [dragging, setDragging] = useState(false);
|
|
25
|
+
const [showPreview, setShowPreview] = useState(false);
|
|
26
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
27
|
+
|
|
28
|
+
const formatFileSize = (bytes: number): string => {
|
|
29
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
30
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
31
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const processFile = useCallback(
|
|
35
|
+
(file: File) => {
|
|
36
|
+
setError(null);
|
|
37
|
+
|
|
38
|
+
if (!ACCEPTED_TYPES.includes(file.type)) {
|
|
39
|
+
setError("Only JPG, PNG, or WebP images are accepted");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (file.size > maxSizeMB * 1024 * 1024) {
|
|
44
|
+
setError(`File must be under ${maxSizeMB}MB`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const reader = new FileReader();
|
|
49
|
+
reader.onload = (e) => {
|
|
50
|
+
setPreview(e.target?.result as string);
|
|
51
|
+
setFileName(file.name);
|
|
52
|
+
setFileSize(formatFileSize(file.size));
|
|
53
|
+
setShowPreview(false);
|
|
54
|
+
onFileSelect?.(file);
|
|
55
|
+
};
|
|
56
|
+
reader.readAsDataURL(file);
|
|
57
|
+
},
|
|
58
|
+
[maxSizeMB, onFileSelect]
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
62
|
+
const file = e.target.files?.[0];
|
|
63
|
+
if (file) processFile(file);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const handleDrop = useCallback(
|
|
67
|
+
(e: React.DragEvent) => {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
setDragging(false);
|
|
70
|
+
const file = e.dataTransfer.files?.[0];
|
|
71
|
+
if (file) processFile(file);
|
|
72
|
+
},
|
|
73
|
+
[processFile]
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const handleDragOver = (e: React.DragEvent) => {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
setDragging(true);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const handleDragLeave = () => setDragging(false);
|
|
82
|
+
|
|
83
|
+
const removeFile = () => {
|
|
84
|
+
setPreview(null);
|
|
85
|
+
setFileName(null);
|
|
86
|
+
setFileSize(null);
|
|
87
|
+
setError(null);
|
|
88
|
+
setShowPreview(false);
|
|
89
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
90
|
+
onFileSelect?.(null);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className={cn("w-full max-w-sm", className)}>
|
|
95
|
+
{label && (
|
|
96
|
+
<label className="block text-sm font-medium text-foreground mb-1.5">
|
|
97
|
+
{label}
|
|
98
|
+
</label>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
<input
|
|
102
|
+
ref={inputRef}
|
|
103
|
+
type="file"
|
|
104
|
+
accept={ACCEPTED_TYPES.join(",")}
|
|
105
|
+
onChange={handleChange}
|
|
106
|
+
className="hidden"
|
|
107
|
+
/>
|
|
108
|
+
|
|
109
|
+
{!preview ? (
|
|
110
|
+
<div
|
|
111
|
+
onClick={() => inputRef.current?.click()}
|
|
112
|
+
onDrop={handleDrop}
|
|
113
|
+
onDragOver={handleDragOver}
|
|
114
|
+
onDragLeave={handleDragLeave}
|
|
115
|
+
className={cn(
|
|
116
|
+
"relative flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed px-4 py-8 cursor-pointer transition-all duration-200",
|
|
117
|
+
dragging
|
|
118
|
+
? "border-primary bg-primary/5 shadow-[0_0_0_3px_hsl(var(--primary)/0.1)]"
|
|
119
|
+
: error
|
|
120
|
+
? "border-destructive bg-destructive/5"
|
|
121
|
+
: "border-input hover:border-muted-foreground/40 bg-card"
|
|
122
|
+
)}
|
|
123
|
+
>
|
|
124
|
+
<div
|
|
125
|
+
className={cn(
|
|
126
|
+
"flex h-12 w-12 items-center justify-center rounded-full transition-colors",
|
|
127
|
+
dragging ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
|
|
128
|
+
)}
|
|
129
|
+
>
|
|
130
|
+
<Upload className="h-5 w-5" />
|
|
131
|
+
</div>
|
|
132
|
+
<div className="text-center">
|
|
133
|
+
<p className="text-sm font-medium text-foreground">
|
|
134
|
+
{dragging ? "Drop your file here" : "Click or drag to upload"}
|
|
135
|
+
</p>
|
|
136
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
137
|
+
JPG, PNG, or WebP · Max {maxSizeMB}MB
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
) : (
|
|
142
|
+
<div className="relative rounded-lg border-2 border-aadhaar-success bg-card overflow-hidden transition-all duration-200">
|
|
143
|
+
{/* Blurred / visible preview */}
|
|
144
|
+
<div className="relative aspect-16/10 w-full bg-muted overflow-hidden">
|
|
145
|
+
<img
|
|
146
|
+
src={preview}
|
|
147
|
+
alt="Masked Aadhaar preview"
|
|
148
|
+
className={cn(
|
|
149
|
+
"h-full w-full object-cover transition-all duration-300",
|
|
150
|
+
showPreview ? "blur-0" : "blur-lg scale-105"
|
|
151
|
+
)}
|
|
152
|
+
/>
|
|
153
|
+
{!showPreview && (
|
|
154
|
+
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-sm">
|
|
155
|
+
<ShieldCheck className="h-8 w-8 text-aadhaar-success" />
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{/* File info bar */}
|
|
161
|
+
<div className="flex items-center gap-2 px-3 py-2.5 border-t border-border">
|
|
162
|
+
<ShieldCheck className="h-4 w-4 shrink-0 text-aadhaar-success" />
|
|
163
|
+
<div className="flex-1 min-w-0">
|
|
164
|
+
<p className="text-xs font-medium text-foreground truncate">{fileName}</p>
|
|
165
|
+
<p className="text-[10px] text-muted-foreground">{fileSize}</p>
|
|
166
|
+
</div>
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
onClick={() => setShowPreview((p) => !p)}
|
|
170
|
+
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
|
171
|
+
aria-label={showPreview ? "Hide preview" : "Show preview"}
|
|
172
|
+
>
|
|
173
|
+
{showPreview ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
|
174
|
+
</button>
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
onClick={removeFile}
|
|
178
|
+
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
|
|
179
|
+
aria-label="Remove file"
|
|
180
|
+
>
|
|
181
|
+
<X className="h-3.5 w-3.5" />
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{/* Helper / error text */}
|
|
188
|
+
<div className="mt-1.5 text-xs">
|
|
189
|
+
{error ? (
|
|
190
|
+
<span className="flex items-center gap-1 text-destructive">
|
|
191
|
+
<AlertCircle className="h-3 w-3" />
|
|
192
|
+
{error}
|
|
193
|
+
</span>
|
|
194
|
+
) : preview ? (
|
|
195
|
+
<span className="text-aadhaar-success">Aadhaar photo uploaded</span>
|
|
196
|
+
) : (
|
|
197
|
+
<span className="text-muted-foreground">
|
|
198
|
+
Upload a masked Aadhaar card image (number partially hidden) max5mb
|
|
199
|
+
</span>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export default MaskedAadhaarUpload;
|
package/otp-input.tsx
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect, KeyboardEvent, ClipboardEvent } from "react";
|
|
2
|
+
import { Timer, RotateCcw, CheckCircle2, ShieldCheck } from "lucide-react";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
|
|
5
|
+
interface OtpInputProps {
|
|
6
|
+
length?: 4 | 6;
|
|
7
|
+
value?: string;
|
|
8
|
+
onChange?: (value: string) => void;
|
|
9
|
+
onComplete?: (value: string) => void;
|
|
10
|
+
resendCooldown?: number;
|
|
11
|
+
onResend?: () => void;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const OtpInput = ({
|
|
16
|
+
length = 6,
|
|
17
|
+
value: controlledValue,
|
|
18
|
+
onChange,
|
|
19
|
+
onComplete,
|
|
20
|
+
resendCooldown = 30,
|
|
21
|
+
onResend,
|
|
22
|
+
className,
|
|
23
|
+
}: OtpInputProps) => {
|
|
24
|
+
const [internalValue, setInternalValue] = useState("");
|
|
25
|
+
const value = controlledValue ?? internalValue;
|
|
26
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
27
|
+
const [timer, setTimer] = useState(resendCooldown);
|
|
28
|
+
const [canResend, setCanResend] = useState(false);
|
|
29
|
+
const [isComplete, setIsComplete] = useState(false);
|
|
30
|
+
const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (timer <= 0) {
|
|
34
|
+
setCanResend(true);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const interval = setInterval(() => setTimer((t) => t - 1), 1000);
|
|
38
|
+
return () => clearInterval(interval);
|
|
39
|
+
}, [timer]);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
inputsRef.current[0]?.focus();
|
|
43
|
+
}, [length]);
|
|
44
|
+
|
|
45
|
+
const setValue = useCallback(
|
|
46
|
+
(newValue: string) => {
|
|
47
|
+
const clamped = newValue.slice(0, length);
|
|
48
|
+
if (!controlledValue) setInternalValue(clamped);
|
|
49
|
+
onChange?.(clamped);
|
|
50
|
+
if (clamped.length === length) {
|
|
51
|
+
setIsComplete(true);
|
|
52
|
+
onComplete?.(clamped);
|
|
53
|
+
} else {
|
|
54
|
+
setIsComplete(false);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
[length, controlledValue, onChange, onComplete]
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const handleChange = useCallback(
|
|
61
|
+
(index: number, digit: string) => {
|
|
62
|
+
if (!/^\d?$/.test(digit)) return;
|
|
63
|
+
const arr = value.split("");
|
|
64
|
+
while (arr.length < length) arr.push("");
|
|
65
|
+
arr[index] = digit;
|
|
66
|
+
const newValue = arr.join("").replace(/ /g, "");
|
|
67
|
+
setValue(newValue);
|
|
68
|
+
|
|
69
|
+
if (digit && index < length - 1) {
|
|
70
|
+
inputsRef.current[index + 1]?.focus();
|
|
71
|
+
setActiveIndex(index + 1);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
[value, length, setValue]
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const handleKeyDown = useCallback(
|
|
78
|
+
(index: number, e: KeyboardEvent<HTMLInputElement>) => {
|
|
79
|
+
if (e.key === "Backspace") {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
const arr = value.split("");
|
|
82
|
+
if (arr[index]) {
|
|
83
|
+
arr[index] = "";
|
|
84
|
+
setValue(arr.join(""));
|
|
85
|
+
} else if (index > 0) {
|
|
86
|
+
arr[index - 1] = "";
|
|
87
|
+
setValue(arr.join(""));
|
|
88
|
+
inputsRef.current[index - 1]?.focus();
|
|
89
|
+
setActiveIndex(index - 1);
|
|
90
|
+
}
|
|
91
|
+
} else if (e.key === "ArrowLeft" && index > 0) {
|
|
92
|
+
inputsRef.current[index - 1]?.focus();
|
|
93
|
+
setActiveIndex(index - 1);
|
|
94
|
+
} else if (e.key === "ArrowRight" && index < length - 1) {
|
|
95
|
+
inputsRef.current[index + 1]?.focus();
|
|
96
|
+
setActiveIndex(index + 1);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
[value, length, setValue]
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const handlePaste = useCallback(
|
|
103
|
+
(e: ClipboardEvent<HTMLInputElement>) => {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
const pasted = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length);
|
|
106
|
+
if (pasted) {
|
|
107
|
+
setValue(pasted);
|
|
108
|
+
const focusIndex = Math.min(pasted.length, length - 1);
|
|
109
|
+
inputsRef.current[focusIndex]?.focus();
|
|
110
|
+
setActiveIndex(focusIndex);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
[length, setValue]
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const handleResend = () => {
|
|
117
|
+
setTimer(resendCooldown);
|
|
118
|
+
setCanResend(false);
|
|
119
|
+
setValue("");
|
|
120
|
+
setIsComplete(false);
|
|
121
|
+
inputsRef.current[0]?.focus();
|
|
122
|
+
setActiveIndex(0);
|
|
123
|
+
onResend?.();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const formatTime = (s: number) =>
|
|
127
|
+
`${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
|
|
128
|
+
|
|
129
|
+
const digits = Array.from({ length }, (_, i) => value[i] || "");
|
|
130
|
+
|
|
131
|
+
// Helper to compute input class string
|
|
132
|
+
const getInputClass = (i: number, digit: string) => {
|
|
133
|
+
const base =
|
|
134
|
+
"h-12 w-12 rounded-lg border-2 bg-background text-center text-lg font-semibold text-foreground outline-none transition-all duration-200";
|
|
135
|
+
|
|
136
|
+
if (isComplete) {
|
|
137
|
+
return `${base} border-green-500 bg-green-500/10`;
|
|
138
|
+
}
|
|
139
|
+
if (activeIndex === i) {
|
|
140
|
+
return `${base} border-primary ring-2 ring-primary/30 scale-105`;
|
|
141
|
+
}
|
|
142
|
+
if (digit) {
|
|
143
|
+
return `${base} border-primary/50`;
|
|
144
|
+
}
|
|
145
|
+
return `${base} border-input`;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className={`space-y-4${className ? ` ${className}` : ""}`}>
|
|
150
|
+
{/* Header */}
|
|
151
|
+
<div className="flex items-center gap-2">
|
|
152
|
+
<ShieldCheck className="h-5 w-5 text-primary" />
|
|
153
|
+
<span className="text-sm font-medium text-foreground">
|
|
154
|
+
Enter {length}-digit OTP
|
|
155
|
+
</span>
|
|
156
|
+
{isComplete && (
|
|
157
|
+
<CheckCircle2 className="ml-auto h-5 w-5 text-green-500 animate-bounce" />
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
{/* OTP Boxes */}
|
|
162
|
+
<div className="flex items-center justify-center gap-2">
|
|
163
|
+
{digits.map((digit, i) => (
|
|
164
|
+
<div key={i} className="flex items-center">
|
|
165
|
+
<input
|
|
166
|
+
ref={(el) => { inputsRef.current[i] = el; }}
|
|
167
|
+
type="text"
|
|
168
|
+
inputMode="numeric"
|
|
169
|
+
maxLength={1}
|
|
170
|
+
value={digit}
|
|
171
|
+
onChange={(e) => handleChange(i, e.target.value.replace(/\D/g, ""))}
|
|
172
|
+
onKeyDown={(e) => handleKeyDown(i, e)}
|
|
173
|
+
onPaste={handlePaste}
|
|
174
|
+
onFocus={() => setActiveIndex(i)}
|
|
175
|
+
className={getInputClass(i, digit)}
|
|
176
|
+
aria-label={`Digit ${i + 1}`}
|
|
177
|
+
/>
|
|
178
|
+
{/* Separator in the middle for 6-digit */}
|
|
179
|
+
{length === 6 && i === 2 && (
|
|
180
|
+
<span className="mx-1 font-bold text-muted-foreground">–</span>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
{/* Progress dots */}
|
|
187
|
+
<div className="flex justify-center gap-1.5">
|
|
188
|
+
{digits.map((d, i) => (
|
|
189
|
+
<div
|
|
190
|
+
key={i}
|
|
191
|
+
className={`h-1.5 w-1.5 rounded-full transition-all duration-200 ${
|
|
192
|
+
d ? "bg-primary scale-125" : "bg-muted-foreground/30"
|
|
193
|
+
}`}
|
|
194
|
+
/>
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
{/* Timer + Resend */}
|
|
199
|
+
<div className="flex items-center justify-between text-sm">
|
|
200
|
+
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
201
|
+
<Timer className="h-4 w-4" />
|
|
202
|
+
{canResend ? (
|
|
203
|
+
<span>Code expired</span>
|
|
204
|
+
) : (
|
|
205
|
+
<span>Resend in {formatTime(timer)}</span>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
<Button
|
|
209
|
+
variant="ghost"
|
|
210
|
+
size="sm"
|
|
211
|
+
onClick={handleResend}
|
|
212
|
+
disabled={!canResend}
|
|
213
|
+
className="gap-1.5 text-primary disabled:text-muted-foreground"
|
|
214
|
+
>
|
|
215
|
+
<RotateCcw className="h-3.5 w-3.5" />
|
|
216
|
+
Resend
|
|
217
|
+
</Button>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export default OtpInput;
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kesp-ui",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI to add kesp-ui components to your project",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"kesp-ui": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node index.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"ui",
|
|
14
|
+
"components",
|
|
15
|
+
"react",
|
|
16
|
+
"nextjs",
|
|
17
|
+
"tailwindcss",
|
|
18
|
+
"india",
|
|
19
|
+
"aadhaar",
|
|
20
|
+
"pan"
|
|
21
|
+
],
|
|
22
|
+
"author": "yourusername",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"axios": "^1.6.0",
|
|
26
|
+
"chalk": "^4.1.2",
|
|
27
|
+
"commander": "^11.0.0",
|
|
28
|
+
"fs-extra": "^11.2.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/pan-input.tsx
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import React, { useState, useRef, useCallback } from "react";
|
|
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
|
+
}
|
|
19
|
+
|
|
20
|
+
// ─── Validation utilities ────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const PAN_REGEX = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
|
|
23
|
+
|
|
24
|
+
const sanitize = (input: string): string =>
|
|
25
|
+
input
|
|
26
|
+
.toUpperCase()
|
|
27
|
+
.replace(/[^A-Z0-9]/g, "")
|
|
28
|
+
.slice(0, 10);
|
|
29
|
+
|
|
30
|
+
const format = (cleaned: string): string => {
|
|
31
|
+
if (cleaned.length <= 5) return cleaned;
|
|
32
|
+
if (cleaned.length <= 9) return `${cleaned.slice(0, 5)} ${cleaned.slice(5)}`;
|
|
33
|
+
return `${cleaned.slice(0, 5)} ${cleaned.slice(5, 9)} ${cleaned.slice(9)}`;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const isValid = (cleaned: string): boolean =>
|
|
37
|
+
cleaned.length === 10 && PAN_REGEX.test(cleaned);
|
|
38
|
+
|
|
39
|
+
const getSegments = (cleaned: string): Segment[] => [
|
|
40
|
+
{
|
|
41
|
+
label: "Letters",
|
|
42
|
+
done: cleaned.length >= 5,
|
|
43
|
+
valid: cleaned.length < 5 || /^[A-Z]{5}$/.test(cleaned.slice(0, 5)),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
label: "Digits",
|
|
47
|
+
done: cleaned.length >= 9,
|
|
48
|
+
valid:
|
|
49
|
+
cleaned.length < 6 ||
|
|
50
|
+
/^[0-9]{1,4}$/.test(cleaned.slice(5, Math.min(9, cleaned.length))),
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
label: "Check",
|
|
54
|
+
done: cleaned.length === 10,
|
|
55
|
+
valid: cleaned.length < 10 || /^[A-Z]$/.test(cleaned[9]),
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// ─── Component ───────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
const PanCardInput: React.FC<PanCardInputProps> = ({
|
|
62
|
+
value: controlledValue,
|
|
63
|
+
onChange,
|
|
64
|
+
onValid,
|
|
65
|
+
className = "",
|
|
66
|
+
label = "PAN Card Number",
|
|
67
|
+
error,
|
|
68
|
+
}) => {
|
|
69
|
+
const [internal, setInternal] = useState<string>("");
|
|
70
|
+
const [focused, setFocused] = useState<boolean>(false);
|
|
71
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
72
|
+
|
|
73
|
+
const raw = controlledValue !== undefined ? controlledValue : internal;
|
|
74
|
+
const cleaned = sanitize(raw);
|
|
75
|
+
const displayed = format(cleaned);
|
|
76
|
+
const valid = isValid(cleaned);
|
|
77
|
+
const segments = getSegments(cleaned);
|
|
78
|
+
const showError = !!error || (!focused && cleaned.length > 0 && !valid);
|
|
79
|
+
|
|
80
|
+
const barClass = (seg: Segment): string => {
|
|
81
|
+
if (!seg.done) return "bg-gray-200";
|
|
82
|
+
if (!seg.valid) return "bg-red-400";
|
|
83
|
+
if (valid) return "bg-green-500";
|
|
84
|
+
return "bg-blue-500";
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const handleChange = useCallback(
|
|
88
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
89
|
+
const next = sanitize(e.target.value);
|
|
90
|
+
|
|
91
|
+
if (onChange) onChange(next);
|
|
92
|
+
else setInternal(next);
|
|
93
|
+
|
|
94
|
+
if (isValid(next) && onValid) onValid(next);
|
|
95
|
+
|
|
96
|
+
requestAnimationFrame(() => {
|
|
97
|
+
if (inputRef.current) {
|
|
98
|
+
const pos = format(next).length;
|
|
99
|
+
inputRef.current.setSelectionRange(pos, pos);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
[onChange, onValid]
|
|
104
|
+
);
|
|
105
|
+
|
|
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)
|
|
117
|
+
return { text: "Format: ABCDE1234F", cls: "text-gray-400" };
|
|
118
|
+
if (valid)
|
|
119
|
+
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
|
+
};
|
|
126
|
+
return { text: `${cleaned.length}/10 characters`, cls: "text-gray-400" };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const helper = helperText();
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div className={`w-full max-w-sm font-sans ${className}`}>
|
|
133
|
+
{label && (
|
|
134
|
+
<label className="block text-sm font-semibold text-gray-700 mb-2 tracking-wide">
|
|
135
|
+
{label}
|
|
136
|
+
</label>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
<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()}
|
|
142
|
+
>
|
|
143
|
+
<svg
|
|
144
|
+
className={`mr-3 h-5 w-5 shrink-0 transition-colors duration-200 ${
|
|
145
|
+
focused ? "text-blue-500" : "text-gray-400"
|
|
146
|
+
}`}
|
|
147
|
+
viewBox="0 0 24 24"
|
|
148
|
+
fill="none"
|
|
149
|
+
stroke="currentColor"
|
|
150
|
+
strokeWidth="2"
|
|
151
|
+
strokeLinecap="round"
|
|
152
|
+
strokeLinejoin="round"
|
|
153
|
+
>
|
|
154
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
155
|
+
<polyline points="14 2 14 8 20 8" />
|
|
156
|
+
<line x1="16" y1="13" x2="8" y2="13" />
|
|
157
|
+
<line x1="16" y1="17" x2="8" y2="17" />
|
|
158
|
+
<polyline points="10 9 9 9 8 9" />
|
|
159
|
+
</svg>
|
|
160
|
+
|
|
161
|
+
<input
|
|
162
|
+
ref={inputRef}
|
|
163
|
+
type="text"
|
|
164
|
+
autoComplete="off"
|
|
165
|
+
spellCheck={false}
|
|
166
|
+
placeholder="ABCDE 1234 F"
|
|
167
|
+
value={displayed}
|
|
168
|
+
onChange={handleChange}
|
|
169
|
+
onFocus={() => setFocused(true)}
|
|
170
|
+
onBlur={() => setFocused(false)}
|
|
171
|
+
className="flex-1 bg-transparent text-lg font-mono tracking-widest text-gray-800 placeholder-gray-300 outline-none uppercase"
|
|
172
|
+
maxLength={12}
|
|
173
|
+
aria-label="PAN Card Number"
|
|
174
|
+
aria-invalid={showError}
|
|
175
|
+
aria-describedby="pan-helper"
|
|
176
|
+
/>
|
|
177
|
+
|
|
178
|
+
<div className="ml-2 shrink-0">
|
|
179
|
+
{valid ? (
|
|
180
|
+
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-blue-500 text-white pan-bounce">
|
|
181
|
+
<svg
|
|
182
|
+
className="h-4 w-4"
|
|
183
|
+
viewBox="0 0 24 24"
|
|
184
|
+
fill="none"
|
|
185
|
+
stroke="currentColor"
|
|
186
|
+
strokeWidth="3"
|
|
187
|
+
>
|
|
188
|
+
<polyline points="20 6 9 17 4 12" />
|
|
189
|
+
</svg>
|
|
190
|
+
</span>
|
|
191
|
+
) : showError ? (
|
|
192
|
+
<svg
|
|
193
|
+
className="h-5 w-5 text-red-400"
|
|
194
|
+
viewBox="0 0 24 24"
|
|
195
|
+
fill="none"
|
|
196
|
+
stroke="currentColor"
|
|
197
|
+
strokeWidth="2"
|
|
198
|
+
>
|
|
199
|
+
<circle cx="12" cy="12" r="10" />
|
|
200
|
+
<line x1="12" y1="8" x2="12" y2="12" />
|
|
201
|
+
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
202
|
+
</svg>
|
|
203
|
+
) : null}
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
{/* Segment progress bars */}
|
|
208
|
+
<div className="mt-2.5 flex gap-1.5">
|
|
209
|
+
{segments.map((seg, i) => (
|
|
210
|
+
<div key={i} className="flex-1">
|
|
211
|
+
<div
|
|
212
|
+
className={`h-1 rounded-full transition-all duration-300 ${barClass(seg)}`}
|
|
213
|
+
/>
|
|
214
|
+
<span className="block mt-1 text-[10px] text-gray-400 text-center select-none">
|
|
215
|
+
{seg.label}
|
|
216
|
+
</span>
|
|
217
|
+
</div>
|
|
218
|
+
))}
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<p id="pan-helper" className={`mt-1 text-xs ${helper.cls}`}>
|
|
222
|
+
{helper.text}
|
|
223
|
+
</p>
|
|
224
|
+
|
|
225
|
+
<style>{`
|
|
226
|
+
@keyframes pan-bounce-in {
|
|
227
|
+
0% { transform: scale(0.5); opacity: 0; }
|
|
228
|
+
60% { transform: scale(1.2); }
|
|
229
|
+
80% { transform: scale(0.95); }
|
|
230
|
+
100% { transform: scale(1); opacity: 1; }
|
|
231
|
+
}
|
|
232
|
+
.pan-bounce { animation: pan-bounce-in 0.4s ease forwards; }
|
|
233
|
+
`}</style>
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export default PanCardInput;
|
package/registry.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kesp-ui",
|
|
3
|
+
"homepage": "https://github.com/yourusername/kesp-ui",
|
|
4
|
+
"items": [
|
|
5
|
+
{
|
|
6
|
+
"name": "aadhaar-input",
|
|
7
|
+
"type": "registry:ui",
|
|
8
|
+
"description": "A validated Aadhaar number input with Verhoeff checksum, progress bar, and formatting.",
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "registry/aadhaar-input.tsx",
|
|
12
|
+
"type": "registry:ui"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"dependencies": ["lucide-react"],
|
|
16
|
+
"devDependencies": [],
|
|
17
|
+
"tailwind": {},
|
|
18
|
+
"cssVars": {}
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "pan-input",
|
|
22
|
+
"type": "registry:ui",
|
|
23
|
+
"description": "A validated PAN Card number input with segment progress bars and format hints.",
|
|
24
|
+
"files": [
|
|
25
|
+
{
|
|
26
|
+
"path": "registry/pan-input.tsx",
|
|
27
|
+
"type": "registry:ui"
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"dependencies": [],
|
|
31
|
+
"devDependencies": [],
|
|
32
|
+
"tailwind": {},
|
|
33
|
+
"cssVars": {}
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "otp-input",
|
|
37
|
+
"type": "registry:ui",
|
|
38
|
+
"description": "OTP input with timer, resend, and auto-validation",
|
|
39
|
+
"files": [
|
|
40
|
+
{
|
|
41
|
+
"path": "registry/otp-input.tsx",
|
|
42
|
+
"type": "registry:ui"
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"dependencies": ["lucide-react"],
|
|
46
|
+
"devDependencies": [],
|
|
47
|
+
"tailwind": {},
|
|
48
|
+
"cssVars": {}
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|