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 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();
@@ -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
+ }