manual improvement 2 & add telegram bot

This commit is contained in:
hamid
2026-07-27 23:58:16 +03:30
parent baa3cc63cd
commit e6a8f93a1e
57 changed files with 4181 additions and 2484 deletions
+64 -14
View File
@@ -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>
+22 -5
View File
@@ -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)');
});
+63 -86
View File
@@ -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 itno 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 pillso 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>
);
};
+25 -2
View File
@@ -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;