frontend phase 0: app shells, design system & data/contract patterns
Turn the starter into the Balinyaar foundation for the three actor
experiences and lock in the patterns later phases copy.
- Cleanup: remove toastDemo namespace, placeholder home page, and the two
dead icons; fix BottomBar to use usePathname (locale-aware active tab).
- Three actor shells under (private-routes), no layout above [locale]:
customer (customer) group with the 5-tab bottom nav; nurse (/nurse) and
admin (/admin) on the shared sidebar engine. Role model via constants/roles
+ useActorRole (defaults to customer until roles land in f1-b2).
- services/{domain} reference (patients) with a mock behind a config seam,
hierarchical query keys, deliberate staleTime, and mutation invalidation;
shared ApiEnvelope/Paginated wire types + unwrap() in lib/api/types.
- Money (integer-safe IRR/Toman) + Shamsi-date utils; toEnglishDigits helper.
- Shared composites, each tested: OtpInput, PhoneNumberField, StepperHeader,
StatusChip, PlaceholderScreen.
- i18n: seed nav/common/shell/patients in both locales; document namespace
conventions. Update client/CLAUDE.md Project Structure + fix ColorSchemeScript
doc drift. Add phase report, STATUS, and REQ-001 (envelope/casing/pagination).
Gate: npm run check + test:ci green (72 tests); build green with NEXT_PUBLIC_API_URL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
import { ChangeEvent, ClipboardEvent, FunctionComponent, KeyboardEvent, useEffect, useMemo, useRef } from 'react';
|
||||
import { Stack, TextField } from '@mui/material';
|
||||
import { digitsOnly } from '@/utils';
|
||||
|
||||
export interface OtpInputProps {
|
||||
/** Number of digit boxes. */
|
||||
length?: number;
|
||||
/** Controlled value — the digits entered so far. */
|
||||
value: string;
|
||||
/** Emits the full concatenated value on every change. */
|
||||
onChange: (value: string) => void;
|
||||
/** Fired once the value reaches `length` digits. */
|
||||
onComplete?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
error?: boolean;
|
||||
/** Accessible label for the group. */
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_LENGTH = 5;
|
||||
const BOX_SIZE = 48;
|
||||
|
||||
/**
|
||||
* One-time-code input: `length` single-digit boxes with auto-advance, backspace-to-previous,
|
||||
* and paste distribution. Digits are normalized (Persian/Arabic → ASCII) and the group is
|
||||
* forced LTR so codes read left-to-right even inside the RTL (`fa`) layout.
|
||||
* @component OtpInput
|
||||
*/
|
||||
const OtpInput: FunctionComponent<OtpInputProps> = ({
|
||||
length = DEFAULT_LENGTH,
|
||||
value,
|
||||
onChange,
|
||||
onComplete,
|
||||
disabled,
|
||||
autoFocus,
|
||||
error,
|
||||
'aria-label': ariaLabel,
|
||||
}) => {
|
||||
const inputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
|
||||
const chars = useMemo(
|
||||
() => Array.from({ length }, (_, index) => value[index] ?? ''),
|
||||
[value, length]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) inputRefs.current[0]?.focus();
|
||||
}, [autoFocus]);
|
||||
|
||||
const focusBox = (index: number) => {
|
||||
const target = inputRefs.current[index];
|
||||
if (target) {
|
||||
target.focus();
|
||||
target.select();
|
||||
}
|
||||
};
|
||||
|
||||
const emit = (next: string[]) => {
|
||||
const joined = next.join('');
|
||||
onChange(joined);
|
||||
if (next.every((char) => char !== '')) onComplete?.(joined);
|
||||
};
|
||||
|
||||
const handleChange = (index: number, event: ChangeEvent<HTMLInputElement>) => {
|
||||
const incoming = digitsOnly(event.target.value);
|
||||
const next = [...chars];
|
||||
|
||||
if (!incoming) {
|
||||
next[index] = '';
|
||||
emit(next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (incoming.length === 1) {
|
||||
next[index] = incoming;
|
||||
focusBox(index + 1);
|
||||
} else {
|
||||
// Paste / multi-char: fill sequentially from the current box.
|
||||
for (let offset = 0; offset < incoming.length && index + offset < length; offset += 1) {
|
||||
next[index + offset] = incoming[offset];
|
||||
}
|
||||
focusBox(Math.min(index + incoming.length, length - 1));
|
||||
}
|
||||
emit(next);
|
||||
};
|
||||
|
||||
const handleKeyDown = (index: number, event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Backspace' && !chars[index]) {
|
||||
focusBox(index - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (index: number, event: ClipboardEvent<HTMLInputElement>) => {
|
||||
event.preventDefault();
|
||||
const pasted = digitsOnly(event.clipboardData.getData('text'));
|
||||
if (!pasted) return;
|
||||
const next = [...chars];
|
||||
for (let offset = 0; offset < pasted.length && index + offset < length; offset += 1) {
|
||||
next[index + offset] = pasted[offset];
|
||||
}
|
||||
focusBox(Math.min(index + pasted.length, length - 1));
|
||||
emit(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={1} dir="ltr" role="group" aria-label={ariaLabel} sx={{ justifyContent: 'center' }}>
|
||||
{chars.map((char, index) => (
|
||||
<TextField
|
||||
key={index}
|
||||
value={char}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
onChange={(event) => handleChange(index, event as ChangeEvent<HTMLInputElement>)}
|
||||
onKeyDown={(event) => handleKeyDown(index, event as KeyboardEvent<HTMLInputElement>)}
|
||||
onPaste={(event) => handlePaste(index, event as ClipboardEvent<HTMLInputElement>)}
|
||||
inputRef={(el: HTMLInputElement | null) => {
|
||||
inputRefs.current[index] = el;
|
||||
}}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
inputMode: 'numeric',
|
||||
maxLength: 1,
|
||||
'aria-label': `${ariaLabel ?? 'digit'} ${index + 1}`,
|
||||
style: { textAlign: 'center', fontSize: '1.25rem', width: BOX_SIZE, padding: 8 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default OtpInput;
|
||||
Reference in New Issue
Block a user