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:
barsa 2026-03-06 18:56:16 +09:00
parent 7d290c814d
commit 1610e436a5
11 changed files with 580 additions and 169 deletions

View File

@ -26,6 +26,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.35.0", "framer-motion": "^12.35.0",
"geist": "^1.5.1", "geist": "^1.5.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"next": "^16.1.6", "next": "^16.1.6",
"react": "^19.2.4", "react": "^19.2.4",

View File

@ -322,6 +322,21 @@
/* Line-height tokens */ /* Line-height tokens */
--leading-display: var(--cp-leading-display); --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 { @layer base {

View File

@ -1,21 +1,15 @@
/** /**
* OtpInput - 6-digit OTP code input * 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"; "use client";
import { import { REGEXP_ONLY_DIGITS } from "input-otp";
useRef,
useState,
useCallback,
useEffect,
useMemo,
type KeyboardEvent,
type ClipboardEvent,
} from "react";
import { cn } from "@/shared/utils"; import { cn } from "@/shared/utils";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
interface OtpInputProps { interface OtpInputProps {
length?: number | undefined; length?: number | undefined;
@ -27,86 +21,6 @@ interface OtpInputProps {
autoFocus?: boolean | undefined; 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({ export function OtpInput({
length = 6, length = 6,
value, value,
@ -116,61 +30,25 @@ export function OtpInput({
error, error,
autoFocus = true, autoFocus = true,
}: OtpInputProps) { }: 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 ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-center gap-2"> <div className="flex justify-center">
{digits.map((digit, index) => ( <InputOTP
<input maxLength={length}
key={index} value={value}
ref={el => { onChange={onChange}
inputRefs.current[index] = el; {...(onComplete && { onComplete })}
}}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={1}
value={digit}
disabled={disabled} disabled={disabled}
onChange={e => handleChange(index, e.target.value)} autoFocus={autoFocus}
onKeyDown={e => handleKeyDown(index, e)} pattern={REGEXP_ONLY_DIGITS}
onPaste={handlePaste} containerClassName="justify-center"
onFocus={() => setActiveIndex(index)} >
className={cn( <InputOTPGroup>
"w-12 h-14 text-center text-xl font-semibold", {Array.from({ length }, (_, i) => (
"rounded-lg border bg-card text-foreground", <InputOTPSlot key={i} index={i} className={cn(error && "border-danger")} />
"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}`}
/>
))} ))}
</InputOTPGroup>
</InputOTP>
</div> </div>
{error && ( {error && (
<p className="text-sm text-danger text-center" role="alert"> <p className="text-sm text-danger text-center" role="alert">

View 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 };

View File

@ -152,6 +152,7 @@ export function LoginForm({
expiresAt={otpState.expiresAt} expiresAt={otpState.expiresAt}
onVerify={handleOtpVerify} onVerify={handleOtpVerify}
onBack={handleBackToCredentials} onBack={handleBackToCredentials}
onClearError={clearError}
loading={loading} loading={loading}
error={error} error={error}
/> />

View File

@ -18,6 +18,7 @@ interface LoginOtpStepProps {
expiresAt: string; expiresAt: string;
onVerify: (code: string, rememberDevice: boolean) => Promise<void>; onVerify: (code: string, rememberDevice: boolean) => Promise<void>;
onBack: () => void; onBack: () => void;
onClearError?: () => void;
loading?: boolean; loading?: boolean;
error?: string | null; error?: string | null;
} }
@ -37,6 +38,7 @@ export function LoginOtpStep({
expiresAt, expiresAt,
onVerify, onVerify,
onBack, onBack,
onClearError,
loading = false, loading = false,
error, error,
}: LoginOtpStepProps) { }: LoginOtpStepProps) {
@ -78,33 +80,41 @@ export function LoginOtpStep({
return () => clearInterval(interval); return () => clearInterval(interval);
}, [expiresAt]); }, [expiresAt]);
const handleVerify = useCallback(async () => { const handleCodeChange = useCallback(
if (code.length !== 6 || isVerifying || loading) return; (value: string) => {
setCode(value);
if (error) onClearError?.();
},
[error, onClearError]
);
const submitCode = useCallback(
async (codeToSubmit: string) => {
if (codeToSubmit.length !== 6 || isVerifying || loading) return;
setIsVerifying(true); setIsVerifying(true);
try { 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 { } finally {
setIsVerifying(false); setIsVerifying(false);
} }
}, [code, isVerifying, loading, onVerify, rememberDevice]); },
[isVerifying, loading, onVerify, rememberDevice]
);
const handleVerify = useCallback(async () => {
await submitCode(code);
}, [code, submitCode]);
const handleComplete = useCallback( const handleComplete = useCallback(
(completedCode: string) => { (completedCode: string) => {
setCode(completedCode); setCode(completedCode);
// Auto-submit when code is complete void submitCode(completedCode);
if (completedCode.length === 6 && !isVerifying && !loading) {
void (async () => {
setIsVerifying(true);
try {
await onVerify(completedCode, rememberDevice);
} finally {
setIsVerifying(false);
}
})();
}
}, },
[isVerifying, loading, onVerify, rememberDevice] [submitCode]
); );
const isExpired = timeRemaining !== null && timeRemaining <= 0; const isExpired = timeRemaining !== null && timeRemaining <= 0;
@ -115,7 +125,7 @@ export function LoginOtpStep({
<OtpHeader maskedEmail={maskedEmail} /> <OtpHeader maskedEmail={maskedEmail} />
<OtpCodeSection <OtpCodeSection
code={code} code={code}
setCode={setCode} setCode={handleCodeChange}
handleComplete={handleComplete} handleComplete={handleComplete}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
isExpired={isExpired} isExpired={isExpired}

View File

@ -4,7 +4,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import { Button } from "@/components/atoms"; import { Button } from "@/components/atoms";
import { OtpInput } from "@/components/molecules"; import { OtpInput } from "@/components/molecules";
import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine"; import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine";
@ -13,19 +13,31 @@ export function VerificationStep() {
const { state, send } = useGetStartedMachine(); const { state, send } = useGetStartedMachine();
const loading = state.matches({ verification: "loading" }); const loading = state.matches({ verification: "loading" });
const error = state.context.error; const machineError = state.context.error;
const attemptsRemaining = state.context.attemptsRemaining; const attemptsRemaining = state.context.attemptsRemaining;
const email = state.context.formData.email; const email = state.context.formData.email;
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [resending, setResending] = useState(false); 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) => { const handleCodeChange = (value: string) => {
setCode(value); setCode(value);
if (localError) setLocalError(null);
}; };
const handleComplete = (completedCode: string) => { const handleComplete = (completedCode: string) => {
if (!loading) { if (!loading) {
setCode(completedCode);
send({ type: "VERIFY_CODE", code: completedCode }); send({ type: "VERIFY_CODE", code: completedCode });
} }
}; };
@ -61,7 +73,7 @@ export function VerificationStep() {
onChange={handleCodeChange} onChange={handleCodeChange}
onComplete={handleComplete} onComplete={handleComplete}
disabled={loading} disabled={loading}
error={error ?? undefined} error={localError ?? undefined}
autoFocus autoFocus
/> />

View File

@ -73,7 +73,11 @@ export function OtpStep() {
const handleComplete = useCallback( const handleComplete = useCallback(
async (code: string) => { async (code: string) => {
try {
await verifyOtp(code); await verifyOtp(code);
} catch {
setOtpValue("");
}
}, },
[verifyOtp] [verifyOtp]
); );

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

View 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
View File

@ -233,6 +233,9 @@ importers:
geist: geist:
specifier: ^1.5.1 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)) 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: lucide-react:
specifier: ^0.563.0 specifier: ^0.563.0
version: 0.563.0(react@19.2.4) version: 0.563.0(react@19.2.4)
@ -5586,6 +5589,15 @@ packages:
} }
engines: { node: ^14.17.0 || ^16.13.0 || >=18.0.0 } 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: inquirer@8.2.7:
resolution: resolution:
{ {
@ -11769,6 +11781,11 @@ snapshots:
ini@4.1.1: {} 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): inquirer@8.2.7(@types/node@25.2.0):
dependencies: dependencies:
"@inquirer/external-editor": 1.0.3(@types/node@25.2.0) "@inquirer/external-editor": 1.0.3(@types/node@25.2.0)