diff --git a/apps/portal/package.json b/apps/portal/package.json index c93b8038..08e05f5c 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -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", diff --git a/apps/portal/src/app/globals.css b/apps/portal/src/app/globals.css index f34d88e6..365e8214 100644 --- a/apps/portal/src/app/globals.css +++ b/apps/portal/src/app/globals.css @@ -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 { diff --git a/apps/portal/src/components/molecules/OtpInput/OtpInput.tsx b/apps/portal/src/components/molecules/OtpInput/OtpInput.tsx index a57cc60d..add82d76 100644 --- a/apps/portal/src/components/molecules/OtpInput/OtpInput.tsx +++ b/apps/portal/src/components/molecules/OtpInput/OtpInput.tsx @@ -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) => { - 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) => { - 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 (
-
- {digits.map((digit, index) => ( - { - inputRefs.current[index] = el; - }} - type="text" - inputMode="numeric" - autoComplete="one-time-code" - maxLength={1} - value={digit} - 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}`} - /> - ))} +
+ + + {Array.from({ length }, (_, i) => ( + + ))} + +
{error && (

diff --git a/apps/portal/src/components/ui/input-otp.tsx b/apps/portal/src/components/ui/input-otp.tsx new file mode 100644 index 00000000..cf387356 --- /dev/null +++ b/apps/portal/src/components/ui/input-otp.tsx @@ -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 & { containerClassName?: string }) { + return ( + + ); +} + +function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ); +} + +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 ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ); +} + +function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { + return ( +
+ +
+ ); +} + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx b/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx index c6c86d31..09b8ab65 100644 --- a/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx +++ b/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx @@ -152,6 +152,7 @@ export function LoginForm({ expiresAt={otpState.expiresAt} onVerify={handleOtpVerify} onBack={handleBackToCredentials} + onClearError={clearError} loading={loading} error={error} /> diff --git a/apps/portal/src/features/auth/components/LoginOtpStep/LoginOtpStep.tsx b/apps/portal/src/features/auth/components/LoginOtpStep/LoginOtpStep.tsx index 6b425f80..c96a2071 100644 --- a/apps/portal/src/features/auth/components/LoginOtpStep/LoginOtpStep.tsx +++ b/apps/portal/src/features/auth/components/LoginOtpStep/LoginOtpStep.tsx @@ -18,6 +18,7 @@ interface LoginOtpStepProps { expiresAt: string; onVerify: (code: string, rememberDevice: boolean) => Promise; 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] + ); - setIsVerifying(true); - try { - await onVerify(code, rememberDevice); - } finally { - setIsVerifying(false); - } - }, [code, isVerifying, loading, onVerify, rememberDevice]); + const submitCode = useCallback( + async (codeToSubmit: string) => { + if (codeToSubmit.length !== 6 || isVerifying || loading) return; + setIsVerifying(true); + try { + 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); + } + }, + [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({ (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 /> diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.tsx index 40602d65..cb4266c9 100644 --- a/apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.tsx +++ b/apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.tsx @@ -73,7 +73,11 @@ export function OtpStep() { const handleComplete = useCallback( async (code: string) => { - await verifyOtp(code); + try { + await verifyOtp(code); + } catch { + setOtpValue(""); + } }, [verifyOtp] ); diff --git a/docs/plans/2026-03-06-otp-input-redesign-design.md b/docs/plans/2026-03-06-otp-input-redesign-design.md new file mode 100644 index 00000000..dd98109e --- /dev/null +++ b/docs/plans/2026-03-06-otp-input-redesign-design.md @@ -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 `` 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 `` 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. diff --git a/docs/plans/2026-03-06-otp-input-redesign-plan.md b/docs/plans/2026-03-06-otp-input-redesign-plan.md new file mode 100644 index 00000000..1126e80a --- /dev/null +++ b/docs/plans/2026-03-06-otp-input-redesign-plan.md @@ -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 `` 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 & { containerClassName?: string }) { + return ( + + ); +} + +function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function InputOTPSlot({ + index, + className, + ...props +}: React.ComponentProps<"div"> & { index: number }) { + const inputOTPContext = React.useContext(OTPInputContext); + const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]; + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ); +} + +function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { + return ( +
+ +
+ ); +} + +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 ( +
+
+ + + {Array.from({ length: firstGroupSize }, (_, i) => ( + + ))} + + + {secondGroupSize > 0 && ( + <> + + + {Array.from({ length: secondGroupSize }, (_, i) => ( + + ))} + + + )} + +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} +``` + +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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d64dcd6..bd347371 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)