- 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.
329 lines
8.0 KiB
Markdown
329 lines
8.0 KiB
Markdown
# 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.
|