Assist_Design/docs/plans/2026-03-06-otp-input-redesign-plan.md

329 lines
8.0 KiB
Markdown
Raw Normal View History

# 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.