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:
hamid
2026-07-02 01:19:21 +03:30
parent 2f2aec61a2
commit 94fdcbe0d1
65 changed files with 1632 additions and 227 deletions
+135
View File
@@ -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;