feat: integrate input-otp library and enhance OTP input handling
- Added input-otp library to streamline OTP input functionality. - Refactored OtpInput component to utilize InputOTP for improved user experience and mobile SMS autofill. - Enhanced LoginOtpStep and VerificationStep components to handle OTP input errors and clear states effectively. - Updated global styles to include animations for OTP caret, improving visual feedback during input. - Made minor adjustments to LoginForm and OtpStep components for better error handling and user interaction.
This commit is contained in:
parent
7d290c814d
commit
1610e436a5
@ -26,6 +26,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.35.0",
|
||||
"geist": "^1.5.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "^16.1.6",
|
||||
"react": "^19.2.4",
|
||||
|
||||
@ -322,6 +322,21 @@
|
||||
|
||||
/* Line-height tokens */
|
||||
--leading-display: var(--cp-leading-display);
|
||||
|
||||
/* OTP caret blink animation */
|
||||
--animate-caret-blink: caret-blink 1.2s ease-out infinite;
|
||||
|
||||
@keyframes caret-blink {
|
||||
0%,
|
||||
70%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
20%,
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@ -1,21 +1,15 @@
|
||||
/**
|
||||
* OtpInput - 6-digit OTP code input
|
||||
*
|
||||
* Auto-focuses next input on entry, handles paste, backspace navigation.
|
||||
* Wrapper around shadcn InputOTP (backed by input-otp library).
|
||||
* Provides mobile SMS autofill, paste support, and accessible slot rendering.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type KeyboardEvent,
|
||||
type ClipboardEvent,
|
||||
} from "react";
|
||||
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
|
||||
|
||||
interface OtpInputProps {
|
||||
length?: number | undefined;
|
||||
@ -27,86 +21,6 @@ interface OtpInputProps {
|
||||
autoFocus?: boolean | undefined;
|
||||
}
|
||||
|
||||
function getInputBorderClass(error: string | undefined, isActive: boolean): string {
|
||||
if (error) return "border-danger focus:ring-danger focus:border-danger";
|
||||
if (isActive) return "border-primary";
|
||||
return "border-border hover:border-muted-foreground/50";
|
||||
}
|
||||
|
||||
function useOtpHandlers({
|
||||
digits,
|
||||
length,
|
||||
onChange,
|
||||
onComplete,
|
||||
inputRefs,
|
||||
setActiveIndex,
|
||||
}: {
|
||||
digits: string[];
|
||||
length: number;
|
||||
onChange: (value: string) => void;
|
||||
onComplete: ((value: string) => void) | undefined;
|
||||
inputRefs: React.MutableRefObject<(HTMLInputElement | null)[]>;
|
||||
setActiveIndex: (index: number) => void;
|
||||
}) {
|
||||
const focusInput = useCallback(
|
||||
(index: number) => {
|
||||
const clampedIndex = Math.max(0, Math.min(index, length - 1));
|
||||
inputRefs.current[clampedIndex]?.focus();
|
||||
setActiveIndex(clampedIndex);
|
||||
},
|
||||
[length, inputRefs, setActiveIndex]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(index: number, char: string) => {
|
||||
if (!/^\d?$/.test(char)) return;
|
||||
const newDigits = [...digits];
|
||||
newDigits[index] = char;
|
||||
const newValue = newDigits.join("");
|
||||
onChange(newValue);
|
||||
if (char && index < length - 1) focusInput(index + 1);
|
||||
if (newValue.length === length) onComplete?.(newValue);
|
||||
},
|
||||
[digits, length, onChange, onComplete, focusInput]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(index: number, e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
if (digits[index]) {
|
||||
handleChange(index, "");
|
||||
} else if (index > 0) {
|
||||
focusInput(index - 1);
|
||||
handleChange(index - 1, "");
|
||||
}
|
||||
} else if (e.key === "ArrowLeft" && index > 0) {
|
||||
e.preventDefault();
|
||||
focusInput(index - 1);
|
||||
} else if (e.key === "ArrowRight" && index < length - 1) {
|
||||
e.preventDefault();
|
||||
focusInput(index + 1);
|
||||
}
|
||||
},
|
||||
[digits, focusInput, handleChange, length]
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: ClipboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
const pastedData = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length);
|
||||
if (pastedData) {
|
||||
onChange(pastedData);
|
||||
focusInput(Math.min(pastedData.length, length - 1));
|
||||
if (pastedData.length === length) onComplete?.(pastedData);
|
||||
}
|
||||
},
|
||||
[length, onChange, onComplete, focusInput]
|
||||
);
|
||||
|
||||
return { focusInput, handleChange, handleKeyDown, handlePaste };
|
||||
}
|
||||
|
||||
export function OtpInput({
|
||||
length = 6,
|
||||
value,
|
||||
@ -116,61 +30,25 @@ export function OtpInput({
|
||||
error,
|
||||
autoFocus = true,
|
||||
}: OtpInputProps) {
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const digits = useMemo(() => {
|
||||
const d = [...value].slice(0, length);
|
||||
while (d.length < length) d.push("");
|
||||
return d;
|
||||
}, [value, length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && !disabled) {
|
||||
const firstEmptyIndex = digits.findIndex(d => !d);
|
||||
inputRefs.current[firstEmptyIndex === -1 ? 0 : firstEmptyIndex]?.focus();
|
||||
}
|
||||
}, [autoFocus, disabled, digits]);
|
||||
|
||||
const { handleChange, handleKeyDown, handlePaste } = useOtpHandlers({
|
||||
digits,
|
||||
length,
|
||||
onChange,
|
||||
onComplete,
|
||||
inputRefs,
|
||||
setActiveIndex,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-center gap-2">
|
||||
{digits.map((digit, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={el => {
|
||||
inputRefs.current[index] = el;
|
||||
}}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={length}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...(onComplete && { onComplete })}
|
||||
disabled={disabled}
|
||||
onChange={e => handleChange(index, e.target.value)}
|
||||
onKeyDown={e => handleKeyDown(index, e)}
|
||||
onPaste={handlePaste}
|
||||
onFocus={() => setActiveIndex(index)}
|
||||
className={cn(
|
||||
"w-12 h-14 text-center text-xl font-semibold",
|
||||
"rounded-lg border bg-card text-foreground",
|
||||
"focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary",
|
||||
"transition-all duration-150",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
getInputBorderClass(error, activeIndex === index)
|
||||
)}
|
||||
aria-label={`Digit ${index + 1}`}
|
||||
/>
|
||||
autoFocus={autoFocus}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
containerClassName="justify-center"
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{Array.from({ length }, (_, i) => (
|
||||
<InputOTPSlot key={i} index={i} className={cn(error && "border-danger")} />
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-danger text-center" role="alert">
|
||||
|
||||
69
apps/portal/src/components/ui/input-otp.tsx
Normal file
69
apps/portal/src/components/ui/input-otp.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { OTPInput, OTPInputContext } from "input-otp";
|
||||
import { MinusIcon } from "lucide-react";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & { containerClassName?: string }) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-group" className={cn("flex items-center", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { index: number }) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
// Index is always valid — we control it via the length prop in OtpInput
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]!;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"relative flex h-14 w-12 items-center justify-center border-y border-r border-border text-xl font-semibold shadow-xs transition-all",
|
||||
"first:rounded-l-lg first:border-l last:rounded-r-lg",
|
||||
"bg-card text-foreground",
|
||||
isActive && "z-10 ring-2 ring-primary border-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink h-6 w-px bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
@ -152,6 +152,7 @@ export function LoginForm({
|
||||
expiresAt={otpState.expiresAt}
|
||||
onVerify={handleOtpVerify}
|
||||
onBack={handleBackToCredentials}
|
||||
onClearError={clearError}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
@ -18,6 +18,7 @@ interface LoginOtpStepProps {
|
||||
expiresAt: string;
|
||||
onVerify: (code: string, rememberDevice: boolean) => Promise<void>;
|
||||
onBack: () => void;
|
||||
onClearError?: () => void;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
@ -37,6 +38,7 @@ export function LoginOtpStep({
|
||||
expiresAt,
|
||||
onVerify,
|
||||
onBack,
|
||||
onClearError,
|
||||
loading = false,
|
||||
error,
|
||||
}: LoginOtpStepProps) {
|
||||
@ -78,33 +80,41 @@ export function LoginOtpStep({
|
||||
return () => clearInterval(interval);
|
||||
}, [expiresAt]);
|
||||
|
||||
const handleVerify = useCallback(async () => {
|
||||
if (code.length !== 6 || isVerifying || loading) return;
|
||||
const handleCodeChange = useCallback(
|
||||
(value: string) => {
|
||||
setCode(value);
|
||||
if (error) onClearError?.();
|
||||
},
|
||||
[error, onClearError]
|
||||
);
|
||||
|
||||
const submitCode = useCallback(
|
||||
async (codeToSubmit: string) => {
|
||||
if (codeToSubmit.length !== 6 || isVerifying || loading) return;
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
await onVerify(code, rememberDevice);
|
||||
await onVerify(codeToSubmit, rememberDevice);
|
||||
} catch {
|
||||
// Verification failed — clear input so user can retry.
|
||||
// The error message from the store remains visible until they type again.
|
||||
setCode("");
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
}, [code, isVerifying, loading, onVerify, rememberDevice]);
|
||||
},
|
||||
[isVerifying, loading, onVerify, rememberDevice]
|
||||
);
|
||||
|
||||
const handleVerify = useCallback(async () => {
|
||||
await submitCode(code);
|
||||
}, [code, submitCode]);
|
||||
|
||||
const handleComplete = useCallback(
|
||||
(completedCode: string) => {
|
||||
setCode(completedCode);
|
||||
// Auto-submit when code is complete
|
||||
if (completedCode.length === 6 && !isVerifying && !loading) {
|
||||
void (async () => {
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
await onVerify(completedCode, rememberDevice);
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
})();
|
||||
}
|
||||
void submitCode(completedCode);
|
||||
},
|
||||
[isVerifying, loading, onVerify, rememberDevice]
|
||||
[submitCode]
|
||||
);
|
||||
|
||||
const isExpired = timeRemaining !== null && timeRemaining <= 0;
|
||||
@ -115,7 +125,7 @@ export function LoginOtpStep({
|
||||
<OtpHeader maskedEmail={maskedEmail} />
|
||||
<OtpCodeSection
|
||||
code={code}
|
||||
setCode={setCode}
|
||||
setCode={handleCodeChange}
|
||||
handleComplete={handleComplete}
|
||||
isSubmitting={isSubmitting}
|
||||
isExpired={isExpired}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/atoms";
|
||||
import { OtpInput } from "@/components/molecules";
|
||||
import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine";
|
||||
@ -13,19 +13,31 @@ export function VerificationStep() {
|
||||
const { state, send } = useGetStartedMachine();
|
||||
|
||||
const loading = state.matches({ verification: "loading" });
|
||||
const error = state.context.error;
|
||||
const machineError = state.context.error;
|
||||
const attemptsRemaining = state.context.attemptsRemaining;
|
||||
const email = state.context.formData.email;
|
||||
|
||||
const [code, setCode] = useState("");
|
||||
const [resending, setResending] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
// Sync machine errors into local state so we can clear on typing,
|
||||
// and clear the input so the user can retry
|
||||
useEffect(() => {
|
||||
if (machineError) {
|
||||
setLocalError(machineError);
|
||||
setCode("");
|
||||
}
|
||||
}, [machineError]);
|
||||
|
||||
const handleCodeChange = (value: string) => {
|
||||
setCode(value);
|
||||
if (localError) setLocalError(null);
|
||||
};
|
||||
|
||||
const handleComplete = (completedCode: string) => {
|
||||
if (!loading) {
|
||||
setCode(completedCode);
|
||||
send({ type: "VERIFY_CODE", code: completedCode });
|
||||
}
|
||||
};
|
||||
@ -61,7 +73,7 @@ export function VerificationStep() {
|
||||
onChange={handleCodeChange}
|
||||
onComplete={handleComplete}
|
||||
disabled={loading}
|
||||
error={error ?? undefined}
|
||||
error={localError ?? undefined}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
|
||||
@ -73,7 +73,11 @@ export function OtpStep() {
|
||||
|
||||
const handleComplete = useCallback(
|
||||
async (code: string) => {
|
||||
try {
|
||||
await verifyOtp(code);
|
||||
} catch {
|
||||
setOtpValue("");
|
||||
}
|
||||
},
|
||||
[verifyOtp]
|
||||
);
|
||||
|
||||
76
docs/plans/2026-03-06-otp-input-redesign-design.md
Normal file
76
docs/plans/2026-03-06-otp-input-redesign-design.md
Normal file
@ -0,0 +1,76 @@
|
||||
# OTP Input Redesign: Replace Custom with shadcn InputOTP
|
||||
|
||||
**Date**: 2026-03-06
|
||||
**Status**: Approved
|
||||
|
||||
## Problem
|
||||
|
||||
The custom `OtpInput` component (`components/molecules/OtpInput/OtpInput.tsx`) has fundamental issues:
|
||||
|
||||
- `onComplete` never fired on typing due to a JS bug (`!string.includes("")` is always false)
|
||||
- Manual focus management across 6 separate `<input>` elements is fragile
|
||||
- No mobile SMS autofill support
|
||||
- No password manager detection
|
||||
- Edge cases around paste, backspace, and focus are hard to maintain
|
||||
|
||||
## Decision
|
||||
|
||||
Replace the custom implementation with `input-otp` via shadcn/ui's InputOTP component (Approach A).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
input-otp (npm)
|
||||
-> components/ui/input-otp.tsx (shadcn primitives)
|
||||
-> components/molecules/OtpInput/OtpInput.tsx (wrapper, same API)
|
||||
-> LoginOtpStep, OtpStep, VerificationStep (no changes)
|
||||
```
|
||||
|
||||
## Component API (unchanged)
|
||||
|
||||
```ts
|
||||
interface OtpInputProps {
|
||||
length?: number; // default 6
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onComplete?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
autoFocus?: boolean; // default true
|
||||
}
|
||||
```
|
||||
|
||||
## Visual Design
|
||||
|
||||
- Stripe-style grouped layout: two groups of 3 slots separated by a dash
|
||||
- Slots: `border-border` default, `border-primary` + ring on active, `border-danger` on error
|
||||
- `bg-card`, `text-foreground`, rounded corners on group edges
|
||||
- Disabled: `opacity-50 cursor-not-allowed`
|
||||
- Blinking caret animation
|
||||
|
||||
## What Changes
|
||||
|
||||
| File | Action |
|
||||
| --------------------------------------------- | --------------------------------- |
|
||||
| `package.json` | Add `input-otp` dependency |
|
||||
| `components/ui/input-otp.tsx` | New — shadcn generated primitives |
|
||||
| `components/molecules/OtpInput/OtpInput.tsx` | Rewrite internals, keep API |
|
||||
| `LoginOtpStep`, `OtpStep`, `VerificationStep` | No changes |
|
||||
|
||||
## What Gets Deleted
|
||||
|
||||
- `useOtpHandlers` hook (focus, paste, keydown management)
|
||||
- `getInputBorderClass` helper
|
||||
- All 6 individual `<input>` elements
|
||||
|
||||
## Error Handling
|
||||
|
||||
Consumer-level error handling (already implemented this session) remains unchanged:
|
||||
|
||||
- LoginOtpStep: catches errors, clears input, clears error on typing via `onClearError`
|
||||
- OtpStep: catches errors, clears input, clears error on typing via `clearOtpError`
|
||||
- VerificationStep: syncs machine error to local state, clears input and error on typing
|
||||
|
||||
## shadcn CLI Note
|
||||
|
||||
Generated file imports `cn` from `@/lib/utils`. Project has `cn` at `@/shared/utils`. Fix the import in the generated file after running the CLI.
|
||||
328
docs/plans/2026-03-06-otp-input-redesign-plan.md
Normal file
328
docs/plans/2026-03-06-otp-input-redesign-plan.md
Normal file
@ -0,0 +1,328 @@
|
||||
# OTP Input Redesign Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace the custom OtpInput component with shadcn's InputOTP backed by `input-otp`, keeping the same external API so consumers need zero changes.
|
||||
|
||||
**Architecture:** Install `input-otp` → generate shadcn `input-otp.tsx` primitives → rewrite `OtpInput` molecule internals using those primitives. All 3 consumers (LoginOtpStep, OtpStep, VerificationStep) continue using the same `<OtpInput>` props interface unchanged.
|
||||
|
||||
**Tech Stack:** `input-otp` library, shadcn/ui primitives, Tailwind v4, React 19
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Install `input-otp` dependency
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/portal/package.json`
|
||||
|
||||
**Step 1: Install the package**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm --filter @customer-portal/portal add input-otp
|
||||
```
|
||||
|
||||
**Step 2: Verify installation**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm --filter @customer-portal/portal exec -- node -e "require('input-otp')" 2>&1 || echo "ESM module, checking package exists" && ls node_modules/input-otp/package.json
|
||||
```
|
||||
|
||||
Expected: Package exists in node_modules.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/portal/package.json pnpm-lock.yaml
|
||||
git commit -m "chore: add input-otp dependency"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create shadcn InputOTP primitives
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `apps/portal/src/components/ui/input-otp.tsx`
|
||||
|
||||
The shadcn CLI may not work cleanly because `components.json` aliases `utils` to `@/lib/utils` which doesn't exist. Create the component manually based on shadcn's new-york input-otp source, using the project's `@/shared/utils` import for `cn`.
|
||||
|
||||
**Step 1: Create the primitives file**
|
||||
|
||||
Create `apps/portal/src/components/ui/input-otp.tsx`:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { OTPInput, OTPInputContext } from "input-otp";
|
||||
import { MinusIcon } from "lucide-react";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & { containerClassName?: string }) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-group" className={cn("flex items-center", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { index: number }) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"relative flex h-14 w-12 items-center justify-center border-y border-r border-border text-xl font-semibold shadow-xs transition-all",
|
||||
"first:rounded-l-lg first:border-l last:rounded-r-lg",
|
||||
"bg-card text-foreground",
|
||||
isActive && "z-10 ring-2 ring-primary border-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink h-6 w-px bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
```
|
||||
|
||||
**Step 2: Add caret-blink animation to globals.css**
|
||||
|
||||
Add inside the `@theme` block in `apps/portal/src/app/globals.css`, before the closing `}`:
|
||||
|
||||
```css
|
||||
/* OTP caret blink animation */
|
||||
--animate-caret-blink: caret-blink 1.2s ease-out infinite;
|
||||
|
||||
@keyframes caret-blink {
|
||||
0%,
|
||||
70%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
20%,
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Verify type-check**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm type-check
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/portal/src/components/ui/input-otp.tsx apps/portal/src/app/globals.css
|
||||
git commit -m "feat: add shadcn InputOTP primitives with caret-blink animation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Rewrite OtpInput molecule
|
||||
|
||||
**Files:**
|
||||
|
||||
- Rewrite: `apps/portal/src/components/molecules/OtpInput/OtpInput.tsx`
|
||||
|
||||
**Step 1: Replace the entire file**
|
||||
|
||||
Replace `apps/portal/src/components/molecules/OtpInput/OtpInput.tsx` with:
|
||||
|
||||
```tsx
|
||||
/**
|
||||
* OtpInput - 6-digit OTP code input
|
||||
*
|
||||
* Wrapper around shadcn InputOTP (backed by input-otp library).
|
||||
* Provides mobile SMS autofill, paste support, and accessible slot rendering.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||
import { cn } from "@/shared/utils";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
InputOTPSeparator,
|
||||
} from "@/components/ui/input-otp";
|
||||
|
||||
interface OtpInputProps {
|
||||
length?: number | undefined;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onComplete?: ((value: string) => void) | undefined;
|
||||
disabled?: boolean | undefined;
|
||||
error?: string | undefined;
|
||||
autoFocus?: boolean | undefined;
|
||||
}
|
||||
|
||||
export function OtpInput({
|
||||
length = 6,
|
||||
value,
|
||||
onChange,
|
||||
onComplete,
|
||||
disabled = false,
|
||||
error,
|
||||
autoFocus = true,
|
||||
}: OtpInputProps) {
|
||||
const firstGroupSize = Math.ceil(length / 2);
|
||||
const secondGroupSize = length - firstGroupSize;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={length}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onComplete={onComplete}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
containerClassName="justify-center"
|
||||
>
|
||||
<InputOTPGroup className={cn(error && "[&_[data-slot=input-otp-slot]]:border-danger")}>
|
||||
{Array.from({ length: firstGroupSize }, (_, i) => (
|
||||
<InputOTPSlot key={i} index={i} className={cn(error && "border-danger")} />
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
|
||||
{secondGroupSize > 0 && (
|
||||
<>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup
|
||||
className={cn(error && "[&_[data-slot=input-otp-slot]]:border-danger")}
|
||||
>
|
||||
{Array.from({ length: secondGroupSize }, (_, i) => (
|
||||
<InputOTPSlot
|
||||
key={firstGroupSize + i}
|
||||
index={firstGroupSize + i}
|
||||
className={cn(error && "border-danger")}
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</>
|
||||
)}
|
||||
</InputOTP>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-danger text-center" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Note: The `index.ts` barrel file (`export { OtpInput } from "./OtpInput"`) stays unchanged.
|
||||
|
||||
**Step 2: Verify type-check**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm type-check
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 3: Verify lint**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
Expected: PASS (no unused imports, no deep path violations)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/portal/src/components/molecules/OtpInput/OtpInput.tsx
|
||||
git commit -m "refactor: replace custom OtpInput with shadcn InputOTP"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Verify all consumers still compile and work
|
||||
|
||||
**Files:** (read-only verification, no changes expected)
|
||||
|
||||
- `apps/portal/src/features/auth/components/LoginOtpStep/LoginOtpStep.tsx`
|
||||
- `apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.tsx`
|
||||
- `apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx`
|
||||
|
||||
**Step 1: Full type-check**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm type-check
|
||||
```
|
||||
|
||||
Expected: PASS — all 3 consumers use the same `OtpInputProps` API which hasn't changed.
|
||||
|
||||
**Step 2: Run portal tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm --filter @customer-portal/portal test 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: All tests pass.
|
||||
|
||||
**Step 3: Final commit if any fixes were needed**
|
||||
|
||||
Only commit if adjustments were required. Otherwise, skip.
|
||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@ -233,6 +233,9 @@ importers:
|
||||
geist:
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
|
||||
input-otp:
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
lucide-react:
|
||||
specifier: ^0.563.0
|
||||
version: 0.563.0(react@19.2.4)
|
||||
@ -5586,6 +5589,15 @@ packages:
|
||||
}
|
||||
engines: { node: ^14.17.0 || ^16.13.0 || >=18.0.0 }
|
||||
|
||||
input-otp@1.4.2:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==,
|
||||
}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
inquirer@8.2.7:
|
||||
resolution:
|
||||
{
|
||||
@ -11769,6 +11781,11 @@ snapshots:
|
||||
|
||||
ini@4.1.1: {}
|
||||
|
||||
input-otp@1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
inquirer@8.2.7(@types/node@25.2.0):
|
||||
dependencies:
|
||||
"@inquirer/external-editor": 1.0.3(@types/node@25.2.0)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user