diff --git a/packages/react-kit/src/auth/components/CodeInput/CodeInput.stories.tsx b/packages/react-kit/src/auth/components/CodeInput/CodeInput.stories.tsx index 570abf4..568810f 100644 --- a/packages/react-kit/src/auth/components/CodeInput/CodeInput.stories.tsx +++ b/packages/react-kit/src/auth/components/CodeInput/CodeInput.stories.tsx @@ -10,16 +10,14 @@ const meta = { }, tags: ['autodocs'], argTypes: { - length: { control: { type: 'number', min: 4, max: 8 } }, disabled: { control: 'boolean' }, - error: { control: 'boolean' }, autoFocus: { control: 'boolean' }, + length: { control: { type: 'number', min: 4, max: 8, step: 1 } }, }, args: { - length: 6, disabled: false, - error: false, autoFocus: false, + length: 6, }, } satisfies Meta @@ -28,15 +26,19 @@ type Story = StoryObj export const Default: Story = {} -export const WithError: Story = { +export const Disabled: Story = { args: { - error: true, + disabled: true, }, } -export const Disabled: Story = { +export const WithOnComplete: Story = { args: { - disabled: true, + onComplete: (code) => { + // biome-ignore lint/suspicious/noConsole: This is a demo/story + console.log(`Code complete: ${code}`) + alert(`Code complete: ${code}`) + }, }, } @@ -46,11 +48,9 @@ export const FourDigits: Story = { }, } -export const WithOnComplete: Story = { +export const EightDigits: Story = { args: { - onComplete: (code) => { - alert(`Code complete: ${code}`) - }, + length: 8, }, } @@ -58,21 +58,21 @@ export const AllStates: Story = { render: () => (
- Default - + Default (6) + window.alert('Completed')} /> +
+
+ 4 digits +
- Error - + 8 digits +
Disabled
-
- 4-digit - -
), } diff --git a/packages/react-kit/src/auth/components/CodeInput/CodeInput.test.tsx b/packages/react-kit/src/auth/components/CodeInput/CodeInput.test.tsx index 6467f2b..c10339b 100644 --- a/packages/react-kit/src/auth/components/CodeInput/CodeInput.test.tsx +++ b/packages/react-kit/src/auth/components/CodeInput/CodeInput.test.tsx @@ -7,35 +7,20 @@ afterEach(() => { cleanup() }) -// Helper: get all digit inputs by aria-label -const getDigits = (length = 6) => - Array.from({ length }, (_, i) => screen.getByLabelText(`Digit ${i + 1}`)) - describe('CodeInput', () => { describe('rendering', () => { - it('renders 6 inputs by default', () => { - render() - const digits = getDigits(6) - expect(digits).toHaveLength(6) - }) - - it('renders the correct number of inputs for a custom length', () => { - render() - expect(getDigits(4)).toHaveLength(4) - }) - - it('renders all inputs as empty initially', () => { - render() - for (const input of getDigits(6)) { - expect((input as HTMLInputElement).value).toBe('') - } + it('renders 6 character boxes by default', () => { + const { container } = render() + const boxes = container.querySelectorAll('.backdrop-blur-md') + expect(boxes).toHaveLength(6) }) - it('renders inputs with type="text"', () => { - render() - for (const input of getDigits(6)) { - expect((input as HTMLInputElement).type).toBe('text') - } + it('renders all boxes as empty initially', () => { + const { container } = render() + const textElements = container.querySelectorAll('.text-h2') + Array.from(textElements).forEach((el) => { + expect(el.textContent).toBe('') + }) }) it('renders with data-testid on the wrapper', () => { @@ -43,303 +28,269 @@ describe('CodeInput', () => { expect(screen.getByTestId('otp')).toBeDefined() }) - it('renders per-digit data-testid when data-testid is provided', () => { - render() - for (let i = 0; i < 4; i++) { - expect(screen.getByTestId(`otp-${i}`)).toBeDefined() - } + it('renders a hidden input for accessibility', () => { + const { container } = render() + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) + expect(hiddenInput).toBeDefined() }) }) describe('typing', () => { it('accepts a digit in the first box', () => { - render() - const [first] = getDigits() - fireEvent.change(first, { target: { value: '3' } }) - expect((first as HTMLInputElement).value).toBe('3') + const { container } = render() + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) as HTMLInputElement + fireEvent.change(hiddenInput, { target: { value: '3' } }) + const textElements = container.querySelectorAll('.text-h2') + expect(textElements[0].textContent).toBe('3') }) - it('ignores non-numeric characters', () => { - render() - const [first] = getDigits() - fireEvent.change(first, { target: { value: 'a' } }) - expect((first as HTMLInputElement).value).toBe('') + it('converts input to uppercase', () => { + const { container } = render() + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) as HTMLInputElement + fireEvent.change(hiddenInput, { target: { value: 'abc123' } }) + expect(hiddenInput.value).toBe('ABC123') }) - it('calls onChange with the current code string after each keystroke', () => { + it('calls onChange with the current code after each keystroke', () => { const onChange = vi.fn() - render() - const [first] = getDigits() - fireEvent.change(first, { target: { value: '5' } }) - expect(onChange).toHaveBeenCalledWith( - '5 '.trimEnd().padEnd(6, ' ').replace(/ /g, ''), - ) - // The code string is the joined values – 6 chars, only first filled - expect(onChange).toHaveBeenCalledWith(expect.stringMatching(/^5/)) - }) - - it('advances focus to the next input after a digit is entered', () => { - render() - const [first, second] = getDigits() - first.focus() - fireEvent.change(first, { target: { value: '1' } }) - expect(document.activeElement).toBe(second) - }) - - it('does not advance focus past the last input', () => { - render() - const [, second] = getDigits(2) - second.focus() - fireEvent.change(second, { target: { value: '9' } }) - expect(document.activeElement).toBe(second) + const { container } = render() + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) as HTMLInputElement + fireEvent.change(hiddenInput, { target: { value: '5' } }) + expect(onChange).toHaveBeenCalledWith('5') + }) + + it('limits input to 6 characters', () => { + const { container } = render() + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) as HTMLInputElement + fireEvent.change(hiddenInput, { target: { value: '1234567890' } }) + expect(hiddenInput.value).toBe('123456') }) }) describe('onComplete', () => { - it('calls onComplete when all digits are filled', () => { + it('calls onComplete when all 6 digits are filled', async () => { const onComplete = vi.fn() - render() - const digits = getDigits(4) - fireEvent.change(digits[0], { target: { value: '1' } }) - fireEvent.change(digits[1], { target: { value: '2' } }) - fireEvent.change(digits[2], { target: { value: '3' } }) - fireEvent.change(digits[3], { target: { value: '4' } }) - expect(onComplete).toHaveBeenCalledOnce() - expect(onComplete).toHaveBeenCalledWith('1234') + const { container } = render( + , + ) + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) as HTMLInputElement + + fireEvent.change(hiddenInput, { target: { value: '123456' } }) + + // Wait for setTimeout to complete + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(onComplete).toHaveBeenCalledWith('123456') }) it('does not call onComplete when only some digits are filled', () => { const onComplete = vi.fn() - render() - const digits = getDigits(4) - fireEvent.change(digits[0], { target: { value: '1' } }) - fireEvent.change(digits[1], { target: { value: '2' } }) + const { container } = render() + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) as HTMLInputElement + + fireEvent.change(hiddenInput, { target: { value: '12' } }) + expect(onComplete).not.toHaveBeenCalled() }) - }) - describe('backspace', () => { - it('clears the current box on Backspace when it has a value', () => { - render() - const [first] = getDigits() - fireEvent.change(first, { target: { value: '7' } }) - fireEvent.keyDown(first, { key: 'Backspace' }) - expect((first as HTMLInputElement).value).toBe('') - }) + it('blurs the input after completion', async () => { + const onComplete = vi.fn() + const { container } = render() + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) as HTMLInputElement - it('moves focus to the previous box on Backspace when current box is empty', () => { - render() - const [first, second] = getDigits(3) - fireEvent.change(first, { target: { value: '1' } }) - second.focus() - fireEvent.keyDown(second, { key: 'Backspace' }) - expect(document.activeElement).toBe(first) - }) + hiddenInput.focus() + expect(document.activeElement).toBe(hiddenInput) + + fireEvent.change(hiddenInput, { target: { value: '123456' } }) - it('does not move focus before the first box on Backspace', () => { - render() - const [first] = getDigits() - first.focus() - fireEvent.keyDown(first, { key: 'Backspace' }) - expect(document.activeElement).toBe(first) + // Wait for setTimeout to complete + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(document.activeElement).not.toBe(hiddenInput) }) }) - describe('arrow keys', () => { - it('moves focus left on ArrowLeft', () => { - render() - const [first, second] = getDigits(3) - second.focus() - fireEvent.keyDown(second, { key: 'ArrowLeft' }) - expect(document.activeElement).toBe(first) - }) + describe('focus behavior', () => { + it('focuses the hidden input when clicking on the container', () => { + const { container } = render() + const button = container.querySelector('button') as HTMLButtonElement + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) as HTMLInputElement - it('moves focus right on ArrowRight', () => { - render() - const [first, second] = getDigits(3) - first.focus() - fireEvent.keyDown(first, { key: 'ArrowRight' }) - expect(document.activeElement).toBe(second) + fireEvent.click(button) + expect(document.activeElement).toBe(hiddenInput) }) - it('accepts a digit after navigating with ArrowRight', () => { - const onChange = vi.fn() - render() - const [first, second] = getDigits(3) - first.focus() - fireEvent.keyDown(first, { key: 'ArrowRight' }) - expect(document.activeElement).toBe(second) - fireEvent.keyDown(second, { key: '7' }) - expect((second as HTMLInputElement).value).toBe('7') - expect(onChange).toHaveBeenCalledWith(expect.stringContaining('7')) + it('shows focus border on the current character box when focused', () => { + const { container } = render() + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) as HTMLInputElement + + fireEvent.focus(hiddenInput) + fireEvent.change(hiddenInput, { target: { value: 'AB' } }) + + const boxes = container.querySelectorAll('.backdrop-blur-md') + // Third box (index 2) should have focus border since we have 2 chars + expect(boxes[2].className).toContain('border-greyScale') }) - it('accepts a digit after navigating with ArrowLeft', () => { - const onChange = vi.fn() - render() - const [first, , third] = getDigits(3) - third.focus() - fireEvent.keyDown(third, { key: 'ArrowLeft' }) - fireEvent.keyDown(document.activeElement as HTMLInputElement, { - key: 'ArrowLeft', + it('does not show focus border when error is true', () => { + const { container } = render() + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) as HTMLInputElement + + fireEvent.focus(hiddenInput) + + const boxes = container.querySelectorAll('.backdrop-blur-md') + Array.from(boxes).forEach((box) => { + expect(box.className).not.toContain('border-greyScale') }) - expect(document.activeElement).toBe(first) - fireEvent.keyDown(first, { key: '4' }) - expect((first as HTMLInputElement).value).toBe('4') }) }) - describe('same digit replacement', () => { - it('advances focus when typing the same digit that already exists', () => { - render() - const [first, second, third] = getDigits(3) - first.focus() - fireEvent.keyDown(first, { key: '5' }) - expect((first as HTMLInputElement).value).toBe('5') - expect(document.activeElement).toBe(second) - // Type same digit in the second box - fireEvent.keyDown(second, { key: '5' }) - expect((second as HTMLInputElement).value).toBe('5') - expect(document.activeElement).toBe(third) + describe('disabled state', () => { + it('disables the hidden input when disabled=true', () => { + const { container } = render() + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) as HTMLInputElement + expect(hiddenInput.disabled).toBe(true) }) - it('calls onChange when typing the same digit over an existing one', () => { - const onChange = vi.fn() - render() - const [first, second] = getDigits(3) - first.focus() - fireEvent.keyDown(first, { key: '3' }) - expect(document.activeElement).toBe(second) - // Navigate back and overwrite with the same digit - fireEvent.keyDown(second, { key: 'ArrowLeft' }) - expect(document.activeElement).toBe(first) - onChange.mockClear() - fireEvent.keyDown(first, { key: '3' }) - expect(onChange).toHaveBeenCalled() - expect(document.activeElement).toBe(second) + it('disables the button when disabled=true', () => { + const { container } = render() + const button = container.querySelector('button') as HTMLButtonElement + expect(button.disabled).toBe(true) }) - it('replaces a digit with a different digit and advances', () => { - render() - const [first, second] = getDigits(3) - first.focus() - fireEvent.keyDown(first, { key: '1' }) - expect(document.activeElement).toBe(second) - // Go back and replace with a different digit - fireEvent.keyDown(second, { key: 'ArrowLeft' }) - fireEvent.keyDown(first, { key: '9' }) - expect((first as HTMLInputElement).value).toBe('9') - expect(document.activeElement).toBe(second) - }) + it('does not focus when clicking while disabled', () => { + const { container } = render() + const button = container.querySelector('button') as HTMLButtonElement + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) as HTMLInputElement - it('calls onComplete when the last digit is the same as the existing one', () => { - const onComplete = vi.fn() - render() - const digits = getDigits(3) - digits[0].focus() - fireEvent.keyDown(digits[0], { key: '5' }) - fireEvent.keyDown(digits[1], { key: '5' }) - fireEvent.keyDown(digits[2], { key: '5' }) - expect(onComplete).toHaveBeenCalledWith('555') + fireEvent.click(button) + expect(document.activeElement).not.toBe(hiddenInput) }) }) - describe('paste', () => { - const createPasteEvent = (text: string) => ({ - clipboardData: { getData: () => text }, - preventDefault: vi.fn(), + describe('autoFocus', () => { + it('focuses the input when autoFocus is true', () => { + const { container } = render() + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) as HTMLInputElement + expect(document.activeElement).toBe(hiddenInput) }) - it('fills all boxes when a full numeric code is pasted', () => { - const onChange = vi.fn() - render() - const [first] = getDigits(4) - fireEvent.paste(first, createPasteEvent('1234')) - const digits = getDigits(4) - expect((digits[0] as HTMLInputElement).value).toBe('1') - expect((digits[1] as HTMLInputElement).value).toBe('2') - expect((digits[2] as HTMLInputElement).value).toBe('3') - expect((digits[3] as HTMLInputElement).value).toBe('4') + it('does not auto-focus when disabled', () => { + const { container } = render() + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) as HTMLInputElement + expect(document.activeElement).not.toBe(hiddenInput) }) + }) - it('calls onComplete when a full code is pasted', () => { - const onComplete = vi.fn() - render() - const [first] = getDigits(4) - fireEvent.paste(first, createPasteEvent('5678')) - expect(onComplete).toHaveBeenCalledWith('5678') - }) + describe('character display', () => { + it('displays characters in the correct boxes', () => { + const { container } = render() + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) as HTMLInputElement - it('strips non-numeric characters from pasted text', () => { - const onChange = vi.fn() - render() - const [first] = getDigits(4) - fireEvent.paste(first, createPasteEvent('12-34')) - expect(onChange).toHaveBeenCalledWith('1234') + fireEvent.change(hiddenInput, { target: { value: 'ABC' } }) + + const textElements = container.querySelectorAll('.text-h2') + expect(textElements[0].textContent).toBe('A') + expect(textElements[1].textContent).toBe('B') + expect(textElements[2].textContent).toBe('C') + expect(textElements[3].textContent).toBe('') + expect(textElements[4].textContent).toBe('') + expect(textElements[5].textContent).toBe('') }) + }) - it('truncates pasted text to the code length', () => { - const onChange = vi.fn() - render() - const [first] = getDigits(4) - fireEvent.paste(first, createPasteEvent('123456789')) - expect(onChange).toHaveBeenCalledWith('1234') + describe('length prop', () => { + it('renders the requested number of boxes (4)', () => { + const { container } = render() + const boxes = container.querySelectorAll('.backdrop-blur-md') + expect(boxes).toHaveLength(4) }) - it('does nothing when pasted text contains no digits', () => { - const onChange = vi.fn() - render() - const [first] = getDigits(4) - fireEvent.paste(first, createPasteEvent('abcd')) - expect(onChange).not.toHaveBeenCalled() + it('renders the requested number of boxes (8)', () => { + const { container } = render() + const boxes = container.querySelectorAll('.backdrop-blur-md') + expect(boxes).toHaveLength(8) }) - }) - describe('disabled state', () => { - it('disables all inputs when disabled=true', () => { - render() - for (const input of getDigits()) { - expect((input as HTMLInputElement).disabled).toBe(true) - } + it('clamps lengths below 4 up to 4', () => { + const { container } = render() + const boxes = container.querySelectorAll('.backdrop-blur-md') + expect(boxes).toHaveLength(4) }) - it('applies disabled styling classes', () => { - render() - for (const input of getDigits()) { - expect((input as HTMLInputElement).className).toContain('opacity-50') - expect((input as HTMLInputElement).className).toContain( - 'cursor-not-allowed', - ) - } + it('clamps lengths above 8 down to 8', () => { + const { container } = render() + const boxes = container.querySelectorAll('.backdrop-blur-md') + expect(boxes).toHaveLength(8) }) - it('does not apply disabled classes when not disabled', () => { - render() - for (const input of getDigits()) { - expect((input as HTMLInputElement).className).not.toContain( - 'opacity-50', - ) - } + it('limits typed input to the configured length', () => { + const { container } = render() + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) as HTMLInputElement + fireEvent.change(hiddenInput, { target: { value: '1234567890' } }) + expect(hiddenInput.value).toBe('1234') }) - }) - describe('error state', () => { - it('applies error border classes when error=true', () => { - render() - for (const input of getDigits()) { - expect((input as HTMLInputElement).className).toContain( - 'border-red-500', - ) - } + it('fires onComplete at the configured length', async () => { + const onComplete = vi.fn() + const { container } = render( + , + ) + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) as HTMLInputElement + + fireEvent.change(hiddenInput, { target: { value: '1234' } }) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(onComplete).toHaveBeenCalledWith('1234') }) - it('applies normal border classes when error=false', () => { - render() - for (const input of getDigits()) { - expect((input as HTMLInputElement).className).toContain( - 'border-gray-200', - ) - } + it('does not fire onComplete at 6 chars when length is 8', () => { + const onComplete = vi.fn() + const { container } = render( + , + ) + const hiddenInput = container.querySelector( + 'input[aria-label="Verification code"]', + ) as HTMLInputElement + + fireEvent.change(hiddenInput, { target: { value: '123456' } }) + expect(onComplete).not.toHaveBeenCalled() }) }) }) diff --git a/packages/react-kit/src/auth/components/CodeInput/index.tsx b/packages/react-kit/src/auth/components/CodeInput/index.tsx index 1abbbfe..6129ebe 100644 --- a/packages/react-kit/src/auth/components/CodeInput/index.tsx +++ b/packages/react-kit/src/auth/components/CodeInput/index.tsx @@ -1,155 +1,119 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Text } from '../../../shared/components/Text' +import { Wrapper } from '../../../shared/components/Wrapper' import { cn } from '../../../shared/utils/common' +const MIN_LENGTH = 4 +const MAX_LENGTH = 8 +const DEFAULT_LENGTH = 6 + +function clampLength(length: number): number { + return Math.max(MIN_LENGTH, Math.min(MAX_LENGTH, length)) +} + +interface CharBoxProps { + char: string + isFocused: boolean +} + +function CharBox({ char, isFocused }: CharBoxProps) { + return ( + + {char} + + ) +} + export interface CodeInputProps { - length?: number onChange?: (code: string) => void onComplete?: (code: string) => void disabled?: boolean error?: boolean autoFocus?: boolean + length?: number 'data-testid'?: string } export function CodeInput({ - length = 6, onChange, onComplete, disabled = false, error = false, autoFocus = false, + length = DEFAULT_LENGTH, 'data-testid': testId, }: CodeInputProps) { - const [values, setValues] = useState(Array(length).fill('')) - const inputRefs = useRef<(HTMLInputElement | null)[]>([]) - const digitKeys = useRef(Array.from({ length }, (_, i) => `digit-${i}`)) + const codeLength = clampLength(length) + const charBoxes = useMemo( + () => + Array.from({ length: codeLength }, (_, i) => ({ + id: `char-${i}`, + index: i, + })), + [codeLength], + ) - useEffect(() => { - setValues(Array(length).fill('')) - digitKeys.current = Array.from({ length }, (_, i) => `digit-${i}`) - inputRefs.current = inputRefs.current.slice(0, length) - }, [length]) - const focusIndex = (index: number) => { - inputRefs.current[index]?.focus() - } + const [code, setCode] = useState('') + const [isFocused, setIsFocused] = useState(false) + const inputRef = useRef(null) useEffect(() => { - if (autoFocus) { - inputRefs.current[0]?.focus() - } - }, [autoFocus]) - - const handleChange = (index: number, raw: string) => { - // Accept only the last digit typed (handles Android auto-fill sending full string) - const digit = raw.replace(/\D/g, '').slice(-1) - const next = [...values] - next[index] = digit - setValues(next) - - const code = next.join('') - onChange?.(code) - if (digit && index < length - 1) { - focusIndex(index + 1) - } - if (code.replace(/\s/g, '').length === length && !next.includes('')) { - onComplete?.(code) + if (autoFocus && !disabled) { + inputRef.current?.focus() } - } + }, [autoFocus, disabled]) - const handleKeyDown = ( - index: number, - e: React.KeyboardEvent, - ) => { - // Handle digit keys directly so that typing the same digit still advances focus. - // The browser skips onChange when the value doesn't actually change (e.g. "5" → "5"), - // so we bypass it entirely for digit input. - if (/^\d$/.test(e.key)) { - e.preventDefault() - handleChange(index, e.key) - return - } + useEffect(() => { + setCode((prev) => prev.slice(0, codeLength)) + }, [codeLength]) - if (e.key === 'Backspace') { - if (values[index]) { - const next = [...values] - next[index] = '' - setValues(next) - onChange?.(next.join('')) - } else if (index > 0) { - focusIndex(index - 1) - const next = [...values] - next[index - 1] = '' - setValues(next) - onChange?.(next.join('')) - } - e.preventDefault() - } else if (e.key === 'ArrowLeft' && index > 0) { - e.preventDefault() - focusIndex(index - 1) - } else if (e.key === 'ArrowRight' && index < length - 1) { - e.preventDefault() - focusIndex(index + 1) - } - } + const handleChange = (text: string) => { + const sanitized = text.slice(0, codeLength).toUpperCase() + setCode(sanitized) + onChange?.(sanitized) - const handlePaste = (e: React.ClipboardEvent) => { - e.preventDefault() - const pasted = e.clipboardData - .getData('text') - .replace(/\D/g, '') - .slice(0, length) - if (!pasted) return - const next = [...values] - for (let i = 0; i < pasted.length; i++) { - next[i] = pasted[i] ?? '' - } - setValues(next) - const code = next.join('') - onChange?.(code) - const focusTarget = Math.min(pasted.length, length - 1) - focusIndex(focusTarget) - if (pasted.length === length) { - onComplete?.(code) + if (sanitized.length === codeLength) { + // Makes sure onComplete is run on the next render cycle after the last char is rendered + setTimeout(() => { + onComplete?.(sanitized) + inputRef.current?.blur() + }, 0) } } return ( -
- {digitKeys.current.map((key, i) => ( - { - inputRefs.current[i] = el - }} - type="text" - inputMode="numeric" - pattern="\d*" - maxLength={1} - value={values[i] ?? ''} - disabled={disabled} - aria-label={`Digit ${i + 1}`} - data-testid={testId ? `${testId}-${i}` : undefined} - onChange={(e) => { - handleChange(i, e.target.value) - }} - onKeyDown={(e) => { - handleKeyDown(i, e) - }} - onPaste={handlePaste} - onFocus={(e) => { - e.target.select() - }} - className={cn( - 'w-12 h-14 text-center text-xl font-semibold rounded-xl border-2 outline-none transition-colors', - 'bg-white text-gray-900 caret-transparent', - error - ? 'border-red-500 focus:border-red-600' - : 'border-gray-200 focus:border-gray-900', - disabled && 'opacity-50 cursor-not-allowed bg-gray-100', - )} +
+ ) } diff --git a/packages/react-kit/src/auth/pages/EmailVerification.tsx b/packages/react-kit/src/auth/pages/EmailVerification.tsx index 67a38a6..43d576e 100644 --- a/packages/react-kit/src/auth/pages/EmailVerification.tsx +++ b/packages/react-kit/src/auth/pages/EmailVerification.tsx @@ -1,26 +1,87 @@ -import { Button } from '../../shared/components/Button' +import { useSendOTP } from '@zerodev/wallet-react' +import { useEffect, useState } from 'react' +import { AppLogo } from '../../shared/components/AppLogo' +import { ScreenWrapper } from '../../shared/components/ScreenWrapper' import { StatusView } from '../../shared/components/StatusView' +import { Text } from '../../shared/components/Text' import { useAuth } from '../hooks/useAuth' export function EmailVerification() { - const { email, goToStep, goBack } = useAuth() + const { email, setOtpId, goToStep } = useAuth() + const { mutateAsync: sendOtp, isPending: isSendOtpPending } = useSendOTP() + + const [secondsLeftUntilResend, setSecondsLeftUntilResend] = useState(60) + const canResend = secondsLeftUntilResend <= 0 && !isSendOtpPending + + useEffect(() => { + if (secondsLeftUntilResend <= 0) return + + const timer = setInterval(() => { + setSecondsLeftUntilResend((prev) => Math.max(0, prev - 1)) + }, 1000) + + return () => { + clearInterval(timer) + } + }, [secondsLeftUntilResend]) + + const handleResendOtp = async () => { + if (!email || !canResend) return + + try { + const { otpId } = await sendOtp({ email }) + setOtpId(otpId) + setSecondsLeftUntilResend(60) + } catch { + // Error sending OTP + } + } return ( -
- - We sent a verification link to {email}. Click the link to continue, or - enter the code manually. - - -
-
-
+ + {({ paddingTop }) => ( +
+ + We've sent a magic link to{' '} + {email} + {'\n'}Please open the email and click the link to log in. + + +
+ + Did not get an email?{' '} + + + + Or{' '} + + +
+ + +
+ )} +
) } diff --git a/packages/react-kit/src/auth/pages/ErrorScreen.tsx b/packages/react-kit/src/auth/pages/ErrorScreen.tsx index bf835ca..ea7d445 100644 --- a/packages/react-kit/src/auth/pages/ErrorScreen.tsx +++ b/packages/react-kit/src/auth/pages/ErrorScreen.tsx @@ -1,20 +1,52 @@ +import { AppLogo } from '../../shared/components/AppLogo' import { Button } from '../../shared/components/Button' +import { ScreenWrapper } from '../../shared/components/ScreenWrapper' import { StatusView } from '../../shared/components/StatusView' +import { Text } from '../../shared/components/Text' import { useAuth } from '../hooks/useAuth' -export function ErrorScreen() { - const { goBack, reset } = useAuth() +interface ErrorScreenProps { + title?: string + message?: string + showRetry?: boolean + showChooseAnother?: boolean +} + +export function ErrorScreen({ + title = 'Oops, something went wrong', + message = "We couldn't complete the sign-in process. This could be due to timeout, an expired link, or a cancelled request.", + showRetry = false, + showChooseAnother = true, +}: ErrorScreenProps) { + const { goToStep, goBack, reset } = useAuth() return ( -
- - An unexpected error occurred. Please try again. - + + {() => ( +
+ + {message} + + +
+ {showRetry && ( +
-
-
-
+ +
+ )} + ) } diff --git a/packages/react-kit/src/auth/pages/OtpInput.tsx b/packages/react-kit/src/auth/pages/OtpInput.tsx index 4ef61f8..de3ee2c 100644 --- a/packages/react-kit/src/auth/pages/OtpInput.tsx +++ b/packages/react-kit/src/auth/pages/OtpInput.tsx @@ -1,37 +1,37 @@ import { useSendOTP, useVerifyOTP } from '@zerodev/wallet-react' import { useEffect, useState } from 'react' +import { AppLogo } from '../../shared/components/AppLogo' import { Button } from '../../shared/components/Button' +import { ScreenWrapper } from '../../shared/components/ScreenWrapper' +import { Text } from '../../shared/components/Text' import { CodeInput } from '../components/CodeInput' import { useAuth } from '../hooks/useAuth' export function OtpInput() { - const { email, otpId, setOtpId, goToStep, goBack, config } = useAuth() - const { mutateAsync: sendOtp } = useSendOTP() + const { email, otpId, setOtpId, goToStep, config } = useAuth() + const { mutateAsync: sendOtp, isPending: isSendOtpPending } = useSendOTP() const { mutateAsync: verifyOtp, isPending } = useVerifyOTP() + const [otp, setOtp] = useState('') const [error, setError] = useState(false) - const [resendAvailableAt, setResendAvailableAt] = useState(Date.now() + 60000) const [secondsUntilResend, setSecondsUntilResend] = useState(60) // Countdown timer for resend useEffect(() => { - if (!resendAvailableAt) return + if (secondsUntilResend <= 0) return + const interval = setInterval(() => { - const remaining = Math.max( - 0, - Math.ceil((resendAvailableAt - Date.now()) / 1000), - ) - setSecondsUntilResend(remaining) + setSecondsUntilResend((prev) => Math.max(0, prev - 1)) }, 1000) return () => clearInterval(interval) - }, [resendAvailableAt]) + }, [secondsUntilResend]) - const handleComplete = async (code: string) => { - if (!otpId) return + const handleVerify = async () => { + if (!otp.trim() || !otpId) return setError(false) try { - await verifyOtp({ otpId, code }) + await verifyOtp({ otpId, code: otp.trim() }) goToStep('authenticated') config?.onSuccess?.() } catch (err) { @@ -40,64 +40,70 @@ export function OtpInput() { } } + const handleComplete = (code: string) => { + setOtp(code) + } + const handleResend = async () => { - if (!email || secondsUntilResend > 0) return + if (!email || secondsUntilResend > 0 || isSendOtpPending) return try { const { otpId: newOtpId } = await sendOtp({ email }) setOtpId(newOtpId) - setResendAvailableAt(Date.now() + 60000) + setSecondsUntilResend(60) setError(false) } catch { setError(true) } } - const canResend = secondsUntilResend <= 0 + const canResend = secondsUntilResend <= 0 && !isSendOtpPending return ( -
-
-

Enter verification code

-

- We sent a 6-digit code to {email} -

-
+ + {() => ( +
+
+ Enter verification code + + Enter the code from the email we sent to{' '} + {email} + +
-
- setError(false)} - onComplete={handleComplete} - disabled={isPending} - error={error} - autoFocus - /> + setError(false)} + disabled={isPending} + error={error} + autoFocus + /> - {error && ( -

- Invalid code. Please try again. -

- )} -
+ + +
-
- + + + )} + ) } diff --git a/packages/react-kit/src/auth/pages/SignUp.tsx b/packages/react-kit/src/auth/pages/SignUp.tsx index c1ddaa9..ea8d2dd 100644 --- a/packages/react-kit/src/auth/pages/SignUp.tsx +++ b/packages/react-kit/src/auth/pages/SignUp.tsx @@ -109,7 +109,7 @@ export function SignUp() { {emailInput && !isEmailLoading ? (