manual improvement 1

This commit is contained in:
hamid
2026-07-27 22:27:04 +03:30
parent bd06ef0016
commit baa3cc63cd
166 changed files with 3111 additions and 1770 deletions
@@ -2,7 +2,7 @@ import { FunctionComponent } from 'react';
import { render, screen, within } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import AppButton, { AppButtonProps } from './AppButton';
import DefaultIcon from '@mui/icons-material/MoreHoriz';
import { Ellipsis as DefaultIcon } from 'lucide-react';
import { randomText, capitalize } from '@/utils';
/**
@@ -130,7 +130,8 @@ describe('<AppButton/> component', () => {
let text = 'button with start icon';
render(<ComponentToTest text={text} startIcon="default" />);
let button = screen.getByText(text);
let icon = within(button).getByTestId('MoreHorizRoundedIcon'); //Note: this is valid only when "default" icon is <MoreHorizRoundedIcon />
// The registry is Lucide now — identify the glyph by AppIcon's own data-icon, not a MUI testid.
let icon = button.querySelector('[data-icon="default"]') as HTMLElement;
expect(icon).toBeDefined();
let span = icon.closest('span');
expect(span).toHaveClass('MuiButton-startIcon');
@@ -140,7 +141,8 @@ describe('<AppButton/> component', () => {
let text = 'button with end icon as string';
render(<ComponentToTest text={text} endIcon="default" />);
let button = screen.getByText(text);
let icon = within(button).getByTestId('MoreHorizRoundedIcon'); //Note: this is valid only when "default" icon is <MoreHorizRoundedIcon />
// The registry is Lucide now — identify the glyph by AppIcon's own data-icon, not a MUI testid.
let icon = button.querySelector('[data-icon="default"]') as HTMLElement;
expect(icon).toBeDefined();
let span = icon.closest('span');
expect(span).toHaveClass('MuiButton-endIcon');
@@ -2,7 +2,7 @@ import { ElementType, FunctionComponent, ReactNode, useMemo } from 'react';
import Button, { ButtonProps } from '@mui/material/Button';
import AppIcon from '../AppIcon';
import AppLink from '../AppLink';
import { APP_BUTTON_VARIANT } from '../../config';
import { APP_BUTTON_ICON_SIZE, APP_BUTTON_VARIANT } from '../../config';
const MUI_BUTTON_COLORS = ['inherit', 'primary', 'secondary', 'success', 'error', 'info', 'warning'];
@@ -44,13 +44,26 @@ const AppButton: FunctionComponent<AppButtonProps> = ({
variant = APP_BUTTON_VARIANT,
...restOfProps
}) => {
// Sized explicitly: MUI's own `fontSize: 20` rule on the icon slot only bites on an SvgIcon that
// sizes off 1em — the Lucide registry sizes off real width/height attributes and would otherwise
// render at the full 24px default inside every button.
const iconStart: ReactNode = useMemo(
() => (!startIcon ? undefined : typeof startIcon === 'string' ? <AppIcon icon={String(startIcon)} /> : startIcon),
() =>
!startIcon ? undefined : typeof startIcon === 'string' ? (
<AppIcon icon={String(startIcon)} size={APP_BUTTON_ICON_SIZE} />
) : (
startIcon
),
[startIcon]
);
const iconEnd: ReactNode = useMemo(
() => (!endIcon ? undefined : typeof endIcon === 'string' ? <AppIcon icon={String(endIcon)} /> : endIcon),
() =>
!endIcon ? undefined : typeof endIcon === 'string' ? (
<AppIcon icon={String(endIcon)} size={APP_BUTTON_ICON_SIZE} />
) : (
endIcon
),
[endIcon]
);
@@ -1,8 +1,8 @@
import { render, screen } from '@testing-library/react';
import AppIcon from './AppIcon';
import { APP_ICON_SIZE } from '../../config';
import { APP_ICON_SIZE, APP_ICON_STROKE_WIDTH } from '../../config';
import { randomColor, randomText } from '@/utils';
import { ICONS } from './config';
import { DIRECTIONAL_ICONS, ICONS } from './config';
const ComponentToTest = AppIcon;
@@ -16,22 +16,22 @@ describe('<AppIcon/> component', () => {
const svg = screen.getByTestId(testId);
expect(svg).toBeDefined();
expect(svg).toHaveAttribute('data-icon', 'default');
expect(svg).toHaveAttribute('height', String(APP_ICON_SIZE)); // default size when .size is not set
expect(svg).toHaveAttribute('width', String(APP_ICON_SIZE)); // default size when .size is not se
// Size is actually driven by fontSize (MUI SvgIcon's `1em` sizing beats
// width/height attributes) — never assert a `size` DOM attribute, it's invalid.
expect(svg).toHaveStyle(`font-size: ${APP_ICON_SIZE}px`);
// Lucide sizes off real width/height attributes — no 1em/fontSize indirection to work around.
expect(svg).toHaveAttribute('height', String(APP_ICON_SIZE));
expect(svg).toHaveAttribute('width', String(APP_ICON_SIZE));
expect(svg).toHaveAttribute('stroke-width', String(APP_ICON_STROKE_WIDTH));
});
it('supports .color property', () => {
const testId = randomText(8);
const color = randomColor(); // Note: 'rgb(255, 128, 0)' format is used by react-icons npm, so tests may fail
const color = randomColor();
render(<ComponentToTest data-testid={testId} color={color} />);
const svg = screen.getByTestId(testId);
expect(svg).toHaveAttribute('data-icon', 'default');
// expect(svg).toHaveAttribute('color', color); // TODO: Looks like MUI Icons exclude .color property from <svg> rendering
expect(svg).toHaveStyle(`color: ${color}`);
expect(svg).toHaveAttribute('fill', 'currentColor'); // .fill must be 'currentColor' when .color property is set
// The whole family is stroked, never filled — a `fill` would paint the glyph solid.
expect(svg).toHaveAttribute('stroke', color);
expect(svg).toHaveAttribute('fill', 'none');
});
it('supports .icon property', () => {
@@ -52,9 +52,6 @@ describe('<AppIcon/> component', () => {
const svg = screen.getByTestId(testId);
expect(svg).toHaveAttribute('height', String(size));
expect(svg).toHaveAttribute('width', String(size));
// The bug this regression-guards: MUI SvgIcon's own CSS sets width/height:1em,
// which beats plain width/height attributes — only fontSize actually resizes it.
expect(svg).toHaveStyle(`font-size: ${size}px`);
});
it('supports .title property', () => {
@@ -65,4 +62,15 @@ describe('<AppIcon/> component', () => {
expect(svg).toBeDefined();
expect(svg).toHaveAttribute('title', title);
});
it('marks only the registered directional icons for the RTL mirror', () => {
for (const icon of Object.keys(ICONS) as Array<keyof typeof ICONS>) {
const testId = randomText(8);
render(<ComponentToTest data-testid={testId} icon={icon} />);
const svg = screen.getByTestId(testId);
// The single globals.css rule keys off this attribute — a name drifting out of
// DIRECTIONAL_ICONS silently stops mirroring under RTL, which is invisible in review.
expect(svg.hasAttribute('data-icon-directional')).toBe(DIRECTIONAL_ICONS.has(icon));
}
});
});
@@ -1,15 +1,13 @@
import { ComponentType, FunctionComponent, SVGAttributes } from 'react';
import { APP_ICON_SIZE } from '../../config';
import { ComponentType, CSSProperties, FunctionComponent } from 'react';
import { APP_ICON_SIZE, APP_ICON_STROKE_WIDTH } from '../../config';
import { IconName, ICONS, DIRECTIONAL_ICONS } from './config';
import { IconProps } from './utils';
/**
* Props of the AppIcon component, also can be used for SVG icons
*/
export interface Props extends SVGAttributes<SVGElement> {
color?: string;
export interface Props extends IconProps {
icon?: IconName | string;
size?: string | number;
title?: string;
}
/**
@@ -18,18 +16,20 @@ export interface Props extends SVGAttributes<SVGElement> {
* @param {string} [color] - color of the icon as a CSS color value
* @param {string} [icon] - name of the Icon to render
* @param {string} [title] - title/hint to show when the cursor hovers the icon
* @param {string | number} [size] - size of the icon, default is ICON_SIZE
* @param {string | number} [size] - size of the icon, default is APP_ICON_SIZE
* @param {string | number} [strokeWidth] - Lucide stroke weight, default is APP_ICON_STROKE_WIDTH
*/
const AppIcon: FunctionComponent<Props> = ({
color,
icon = 'default',
size = APP_ICON_SIZE,
strokeWidth = APP_ICON_STROKE_WIDTH,
style,
...restOfProps
}) => {
const iconName = (icon || 'default').trim().toLowerCase() as IconName;
let ComponentToRender: ComponentType = ICONS[iconName];
let ComponentToRender: ComponentType<IconProps> = ICONS[iconName];
if (!ComponentToRender) {
if (process.env.NODE_ENV !== 'production') {
console.warn(`AppIcon: icon "${iconName}" is not found!`);
@@ -37,29 +37,30 @@ const AppIcon: FunctionComponent<Props> = ({
ComponentToRender = ICONS.default;
}
const sizeValue = typeof size === 'number' ? `${size}px` : size;
// MUI's SvgIcon sets width/height:1em via its own class, which beats plain
// width/height attributes — driving size through fontSize (the basis for 1em)
// is what actually resizes MUI-registry icons. Custom raw <svg> icons (the
// brand mark) aren't MUI SvgIcon-based, so they keep scaling off the explicit
// width/height attributes below; the extra fontSize style is harmless for them.
const propsToRender = {
height: size,
width: size,
color,
fill: color && 'currentColor',
style: { ...style, color, fontSize: sizeValue },
...restOfProps,
};
// Lucide renders a stroked (never filled) <svg> sized off real width/height attributes, so
// `size` alone is authoritative — the old fontSize/1em dance MUI's SvgIcon needed is gone.
// `flexShrink: 0` is the house default because an icon squashed by a flex sibling is the one
// layout bug this component was silently reintroducing on every narrow row; a caller can still
// override it through `style`.
const styleToRender: CSSProperties = { flexShrink: 0, ...style, ...(color ? { color } : null) };
// Directional icons (back/chevron_start, …) are authored for LTR and mirror
// under RTL via the single CSS rule in tokens.css keyed on this attribute —
// under RTL via the single CSS rule in globals.css keyed on this attribute —
// registering a name in DIRECTIONAL_ICONS (config.ts) is the only step a
// later phase needs; never hand-roll a flip at the call site.
const directionalProps = DIRECTIONAL_ICONS.has(iconName) ? { 'data-icon-directional': true } : undefined;
return <ComponentToRender data-icon={iconName} {...directionalProps} {...propsToRender} />;
return (
<ComponentToRender
data-icon={iconName}
{...directionalProps}
size={size}
color={color}
strokeWidth={strokeWidth}
style={styleToRender}
{...restOfProps}
/>
);
};
export default AppIcon;
+263 -199
View File
@@ -1,107 +1,123 @@
// SVG assets — the Balinyaar brand mark
import LogoMark from './icons/LogoMark';
// MUI Icons — normalized to ONE visual family (Rounded): warmer, softer strokes
// than the filled/outlined mix this registry used to carry, fitting the
// clinical-but-human tone. Adding an icon = import here + one lowercase key
// below; never import a raw MUI icon anywhere else in the app.
import DefaultIcon from '@mui/icons-material/MoreHorizRounded';
import CloseIcon from '@mui/icons-material/CloseRounded';
import MenuIcon from '@mui/icons-material/MenuRounded';
import SearchIcon from '@mui/icons-material/SearchRounded';
import InfoIcon from '@mui/icons-material/InfoRounded';
import HomeIcon from '@mui/icons-material/HomeRounded';
import AccountCircleIcon from '@mui/icons-material/AccountCircleRounded';
import ExitToAppIcon from '@mui/icons-material/ExitToAppRounded';
import NotificationsIcon from '@mui/icons-material/NotificationsRounded';
import DangerousIcon from '@mui/icons-material/DangerousRounded';
import EventNoteIcon from '@mui/icons-material/EventNoteRounded';
import GroupsIcon from '@mui/icons-material/GroupsRounded';
import PeopleAltIcon from '@mui/icons-material/PeopleAltRounded';
import WalletIcon from '@mui/icons-material/AccountBalanceWalletRounded';
import DashboardIcon from '@mui/icons-material/DashboardRounded';
import VerifiedUserIcon from '@mui/icons-material/VerifiedUserRounded';
import CheckCircleIcon from '@mui/icons-material/CheckCircleRounded';
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmptyRounded';
import CancelIcon from '@mui/icons-material/CancelRounded';
import MedicalServicesIcon from '@mui/icons-material/MedicalServicesRounded';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettingsRounded';
import AddIcon from '@mui/icons-material/AddRounded';
import EditIcon from '@mui/icons-material/EditRounded';
import ArchiveIcon from '@mui/icons-material/Inventory2Rounded';
import BankIcon from '@mui/icons-material/AccountBalanceRounded';
import CameraIcon from '@mui/icons-material/PhotoCameraRounded';
import WarningIcon from '@mui/icons-material/WarningAmberRounded';
import LocationIcon from '@mui/icons-material/LocationOnRounded';
import DeleteIcon from '@mui/icons-material/DeleteRounded';
import CoverageIcon from '@mui/icons-material/MapRounded';
// Catalog — nurse services surface + the customer Home service-category grid (f4/b5)
import ServicesIcon from '@mui/icons-material/LocalOfferRounded';
import CategoryIcon from '@mui/icons-material/CategoryRounded';
import ElderlyIcon from '@mui/icons-material/ElderlyRounded';
import PostSurgeryIcon from '@mui/icons-material/HealingRounded';
import InfantIcon from '@mui/icons-material/ChildCareRounded';
import ChronicIcon from '@mui/icons-material/MonitorHeartRounded';
import CompanionshipIcon from '@mui/icons-material/VolunteerActivismRounded';
// Verification — nurse trust flow (f5/b6): document upload, credential + identity, re-upload
import UploadIcon from '@mui/icons-material/CloudUploadRounded';
import DocumentIcon from '@mui/icons-material/InsertDriveFileRounded';
import RefreshIcon from '@mui/icons-material/RefreshRounded';
import IdentityIcon from '@mui/icons-material/BadgeRounded';
import LicenseIcon from '@mui/icons-material/WorkspacePremiumRounded';
import PublishIcon from '@mui/icons-material/RocketLaunchRounded';
// Search & discovery — the customer nurse-finding flow (f6/b7): rating star, filter controls
import StarIcon from '@mui/icons-material/StarRounded';
import StarHalfIcon from '@mui/icons-material/StarHalfRounded';
import TuneIcon from '@mui/icons-material/TuneRounded';
import SortIcon from '@mui/icons-material/SortRounded';
// Booking requests — the pre-payment intent flow (f7/b8): nurse inbox + the pay-&-continue handoff
import RequestsIcon from '@mui/icons-material/AssignmentRounded';
import PaymentIcon from '@mui/icons-material/CreditCardRounded';
// Bookings, sessions & EVV — the post-payment engagement (f8/b9): check-in/out, GPS, care instructions
import CheckInIcon from '@mui/icons-material/LoginRounded';
import CheckOutIcon from '@mui/icons-material/LogoutRounded';
import GpsIcon from '@mui/icons-material/MyLocationRounded';
import ScheduleIcon from '@mui/icons-material/ScheduleRounded';
import ClinicalIcon from '@mui/icons-material/HealthAndSafetyRounded';
import MedicationIcon from '@mui/icons-material/MedicationRounded';
import EmergencyIcon from '@mui/icons-material/LocalPhoneRounded';
import PhoneIcon from '@mui/icons-material/PhoneRounded';
import LockIcon from '@mui/icons-material/LockRounded';
import NavigateIcon from '@mui/icons-material/NavigationRounded';
// BNPL — the installment-checkout surface (f11/b12): installment plans + repayment schedule
import InstallmentsIcon from '@mui/icons-material/PaymentsRounded';
// Payouts — the nurse earnings & payout-history surface (f12/b13)
import EarningsIcon from '@mui/icons-material/PaidRounded';
// Reviews & patient care records (f13/b14): visit-note authoring, record tabs, family-ownership banner
import NotesIcon from '@mui/icons-material/NoteAltRounded';
import RoutineIcon from '@mui/icons-material/EventRepeatRounded';
import TasksIcon from '@mui/icons-material/ChecklistRounded';
import HistoryIcon from '@mui/icons-material/HistoryRounded';
import FamilyIcon from '@mui/icons-material/FamilyRestroomRounded';
// Messaging (tickets) & notifications (f14/b15): support inbox + the message-send action
import SupportIcon from '@mui/icons-material/SupportAgentRounded';
import SendIcon from '@mui/icons-material/SendRounded';
// Admin backoffice & partner consoles (f15/b15): config, holidays, audit, alerts, moderation, partners, refunds
import CalendarIcon from '@mui/icons-material/CalendarMonthRounded';
import AuditIcon from '@mui/icons-material/FactCheckRounded';
import AlertsIcon from '@mui/icons-material/NotificationImportantRounded';
import ModerationIcon from '@mui/icons-material/GavelRounded';
import PartnersIcon from '@mui/icons-material/ApartmentRounded';
import RolesIcon from '@mui/icons-material/ManageAccountsRounded';
import RefundsIcon from '@mui/icons-material/CurrencyExchangeRounded';
import DownloadIcon from '@mui/icons-material/FileDownloadRounded';
import ExpandIcon from '@mui/icons-material/ExpandMoreRounded';
import ExternalIcon from '@mui/icons-material/OpenInNewRounded';
import AssignIcon from '@mui/icons-material/AssignmentIndRounded';
// Cross-cutting affordances: back/forward navigation, share/copy, attachments
import BackIcon from '@mui/icons-material/ArrowBackRounded';
import ForwardIcon from '@mui/icons-material/ArrowForwardRounded';
import ShareIcon from '@mui/icons-material/ShareRounded';
import CopyIcon from '@mui/icons-material/ContentCopyRounded';
import AttachmentIcon from '@mui/icons-material/AttachFileRounded';
import LanguageIcon from '@mui/icons-material/TranslateRounded';
// Auth & first-run (ui-phase-3): the onboarding relation fork needs a distinct glyph per option
import FavoriteIcon from '@mui/icons-material/FavoriteRounded';
/*
* MUI Icons are gone. The registry now maps every name onto **Lucide**
* (`lucide-react`) — a single contemporary outline family drawn on a 24px grid
* with round caps/joins, which reads far lighter than the old filled/Rounded
* glyphs at the small sizes a phone-width app actually uses.
*
* Two rules keep the set coherent:
* 1. ONE family. Never import an icon from anywhere but `lucide-react` here,
* and never import a raw icon component anywhere else in the app.
* 2. The mapping is SEMANTIC, not incidental — a name describes the domain
* concept ("verification", "earnings", "coverage") and the glyph is chosen
* to depict that concept, so a rename of the underlying glyph never leaks
* into call sites. Related concepts deliberately share a visual root
* (every trust/verification name is a shield, every money name is a coin or
* card, every clinical name is a pulse/cross).
*
* Adding an icon = one import here + one lowercase key in ICONS below.
*/
import {
Activity,
Archive,
ArrowLeft,
ArrowLeftRight,
ArrowRight,
ArrowUpDown,
Award,
Baby,
Bandage,
Bell,
BellRing,
Building2,
Calendar,
CalendarCheck,
CalendarClock,
CalendarDays,
Camera,
ChevronDown,
ChevronLeft,
ChevronRight,
CircleCheckBig,
CircleHelp,
CircleUserRound,
CircleX,
ClipboardList,
Clock,
CloudUpload,
Coins,
Copy,
CreditCard,
Download,
Ellipsis,
ExternalLink,
FileSearch,
FileText,
Gavel,
HandHeart,
Headset,
Heart,
HeartHandshake,
HeartPulse,
History,
Hourglass,
House,
IdCard,
Info,
Landmark,
Languages,
LayoutDashboard,
LayoutGrid,
ListChecks,
LocateFixed,
Lock,
LogIn,
LogOut,
Map,
MapPin,
Menu,
MonitorSmartphone,
Moon,
Navigation,
NotebookPen,
OctagonAlert,
Paperclip,
Pencil,
PersonStanding,
Phone,
PhoneCall,
Pill,
Plus,
RefreshCw,
Repeat,
Rocket,
Search,
Send,
Settings,
Settings2,
Share2,
Shield,
ShieldCheck,
ShieldPlus,
SlidersHorizontal,
Star,
StarHalf,
Stethoscope,
Sun,
SunMoon,
Tag,
Trash2,
TrendingUp,
TriangleAlert,
Undo2,
UserCog,
UserPlus,
Users,
UsersRound,
Wallet,
X,
} from 'lucide-react';
/**
* List of all available Icon names
@@ -110,108 +126,156 @@ export type IconName = keyof typeof ICONS;
/**
* How to use:
* 1. Import all required React, MUI or other SVG icons into this file.
* 2. Add icons with "unique lowercase names" into ICONS object. Lowercase is a must!
* 3. Use icons everywhere in the App by their names in <Icon icon="xxx" /> component
* 1. Import the Lucide icon into this file.
* 2. Add it with a "unique lowercase name" to the ICONS object. Lowercase is a must!
* 3. Use it everywhere in the app by name: <AppIcon icon="xxx" />
* Important: properties of ICONS object MUST be lowercase!
* Note: You can use camelCase or UPPERCASE in the <Icon icon="someIconByName" /> component
* Note: You can use camelCase or UPPERCASE in the <AppIcon icon="someIconByName" /> component
*/
export const ICONS /* Note: Setting type disables property autocomplete :( was - : Record<string, ComponentType> */ = {
default: DefaultIcon,
export const ICONS = {
// ── Chrome & navigation ───────────────────────────────────────────────
default: Ellipsis,
logo: LogoMark,
close: CloseIcon,
menu: MenuIcon,
search: SearchIcon,
info: InfoIcon,
home: HomeIcon,
account: AccountCircleIcon,
logout: ExitToAppIcon,
notifications: NotificationsIcon,
error: DangerousIcon,
bookings: EventNoteIcon,
patients: GroupsIcon,
users: PeopleAltIcon,
wallet: WalletIcon,
profile: AccountCircleIcon,
dashboard: DashboardIcon,
verification: VerifiedUserIcon,
verified: CheckCircleIcon,
pending: HourglassEmptyIcon,
rejected: CancelIcon,
visits: MedicalServicesIcon,
admin: AdminPanelSettingsIcon,
add: AddIcon,
edit: EditIcon,
archive: ArchiveIcon,
bank: BankIcon,
camera: CameraIcon,
warning: WarningIcon,
location: LocationIcon,
delete: DeleteIcon,
coverage: CoverageIcon,
services: ServicesIcon,
category: CategoryIcon,
elderly: ElderlyIcon,
post_surgery: PostSurgeryIcon,
infant: InfantIcon,
chronic: ChronicIcon,
companionship: CompanionshipIcon,
upload: UploadIcon,
document: DocumentIcon,
refresh: RefreshIcon,
identity: IdentityIcon,
license: LicenseIcon,
publish: PublishIcon,
star: StarIcon,
star_half: StarHalfIcon,
tune: TuneIcon,
sort: SortIcon,
requests: RequestsIcon,
payment: PaymentIcon,
check_in: CheckInIcon,
check_out: CheckOutIcon,
gps: GpsIcon,
schedule: ScheduleIcon,
clinical: ClinicalIcon,
medication: MedicationIcon,
emergency: EmergencyIcon,
phone: PhoneIcon,
lock: LockIcon,
navigate: NavigateIcon,
installments: InstallmentsIcon,
earnings: EarningsIcon,
notes: NotesIcon,
routine: RoutineIcon,
tasks: TasksIcon,
history: HistoryIcon,
family: FamilyIcon,
support: SupportIcon,
send: SendIcon,
config: TuneIcon,
calendar: CalendarIcon,
audit: AuditIcon,
alerts: AlertsIcon,
moderation: ModerationIcon,
partners: PartnersIcon,
roles: RolesIcon,
refunds: RefundsIcon,
download: DownloadIcon,
expand: ExpandIcon,
external: ExternalIcon,
assign: AssignIcon,
back: BackIcon,
chevron_start: BackIcon,
forward: ForwardIcon,
share: ShareIcon,
copy: CopyIcon,
attachment: AttachmentIcon,
language: LanguageIcon,
favorite: FavoriteIcon,
close: X,
menu: Menu,
more: Ellipsis,
search: Search,
info: Info,
help: CircleHelp,
home: House,
account: CircleUserRound,
logout: LogOut,
notifications: Bell,
error: OctagonAlert,
settings: Settings,
// ── Actor destinations ────────────────────────────────────────────────
bookings: CalendarDays,
patients: HeartHandshake, // «حلقهٔ مراقبت» — a care circle, not a patient list
users: UsersRound,
wallet: Wallet,
profile: CircleUserRound,
dashboard: LayoutDashboard,
today: CalendarCheck,
practice: Stethoscope, // the nurse's «حرفهٔ من» group root
visits: Stethoscope,
admin: Shield,
// ── Trust & verification (all shields — one visual root) ──────────────
verification: ShieldCheck,
verified: CircleCheckBig,
pending: Hourglass,
rejected: CircleX,
identity: IdCard,
license: Award,
// ── Common actions ────────────────────────────────────────────────────
add: Plus,
edit: Pencil,
archive: Archive,
delete: Trash2,
refresh: RefreshCw,
upload: CloudUpload,
download: Download,
document: FileText,
publish: Rocket,
camera: Camera,
warning: TriangleAlert,
// ── Geography & coverage ──────────────────────────────────────────────
location: MapPin,
coverage: Map,
gps: LocateFixed,
navigate: Navigation,
// ── Catalog — the customer Home category grid (f4/b5) ─────────────────
services: Tag,
category: LayoutGrid,
elderly: PersonStanding,
post_surgery: Bandage,
infant: Baby,
chronic: HeartPulse,
companionship: HandHeart,
// ── Search & discovery ────────────────────────────────────────────────
star: Star,
star_half: StarHalf,
tune: SlidersHorizontal,
sort: ArrowUpDown,
// ── Booking requests, sessions & EVV ──────────────────────────────────
requests: ClipboardList,
check_in: LogIn,
check_out: LogOut,
schedule: Clock,
clinical: ShieldPlus,
medication: Pill,
emergency: PhoneCall,
phone: Phone,
lock: Lock,
// ── Money (coins/cards — one visual root) ─────────────────────────────
payment: CreditCard,
installments: CalendarClock,
earnings: Coins,
bank: Landmark,
refunds: Undo2,
trending: TrendingUp,
// ── Reviews & patient care records ────────────────────────────────────
notes: NotebookPen,
routine: Repeat,
tasks: ListChecks,
history: History,
family: Users,
activity: Activity,
// ── Messaging & support ───────────────────────────────────────────────
support: Headset,
send: Send,
// ── Admin backoffice & partner consoles ───────────────────────────────
config: Settings2,
calendar: Calendar,
audit: FileSearch,
alerts: BellRing,
moderation: Gavel,
partners: Building2,
roles: UserCog,
assign: UserPlus,
// ── Cross-cutting affordances ─────────────────────────────────────────
back: ArrowLeft,
forward: ArrowRight,
chevron_start: ChevronLeft,
chevron_end: ChevronRight,
expand: ChevronDown,
external: ExternalLink,
share: Share2,
copy: Copy,
attachment: Paperclip,
switch: ArrowLeftRight,
// ── Settings surface ──────────────────────────────────────────────────
language: Languages,
appearance: SunMoon,
light_mode: Sun,
dark_mode: Moon,
/** "Follow the operating system" — the third theme choice, not a device list. */
system_mode: MonitorSmartphone,
favorite: Heart,
};
/**
* Icons authored for LTR that must mirror horizontally under RTL (back arrows,
* chevrons pointing "start"). AppIcon applies the flip via a `data-icon-directional`
* attribute + the single CSS rule in globals.css — add a name here, nothing else.
* chevrons pointing "start"/"end", the send paper-plane). AppIcon applies the flip via a
* `data-icon-directional` attribute + the single CSS rule in globals.css — add a
* name here, nothing else.
*/
export const DIRECTIONAL_ICONS = new Set<IconName>(['back', 'chevron_start', 'forward', 'send']);
export const DIRECTIONAL_ICONS = new Set<IconName>([
'back',
'chevron_start',
'chevron_end',
'forward',
'send',
]);
@@ -11,8 +11,15 @@ import { IconProps } from '../utils';
* LogoLockup.tsx instead.
* @component LogoMark
*/
const LogoMark: FunctionComponent<IconProps> = ({ color = 'currentColor', ...props }) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" {...props}>
const LogoMark: FunctionComponent<IconProps> = ({
color = 'currentColor',
size = 24,
// Swallowed on purpose: the registry hands every icon Lucide's stroke weight, and this mark is
// solid-fill — letting it through would emit a meaningless stroke-width attribute.
strokeWidth: _strokeWidth,
...props
}) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width={size} height={size} {...props}>
<path
fill={color}
fillRule="evenodd"
@@ -1,11 +1,16 @@
import { SVGAttributes } from 'react';
/**
* Props to use with custom SVG icons, similar to AppIcon's Props
* Props a custom SVG icon must accept to sit in the same registry as the Lucide set.
* Mirrors Lucide's own `LucideProps` shape (`size` drives width/height, `color` paints,
* `strokeWidth` is accepted and may be ignored by a solid-fill mark) so `AppIcon` can
* render every registry entry through one uniform call.
*/
export interface IconProps extends SVGAttributes<SVGElement> {
export interface IconProps extends Omit<SVGAttributes<SVGElement>, 'color'> {
color?: string;
icon?: string;
size?: string | number;
/** Accepted for contract parity with Lucide; a solid-fill brand mark ignores it. */
strokeWidth?: string | number;
title?: string;
}
@@ -38,8 +38,7 @@ describe('<AppIconButton/> component', () => {
expect(svg).toHaveAttribute('data-icon', 'default'); // default icon
expect(svg).toHaveAttribute('height', String(APP_ICON_SIZE)); // default size when .size is not set
expect(svg).toHaveAttribute('width', String(APP_ICON_SIZE)); // default size when .size is not se
// Size is actually driven by fontSize, not a `size` DOM attribute (invalid on <svg>).
expect(svg).toHaveStyle(`font-size: ${APP_ICON_SIZE}px`);
// Lucide sizes off the width/height attributes above — no fontSize/1em indirection left to assert.
});
it('supports .color property', () => {
@@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import NavHubList from './NavHubList';
const ITEMS = [
{ title: 'Earnings', subtitle: 'What you are owed', icon: 'earnings', path: '/nurse/earnings' },
{ title: 'Bank', icon: 'bank', path: '/nurse/bank', badgeCount: 3 },
];
function renderList() {
return render(
<NextIntlClientProvider locale="en" messages={{}}>
<NavHubList items={ITEMS} title="Money" />
</NextIntlClientProvider>
);
}
describe('<NavHubList/> component', () => {
it('renders one link per item, locale-prefixed', () => {
renderList();
expect(screen.getByRole('link', { name: /Earnings/ })).toHaveAttribute('href', '/en/nurse/earnings');
expect(screen.getByRole('link', { name: /Bank/ })).toHaveAttribute('href', '/en/nurse/bank');
});
it('renders the section title and item subtitles', () => {
renderList();
expect(screen.getByText('Money')).toBeInTheDocument();
expect(screen.getByText('What you are owed')).toBeInTheDocument();
});
it('shows a badge only for an item that has a count', () => {
renderList();
// A zero/absent count must not render a stray dot next to the glyph.
expect(screen.getByText('3')).toBeInTheDocument();
});
});
@@ -0,0 +1,97 @@
'use client';
import { FunctionComponent, ReactNode } from 'react';
import { Badge, Box, Divider, Link as MuiLink, Stack, Typography } from '@mui/material';
import AppIcon from '@/components/common/AppIcon';
import SurfaceCard from '@/components/common/SurfaceCard';
import { Link } from '@/i18n/navigation';
export interface NavHubItem {
title: string;
/** One line of orientation — what the destination is for, not a restatement of the title. */
subtitle?: string;
icon?: string;
/** Locale-less `ROUTES.*` path; the locale-aware `Link` adds the prefix. */
path: string;
/** Unread/pending count rendered on the glyph. */
badgeCount?: number;
/** A short trailing value (a status chip, a count) shown before the chevron. */
meta?: ReactNode;
}
interface Props {
items: Array<NavHubItem>;
/** Optional section label rendered above the card. */
title?: string;
}
const GLYPH_BOX = 36;
/**
* The grouped list of destinations a group-root ("hub") page is built from — the surface that
* replaced the sidebar. A drawer showed every destination in the app at once behind a hamburger;
* a hub shows one area's destinations in place, with room for the subtitle and status the drawer
* never had.
* @component NavHubList
*/
const NavHubList: FunctionComponent<Props> = ({ items, title }) => (
<Stack sx={{ gap: 1 }}>
{title ? (
<Typography variant="overline" sx={{ color: 'text.secondary', paddingInlineStart: 0.5 }}>
{title}
</Typography>
) : null}
<SurfaceCard sx={{ p: 0, overflow: 'hidden' }}>
<Stack divider={<Divider />}>
{items.map((item) => (
<MuiLink
key={item.path}
component={Link}
href={item.path}
color="inherit"
underline="none"
sx={{ display: 'block', '&:hover': { bgcolor: 'var(--bal-primary-soft)' } }}
>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5, px: 2, py: 1.5, minWidth: 0 }}>
<Badge
badgeContent={item.badgeCount ?? 0}
max={99}
invisible={!item.badgeCount}
sx={{ '& .MuiBadge-badge': { bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)' } }}
>
<Box
sx={{
width: GLYPH_BOX,
height: GLYPH_BOX,
display: 'grid',
placeItems: 'center',
borderRadius: 'var(--bal-radius-sm)',
bgcolor: 'var(--bal-primary-soft)',
}}
>
<AppIcon icon={item.icon} size={18} color="var(--bal-primary)" />
</Box>
</Badge>
<Stack sx={{ gap: 0.125, minWidth: 0, flexGrow: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.title}
</Typography>
{item.subtitle ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{item.subtitle}
</Typography>
) : null}
</Stack>
{item.meta}
<AppIcon icon="chevron_end" size={18} color="var(--bal-text-secondary)" aria-hidden="true" />
</Stack>
</MuiLink>
))}
</Stack>
</SurfaceCard>
</Stack>
);
export default NavHubList;
@@ -0,0 +1,4 @@
import NavHubList from './NavHubList';
export type { NavHubItem } from './NavHubList';
export { NavHubList as default, NavHubList };
@@ -12,10 +12,23 @@ import { usePathname } from '@/i18n/navigation';
* `globals.css` handles that for every consumer, this one included.
* @component RouteFadeIn
*/
const RouteFadeIn: FunctionComponent<PropsWithChildren> = ({ children }) => {
interface RouteFadeInProps extends PropsWithChildren {
/**
* Makes the animated box a full-height flex column. The chrome-free shells (auth, onboarding)
* vertically center their card against the frame, which a plain auto-height wrapper in between
* would silently break.
*/
fill?: boolean;
}
const RouteFadeIn: FunctionComponent<RouteFadeInProps> = ({ children, fill }) => {
const pathname = usePathname();
return (
<Box key={pathname} data-bal-route-fade sx={{ minWidth: 0 }}>
<Box
key={pathname}
data-bal-route-fade
sx={{ minWidth: 0, ...(fill && { flexGrow: 1, display: 'flex', flexDirection: 'column' }) }}
>
{children}
</Box>
);
+3
View File
@@ -23,6 +23,7 @@ import Pager from './Pager';
import InitialsAvatar from './InitialsAvatar';
import FormDialogShell from './FormDialogShell';
import RouteFadeIn from './RouteFadeIn';
import NavHubList from './NavHubList';
export {
ErrorBoundary,
@@ -50,6 +51,7 @@ export {
InitialsAvatar,
FormDialogShell,
RouteFadeIn,
NavHubList,
};
export type { EmptyStateProps } from './EmptyState';
export type { ErrorStateProps } from './ErrorState';
@@ -67,3 +69,4 @@ export type { StickyActionBarProps } from './StickyActionBar';
export type { PagerProps } from './Pager';
export type { InitialsAvatarProps } from './InitialsAvatar';
export type { FormDialogShellProps } from './FormDialogShell';
export type { NavHubItem } from './NavHubList';