manual improvement 2 & add telegram bot
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { Box, Stack } from '@mui/material';
|
||||
import { APP_FRAME_MAX_WIDTH } from './config';
|
||||
import { APP_FRAME_MAX_WIDTH, BOTTOM_NAV_HEIGHT, FLOATING_BAR_SX, TOP_CHROME_HEIGHT } from './config';
|
||||
|
||||
/**
|
||||
* The gutter between a floating chrome bar and the frame edge, and the gap between it and the
|
||||
* content it floats over. Header and footer are mirror images of each other, so they share both.
|
||||
*/
|
||||
const BAR_INLINE_GUTTER = 1.5;
|
||||
const BAR_OUTER_GAP = '10px';
|
||||
const BAR_CONTENT_GAP = 0.5;
|
||||
|
||||
interface AppFrameProps {
|
||||
/** Pinned to the top of the frame; never scrolls with the content. */
|
||||
@@ -13,17 +20,25 @@ interface AppFrameProps {
|
||||
|
||||
/**
|
||||
* The one device frame every shell renders inside — a centered, phone-width column on a canvas
|
||||
* that absorbs whatever extra viewport there is. Three structural jobs, and it is the only place
|
||||
* that absorbs whatever extra viewport there is. Four structural jobs, and it is the only place
|
||||
* any of them are solved:
|
||||
*
|
||||
* 1. **Width is capped everywhere.** No shell stretches a header, a nav bar, or a content column
|
||||
* across a desktop monitor; a wide window gets more canvas, not a wider app.
|
||||
* 2. **The frame owns the scroll, not the document.** Header and footer are flex siblings of a
|
||||
* single scrolling `<main>`, so the top bar needs no `position: fixed` and no page needs a
|
||||
* matching top offset (the source of the old per-shell `pt: TOP_BAR_*` math).
|
||||
* 2. **The frame owns the scroll, not the document.** A single scrolling `<main>` fills the frame;
|
||||
* header and footer are pinned over it and reserve their own space through `<main>`'s padding,
|
||||
* so no page needs a top offset of its own (the old per-shell `pt: TOP_BAR_*` math).
|
||||
* 3. **Horizontal scroll is structurally impossible.** `overflowX: hidden` + `minWidth: 0` on the
|
||||
* column means an over-wide child clips instead of dragging the whole app sideways; anything
|
||||
* genuinely wide (a data table) scrolls inside its own container.
|
||||
* 4. **On a window wide enough to show it, the frame reads as one object.** Above `sm` it lifts
|
||||
* off the canvas as a rounded, shadowed card with a gutter all round. On a phone it still fills
|
||||
* the viewport edge to edge — there is no canvas to float on.
|
||||
*
|
||||
* The chrome is pinned with `position: absolute` against this frame rather than `position: fixed`
|
||||
* against the viewport, which is the only way it can stay inside a centered, floating column — a
|
||||
* viewport-fixed bar would break out and span the whole window. The frame never scrolls (only
|
||||
* `<main>` does), so the two are visually identical on a phone, where the frame *is* the viewport.
|
||||
* @layout AppFrame
|
||||
*/
|
||||
const AppFrame: FunctionComponent<AppFrameProps> = ({ header, footer, children }) => (
|
||||
@@ -32,27 +47,49 @@ const AppFrame: FunctionComponent<AppFrameProps> = ({ header, footer, children }
|
||||
height: '100dvh',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
// The gutter that turns the column into a floating card; zero on a phone, where the frame IS
|
||||
// the viewport. `box-sizing: border-box` (globals.css) keeps the inner `height: 100%` honest.
|
||||
p: { xs: 0, sm: 3 },
|
||||
bgcolor: 'var(--bal-frame-canvas)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxWidth: APP_FRAME_MAX_WIDTH,
|
||||
minWidth: 0,
|
||||
height: '100%',
|
||||
bgcolor: 'background.default',
|
||||
// Reads as a hairline seam between app and canvas on a wide window; invisible on a phone,
|
||||
// where the frame already fills the viewport edge to edge.
|
||||
borderInline: '1px solid',
|
||||
borderColor: 'divider',
|
||||
border: { xs: 'none', sm: '1px solid' },
|
||||
borderColor: { sm: 'divider' },
|
||||
borderRadius: { xs: 0, sm: 'var(--bal-radius-lg)' },
|
||||
boxShadow: { xs: 'none', sm: 'var(--bal-shadow-3)' },
|
||||
// Load-bearing with the radius above — the pinned header and the scrolling <main> both paint
|
||||
// to the frame's edge and would otherwise square off its rounded corners.
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* The header is the bottom nav mirrored: the same inset, the same pill radius, the same
|
||||
border and elevation, floating over the content rather than capping it. `pointerEvents`
|
||||
hands the transparent gutter around it back to the content scrolling underneath. */}
|
||||
{header ? (
|
||||
<Box component="header" sx={{ flexShrink: 0, minWidth: 0 }}>
|
||||
{header}
|
||||
<Box
|
||||
component="header"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
insetInline: 0,
|
||||
top: 0,
|
||||
zIndex: 3,
|
||||
minWidth: 0,
|
||||
pointerEvents: 'none',
|
||||
paddingInline: BAR_INLINE_GUTTER,
|
||||
paddingBlockStart: `calc(env(safe-area-inset-top) + ${BAR_OUTER_GAP})`,
|
||||
paddingBlockEnd: BAR_CONTENT_GAP,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ ...FLOATING_BAR_SX, pointerEvents: 'auto', minWidth: 0 }}>{header}</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
@@ -64,6 +101,16 @@ const AppFrame: FunctionComponent<AppFrameProps> = ({ header, footer, children }
|
||||
minWidth: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
// How much of the scrollport each pinned bar covers, published as custom properties so a
|
||||
// `position: sticky` element anywhere in the tree can clear the chrome without importing a
|
||||
// constant or knowing which shell it is in. Both resolve to 0px in a chrome-free shell
|
||||
// (FocusedLayout, PublicLayout), which is why sticky consumers can read them unconditionally.
|
||||
'--bal-chrome-top': header ? `calc(${TOP_CHROME_HEIGHT}px + env(safe-area-inset-top))` : '0px',
|
||||
'--bal-chrome-bottom': footer ? `calc(${BOTTOM_NAV_HEIGHT}px + env(safe-area-inset-bottom))` : '0px',
|
||||
// Reserves the exact height of each pinned bar, so no screen ever has content hidden
|
||||
// behind the header or the nav and no page has to know either bar exists.
|
||||
paddingBlockStart: 'var(--bal-chrome-top)',
|
||||
paddingBlockEnd: 'var(--bal-chrome-bottom)',
|
||||
// Keeps a rubber-band scroll at the end of a list from chaining out to the canvas.
|
||||
overscrollBehaviorY: 'contain',
|
||||
}}
|
||||
@@ -72,8 +119,11 @@ const AppFrame: FunctionComponent<AppFrameProps> = ({ header, footer, children }
|
||||
</Box>
|
||||
|
||||
{footer ? (
|
||||
<Box component="footer" sx={{ flexShrink: 0, minWidth: 0 }}>
|
||||
{footer}
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{ position: 'absolute', insetInline: 0, bottom: 0, zIndex: 3, minWidth: 0, pointerEvents: 'none' }}
|
||||
>
|
||||
<Box sx={{ pointerEvents: 'auto' }}>{footer}</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
@@ -4,19 +4,30 @@ import { useTranslations } from 'next-intl';
|
||||
import { NotificationBell } from '@/components/notifications';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { LinkToPage } from '@/utils';
|
||||
import { useNurseRequestInbox } from '@/services/bookingRequests';
|
||||
import { useSupportUnreadTotal } from '@/services/tickets';
|
||||
import MobileShell from './MobileShell';
|
||||
|
||||
/**
|
||||
* Nurse app shell — the "نمای پرستار" workspace. Four bottom-nav destinations, one per group the
|
||||
* sidebar used to hide behind a hamburger: امروز (the operational home), حرفهٔ من, مالی, and a
|
||||
* بیشتر hub carrying support, notifications and settings. The identity card that lived in the
|
||||
* drawer header now opens from that hub, where it can be read rather than glanced at.
|
||||
* Nurse app shell — the "نمای پرستار" workspace. Five bottom-nav destinations: امروز (the
|
||||
* operational home), درخواستها, حرفهٔ من, مالی, and a بیشتر hub carrying support, notifications and
|
||||
* settings.
|
||||
*
|
||||
* **درخواستها is a tab, not a link.** It was previously reachable only from a strip on the
|
||||
* dashboard — which meant the one screen in the whole nurse app with a *deadline* on it (a pending
|
||||
* request expires unanswered) lived one tab-plus-a-scroll away, and had no way to signal that
|
||||
* something was waiting from anywhere else in the app. As a tab it carries the pending count as a
|
||||
* badge, so the nurse sees the queue filling up from any screen. The inbox query is the same cached
|
||||
* one the dashboard strip and the inbox page already read — no extra request.
|
||||
*
|
||||
* Identity lives in the «بیشتر» hub only — never in the top bar. One tap from any screen via the
|
||||
* nav is close enough for a preference-and-account surface, and the header stays a title bar.
|
||||
* @layout NurseLayout
|
||||
*/
|
||||
const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
const t = useTranslations('nav');
|
||||
const supportUnreadTotal = useSupportUnreadTotal();
|
||||
const { data: pendingRequests } = useNurseRequestInbox();
|
||||
|
||||
const tabs: Array<LinkToPage> = useMemo(
|
||||
() => [
|
||||
@@ -25,6 +36,12 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
path: ROUTES.NURSE,
|
||||
icon: 'today',
|
||||
},
|
||||
{
|
||||
title: t('requests'),
|
||||
path: ROUTES.NURSE_REQUESTS,
|
||||
icon: 'requests',
|
||||
badgeCount: pendingRequests?.total || undefined,
|
||||
},
|
||||
{
|
||||
title: t('group_profession'),
|
||||
path: ROUTES.NURSE_PRACTICE,
|
||||
@@ -50,7 +67,7 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
badgeCount: supportUnreadTotal ?? undefined,
|
||||
},
|
||||
],
|
||||
[t, supportUnreadTotal]
|
||||
[t, supportUnreadTotal, pendingRequests]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -29,9 +29,12 @@ function renderBar(at: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The single active tab, read off the aria-current the bar stamps on it. */
|
||||
/**
|
||||
* The single active tab, read off the aria-current the bar stamps on it. The bar is icon-only, so
|
||||
* the tab's name lives in its `aria-label` — which is exactly the thing a screen-reader user hears.
|
||||
*/
|
||||
function activeTabName(): string | undefined {
|
||||
return screen.queryByRole('button', { current: 'page' })?.textContent ?? undefined;
|
||||
return screen.queryByRole('button', { current: 'page' })?.getAttribute('aria-label') ?? undefined;
|
||||
}
|
||||
|
||||
describe('<BottomBar/> component', () => {
|
||||
@@ -70,9 +73,16 @@ describe('<BottomBar/> component', () => {
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is icon-only but keeps every tab nameable', () => {
|
||||
renderBar('/nurse');
|
||||
// The visible caption is gone; the accessible name is not.
|
||||
expect(screen.getByRole('navigation')).not.toHaveTextContent('Practice');
|
||||
TABS.forEach((tab) => expect(screen.getByRole('button', { name: tab.title })).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('floats as a rounded, elevated bar rather than an edge-to-edge slab', () => {
|
||||
renderBar('/nurse');
|
||||
const bar = screen.getByRole('navigation').firstElementChild as HTMLElement;
|
||||
const bar = screen.getByRole('navigation');
|
||||
expect(bar).toHaveStyle('border-radius: var(--bal-radius-pill)');
|
||||
expect(bar).toHaveStyle('box-shadow: var(--bal-shadow-2)');
|
||||
});
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useCallback, useMemo } from 'react';
|
||||
import { Badge, Box, ButtonBase, Stack, Typography } from '@mui/material';
|
||||
import { Badge, ButtonBase, Stack } from '@mui/material';
|
||||
import { LinkToPage } from '@/utils';
|
||||
import { AppIcon } from '@/components';
|
||||
import { usePathname, useRouter } from '@/i18n/navigation';
|
||||
import { FLOATING_BAR_SX } from '../config';
|
||||
import { matchActivePath } from '../matchActivePath';
|
||||
|
||||
interface Props {
|
||||
items: Array<LinkToPage>;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 22;
|
||||
/** The active pill behind the icon — wide enough to read as a target, short enough to stay a pill. */
|
||||
const PILL_WIDTH = 52;
|
||||
const PILL_HEIGHT = 28;
|
||||
const ICON_SIZE = 23;
|
||||
/** Comfortable-touch size for an icon-only target (WCAG 2.5.8 asks for ≥24px; 44px is the iOS/Material ask). */
|
||||
const TAB_SIZE = 44;
|
||||
|
||||
/**
|
||||
* The app's only navigation surface: a bottom tab bar carrying one destination per top-level
|
||||
* area. It replaced the drawer + hamburger outright — on a phone-width frame a drawer hides the
|
||||
* whole information architecture behind a tap and gives the top bar a job it doesn't need.
|
||||
*
|
||||
* Each tab is a `ButtonBase` (not MUI's `BottomNavigation`) so the active state can be a pill
|
||||
* that fills in behind the icon rather than a color-only swap: the shape change is what makes
|
||||
* the selection legible at a glance. The transition reads through `--bal-motion-fast`, so the
|
||||
* app-wide reduced-motion gate (globals.css) already collapses it — no local branch.
|
||||
* **Icon-only.** The caption under each icon is gone: at five tabs inside a 480px frame the
|
||||
* Persian labels («حرفهٔ من», «درخواستها») were the widest thing in the bar and forced a two-line
|
||||
* bar to carry one line of meaning. The icon set is already distinct, every tab keeps its label as
|
||||
* `aria-label`/`title`, and the active tab is unambiguous from the filled pill — so the text was
|
||||
* paying rent in the one place vertical space is scarcest.
|
||||
*
|
||||
* Each tab is a `ButtonBase` (not MUI's `BottomNavigation`) so the whole target IS the indicator: a
|
||||
* fixed-size circle that hover, press, focus ring and the active fill all share, rather than a
|
||||
* rectangular ripple squaring off against the rounded bar behind it. Circles are laid out with
|
||||
* `space-around` instead of `flex: 1` so the target keeps one size whatever the tab count is. The
|
||||
* transition reads through `--bal-motion-fast`, so the app-wide reduced-motion gate (globals.css)
|
||||
* already collapses it.
|
||||
*
|
||||
* Locale-aware via the `@/i18n/navigation` wrapper (no manual `/${locale}` prefixing), with the
|
||||
* shared `matchActivePath` longest-prefix helper deciding the active tab, so a nested route
|
||||
@@ -58,88 +66,57 @@ const BottomBar: FunctionComponent<Props> = ({ items }) => {
|
||||
return (
|
||||
// The bar floats: it is inset from the frame edges and fully rounded, so the page background
|
||||
// runs behind and around it instead of the bar sealing off the bottom of the screen with an
|
||||
// edge-to-edge slab. It is still a flex sibling of the scrolling <main> (AppFrame), not an
|
||||
// overlay — content is never hidden underneath it and no screen needs bottom padding for it.
|
||||
<Box
|
||||
// edge-to-edge slab. It is pinned over the scrolling <main> by AppFrame, which reserves exactly
|
||||
// `BOTTOM_NAV_HEIGHT` of bottom padding so no screen's content ever ends up underneath it.
|
||||
<Stack
|
||||
component="nav"
|
||||
direction="row"
|
||||
sx={{
|
||||
px: 1.5,
|
||||
pt: 0.5,
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom) + 10px)',
|
||||
mx: 1.5,
|
||||
mt: 0.5,
|
||||
marginBottom: 'calc(env(safe-area-inset-bottom) + 10px)',
|
||||
p: 0.75,
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
...FLOATING_BAR_SX,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
px: 0.75,
|
||||
py: 1,
|
||||
gap: 0.25,
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 'var(--bal-radius-pill)',
|
||||
boxShadow: 'var(--bal-shadow-2)',
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isActive = Boolean(item.path) && item.path === activePath;
|
||||
return (
|
||||
<ButtonBase
|
||||
key={`${item.title}-${item.path}`}
|
||||
onClick={() => onSelect(item)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: 'column',
|
||||
gap: 0.25,
|
||||
py: 0.25,
|
||||
// Matches the bar's own shape so the ripple/focus ring never squares off a
|
||||
// corner against the rounded container behind it.
|
||||
borderRadius: 'var(--bal-radius-pill)',
|
||||
}}
|
||||
{items.map((item) => {
|
||||
const isActive = Boolean(item.path) && item.path === activePath;
|
||||
return (
|
||||
<ButtonBase
|
||||
key={`${item.title}-${item.path}`}
|
||||
onClick={() => onSelect(item)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
aria-label={item.title}
|
||||
title={item.title}
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
width: TAB_SIZE,
|
||||
height: TAB_SIZE,
|
||||
borderRadius: '50%',
|
||||
bgcolor: isActive ? 'var(--bal-primary-soft)' : 'transparent',
|
||||
transition: 'background-color var(--bal-motion-fast) var(--bal-easing-standard)',
|
||||
'&:hover': { bgcolor: 'var(--bal-primary-soft)' },
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
badgeContent={item.badgeCount ?? 0}
|
||||
max={99}
|
||||
invisible={!item.badgeCount}
|
||||
sx={{ '& .MuiBadge-badge': { bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)' } }}
|
||||
>
|
||||
<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: PILL_WIDTH,
|
||||
height: PILL_HEIGHT,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
borderRadius: 'var(--bal-radius-pill)',
|
||||
bgcolor: isActive ? 'var(--bal-primary-soft)' : 'transparent',
|
||||
transition: 'background-color var(--bal-motion-fast) var(--bal-easing-standard)',
|
||||
}}
|
||||
>
|
||||
<AppIcon
|
||||
icon={item.icon}
|
||||
size={ICON_SIZE}
|
||||
color={isActive ? 'var(--bal-primary)' : 'var(--bal-text-secondary)'}
|
||||
strokeWidth={isActive ? 2.1 : 1.75}
|
||||
/>
|
||||
</Box>
|
||||
</Badge>
|
||||
<Typography
|
||||
variant="caption"
|
||||
noWrap
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
color: isActive ? 'var(--bal-primary)' : 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</Typography>
|
||||
</ButtonBase>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
<AppIcon
|
||||
icon={item.icon}
|
||||
size={ICON_SIZE}
|
||||
color={isActive ? 'var(--bal-primary)' : 'var(--bal-text-secondary)'}
|
||||
strokeWidth={isActive ? 2.2 : 1.75}
|
||||
/>
|
||||
</Badge>
|
||||
</ButtonBase>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -15,7 +15,30 @@ export const APP_FRAME_MAX_WIDTH = 480;
|
||||
|
||||
/**
|
||||
* TopBar configuration — one height at every viewport (the frame never changes width, so the
|
||||
* old mobile/desktop split had nothing left to switch on). The bar sits *inside* the frame as a
|
||||
* normal flex row rather than `position: fixed`, so no page needs a matching top offset.
|
||||
* old mobile/desktop split had nothing left to switch on).
|
||||
*/
|
||||
export const TOP_BAR_HEIGHT = 56;
|
||||
|
||||
/**
|
||||
* Total vertical space the floating header occupies: the bar is `TOP_BAR_HEIGHT` (56) + a 1px border
|
||||
* each side = 58, plus a 10px gap above it and a 4px gap below. Deliberately the same total as
|
||||
* `BOTTOM_NAV_HEIGHT` — the two bars are the same object mirrored, so they must be the same size.
|
||||
*/
|
||||
export const TOP_CHROME_HEIGHT = 72;
|
||||
|
||||
/**
|
||||
* Total vertical space the floating bottom nav occupies, measured rather than guessed: the pill is
|
||||
* `TAB_SIZE` (44) + its own 6px padding top and bottom + a 1px border each side = 58, plus the 4px
|
||||
* gap above it and the 10px gap below. `AppFrame` reserves exactly this much padding under the
|
||||
* scrolling content, so nothing is ever hidden behind the bar. Keep in sync with `BottomBar`.
|
||||
*/
|
||||
export const BOTTOM_NAV_HEIGHT = 72;
|
||||
|
||||
/** Shared geometry of the two floating chrome bars — one definition, so they cannot drift apart. */
|
||||
export const FLOATING_BAR_SX = {
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 'var(--bal-radius-pill)',
|
||||
boxShadow: 'var(--bal-shadow-2)',
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user