ui phase 10

This commit is contained in:
hamid
2026-07-19 17:13:32 +03:30
parent b638e25a0e
commit b4b8c9ea79
48 changed files with 1643 additions and 290 deletions
@@ -214,4 +214,4 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
* 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.
*/
export const DIRECTIONAL_ICONS = new Set<IconName>(['back', 'chevron_start', 'forward']);
export const DIRECTIONAL_ICONS = new Set<IconName>(['back', 'chevron_start', 'forward', 'send']);
@@ -0,0 +1,44 @@
import { render, screen, fireEvent } from '@testing-library/react';
jest.mock('next-intl', () => ({
useTranslations: (namespace: string) => (key: string) => {
const messages = jest.requireActual('../../../messages/fa.json') as Record<string, Record<string, string>>;
return messages[namespace]?.[key] ?? key;
},
}));
import { ThemeProvider } from '../../theme';
import EmergencyPlaybookRow from './EmergencyPlaybookRow';
describe('<EmergencyPlaybookRow/> component', () => {
it('starts collapsed with the playbook body hidden', () => {
render(
<ThemeProvider>
<EmergencyPlaybookRow onOpenTicket={() => {}} />
</ThemeProvider>,
);
expect(screen.getByTestId('emergency-playbook-toggle')).toHaveAttribute('aria-expanded', 'false');
});
it('expands to reveal the playbook body + open-ticket action on tap', () => {
render(
<ThemeProvider>
<EmergencyPlaybookRow onOpenTicket={() => {}} />
</ThemeProvider>,
);
fireEvent.click(screen.getByTestId('emergency-playbook-toggle'));
expect(screen.getByTestId('emergency-playbook-toggle')).toHaveAttribute('aria-expanded', 'true');
});
it('invokes onOpenTicket from the expanded open-ticket action', () => {
const onOpenTicket = jest.fn();
render(
<ThemeProvider>
<EmergencyPlaybookRow onOpenTicket={onOpenTicket} />
</ThemeProvider>,
);
fireEvent.click(screen.getByTestId('emergency-playbook-toggle'));
fireEvent.click(screen.getByRole('button', { name: /پشتیبانی/ }));
expect(onOpenTicket).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,70 @@
'use client';
import { FunctionComponent, useState } from 'react';
import Box from '@mui/material/Box';
import ButtonBase from '@mui/material/ButtonBase';
import Collapse from '@mui/material/Collapse';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { useTranslations } from 'next-intl';
import AppButton from '@/components/common/AppButton';
import { AppIcon } from '@/components/common';
export interface EmergencyPlaybookRowProps {
/** Opens the support-ticket flow ("…then open a ticket"). */
onOpenTicket: () => void;
}
/**
* The ticket-inbox emergency affordance (§3.4) — a compact, neutral, collapsed-by-default row (not the
* alarm-red `EmergencyBanner`, which stays reserved for the surface that actually has a `tel:` contact:
* the nurse's post-confirmation booking detail). Expands to the playbook copy + an "open a ticket" action.
* The inbox never has a phone number to show, so its copy never instructs calling one.
* @component EmergencyPlaybookRow
*/
const EmergencyPlaybookRow: FunctionComponent<EmergencyPlaybookRowProps> = ({ onOpenTicket }) => {
const t = useTranslations('tickets');
const [expanded, setExpanded] = useState(false);
return (
<Box
data-testid="emergency-playbook-row"
sx={{ borderRadius: 2, border: '1px solid', borderColor: 'divider', bgcolor: 'var(--bal-bg-paper)' }}
>
<ButtonBase
onClick={() => setExpanded((current) => !current)}
aria-expanded={expanded}
data-testid="emergency-playbook-toggle"
sx={{ display: 'flex', width: '100%', justifyContent: 'flex-start', p: 1.5, gap: 1, textAlign: 'start' }}
>
<AppIcon icon="info" size={20} color="var(--bal-text-secondary)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700, flexGrow: 1, color: 'var(--bal-text-secondary)' }}>
{t('emergency_row_title')}
</Typography>
<AppIcon
icon="expand"
size={20}
color="var(--bal-text-secondary)"
style={{ transform: expanded ? 'rotate(180deg)' : undefined, transition: 'transform var(--bal-motion-fast) var(--bal-easing-standard)' }}
/>
</ButtonBase>
<Collapse in={expanded}>
<Stack sx={{ gap: 1, px: 1.5, pb: 1.5 }}>
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
{t('emergency_row_body')}
</Typography>
<AppButton
variant="text"
color="primary"
startIcon="support"
onClick={onOpenTicket}
sx={{ alignSelf: 'flex-start' }}
>
{t('emergency_open_ticket')}
</AppButton>
</Stack>
</Collapse>
</Box>
);
};
export default EmergencyPlaybookRow;
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import MessageBubble from './MessageBubble';
import type { TicketMessage } from '@/services/tickets/types';
@@ -13,14 +13,20 @@ const base: TicketMessage = {
sendStatus: 'sent',
};
function renderBubble(overrides: Partial<TicketMessage>) {
function renderBubble(overrides: Partial<TicketMessage>, extra: { showAuthorLabel?: boolean; onRetry?: () => void; onDiscard?: () => void } = {}) {
return render(
<ThemeProvider>
<MessageBubble
message={{ ...base, ...overrides }}
authorLabel="پشتیبانی"
showAuthorLabel={extra.showAuthorLabel ?? true}
timeLabel="۱۰:۰۰"
sendingLabel="در حال ارسال…"
failedLabel="پیام ارسال نشد"
retryLabel="تلاش مجدد"
discardLabel="حذف"
onRetry={extra.onRetry}
onDiscard={extra.onDiscard}
/>
</ThemeProvider>,
);
@@ -35,6 +41,11 @@ describe('<MessageBubble/> component', () => {
expect(screen.getByText('۱۰:۰۰')).toBeInTheDocument();
});
it('hides the author label when it is not the head of its group', () => {
renderBubble({ isMine: false }, { showAuthorLabel: false });
expect(screen.queryByText('پشتیبانی')).not.toBeInTheDocument();
});
it('marks my messages as mine and never shows an author label', () => {
renderBubble({ isMine: true });
expect(screen.getByTestId('message-bubble')).toHaveAttribute('data-mine', 'true');
@@ -46,4 +57,20 @@ describe('<MessageBubble/> component', () => {
expect(screen.getByText('در حال ارسال…')).toBeInTheDocument();
expect(screen.queryByText('۱۰:۰۰')).not.toBeInTheDocument();
});
it('announces a failed send (role="alert") and offers retry + discard in place', () => {
const onRetry = jest.fn();
const onDiscard = jest.fn();
renderBubble(
{ isMine: true, id: null, clientMessageId: 'c1', sendStatus: 'failed' },
{ onRetry, onDiscard },
);
const bubble = screen.getByTestId('message-bubble');
expect(bubble).toHaveAttribute('role', 'alert');
expect(screen.getByText('سلام، ساعت ویزیت را تغییر دهید')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('message-retry'));
expect(onRetry).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByTestId('message-discard'));
expect(onDiscard).toHaveBeenCalledTimes(1);
});
});
@@ -1,37 +1,66 @@
'use client';
import { FunctionComponent } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { AppIcon } from '@/components/common';
import type { TicketMessage } from '@/services/tickets/types';
export interface MessageBubbleProps {
message: TicketMessage;
/** Translated author label (only shown for others' messages — mine is obvious). */
authorLabel: string;
/** Pre-formatted Shamsi time — the caller owns locale (shown once the message is sent). */
/** Renders the author label above the bubble — only the first bubble of a consecutive same-author group. */
showAuthorLabel: boolean;
/** Pre-formatted **hh:mm** — the full date lives on the thread's date separator, not the bubble. */
timeLabel: string;
/** "در حال ارسال…" — shown while an optimistic message is still sending (in place of the time). */
sendingLabel: string;
/** "پیام ارسال نشد" — shown in place of the time when a send has failed (§3.3). */
failedLabel: string;
/** «تلاش مجدد» — re-mutates with the same `clientMessageId` (retry-in-place, never a duplicate bubble). */
retryLabel: string;
/** Discards the failed bubble and restores its text to the composer draft. */
discardLabel: string;
onRetry?: () => void;
onDiscard?: () => void;
}
/**
* One message bubble in a ticket thread. **Mine vs theirs** drives side + color and **mirrors for RTL**
* automatically (`justifyContent: flex-end` resolves to the inline-end — left in RTL). A pending optimistic
* send shows the "sending" state in place of the timestamp; on a send **failure** the bubble is rolled back
* by `usePostMessage` and the draft is retried from the composer (§3.5), so this bubble only ever renders the
* sending/sent states. Purely presentational — the caller supplies the translated author/time strings. Never
* renders any internal-note content or styling (there is no internal message in the user view — §5).
* send shows the "sending" state in place of the timestamp. On **failure** the bubble stays in place
* (`sendStatus: 'failed'`, error-token accented, `role="alert"` so screen readers are told) with a retry
* chip (re-mutates the same `clientMessageId` — never a duplicate) and a discard action that restores the
* typed text to the composer — the invariant that survives any mechanism change: a failure never loses
* typed text. Never renders any internal-note content or styling (there is no internal message in the user
* view — §5).
* @component MessageBubble
*/
const MessageBubble: FunctionComponent<MessageBubbleProps> = ({ message, authorLabel, timeLabel, sendingLabel }) => {
const MessageBubble: FunctionComponent<MessageBubbleProps> = ({
message,
authorLabel,
showAuthorLabel,
timeLabel,
sendingLabel,
failedLabel,
retryLabel,
discardLabel,
onRetry,
onDiscard,
}) => {
const isMine = message.isMine;
const sending = message.sendStatus === 'sending';
const failed = message.sendStatus === 'failed';
return (
<Box
data-testid="message-bubble"
data-mine={isMine ? 'true' : 'false'}
data-send-status={message.sendStatus}
role={failed ? 'alert' : undefined}
sx={{ display: 'flex', justifyContent: isMine ? 'flex-end' : 'flex-start', width: '100%' }}
>
<Stack
@@ -40,17 +69,17 @@ const MessageBubble: FunctionComponent<MessageBubbleProps> = ({ message, authorL
px: 1.5,
py: 1,
borderRadius: 2,
bgcolor: isMine ? 'var(--bal-primary)' : 'var(--bal-bg-paper)',
color: isMine ? 'var(--bal-primary-contrast)' : 'var(--bal-text-primary)',
border: isMine ? 'none' : '1px solid',
borderColor: isMine ? undefined : 'divider',
bgcolor: failed ? 'var(--bal-error-soft)' : isMine ? 'var(--bal-primary)' : 'var(--bal-bg-paper)',
color: failed ? 'var(--bal-text-primary)' : isMine ? 'var(--bal-primary-contrast)' : 'var(--bal-text-primary)',
border: failed ? '1px solid' : isMine ? 'none' : '1px solid',
borderColor: failed ? 'var(--bal-error)' : isMine ? undefined : 'divider',
// Mine leans to the inline-end corner, theirs to the inline-start — a subtle "tail".
borderStartEndRadius: isMine ? 4 : undefined,
borderStartStartRadius: isMine ? undefined : 4,
opacity: sending ? 0.75 : 1,
}}
>
{!isMine ? (
{!isMine && showAuthorLabel ? (
<Typography variant="caption" sx={{ fontWeight: 700, color: 'var(--bal-primary)', mb: 0.25 }}>
{authorLabel}
</Typography>
@@ -60,18 +89,43 @@ const MessageBubble: FunctionComponent<MessageBubbleProps> = ({ message, authorL
{message.body}
</Typography>
<Typography
variant="caption"
sx={{
mt: 0.25,
alignSelf: 'flex-end',
opacity: isMine ? 0.8 : 1,
color: isMine ? 'inherit' : 'var(--bal-text-secondary)',
direction: 'ltr',
}}
>
{sending ? sendingLabel : timeLabel}
</Typography>
{failed ? (
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center', mt: 0.5 }}>
<Typography variant="caption" sx={{ color: 'var(--bal-error)', fontWeight: 700, flexGrow: 1 }}>
{failedLabel}
</Typography>
<Button
onClick={onRetry}
data-testid="message-retry"
size="small"
sx={{ minWidth: 0, p: 0, color: 'var(--bal-error)', fontWeight: 700, textDecoration: 'underline' }}
>
{retryLabel}
</Button>
<IconButton
size="small"
onClick={onDiscard}
aria-label={discardLabel}
data-testid="message-discard"
sx={{ color: 'var(--bal-error)' }}
>
<AppIcon icon="delete" size={16} color="var(--bal-error)" />
</IconButton>
</Stack>
) : (
<Typography
variant="caption"
sx={{
mt: 0.25,
display: 'block',
textAlign: 'end',
opacity: isMine ? 0.8 : 1,
color: isMine ? 'inherit' : 'var(--bal-text-secondary)',
}}
>
{sending ? sendingLabel : timeLabel}
</Typography>
)}
</Stack>
</Box>
);
@@ -1,92 +1,77 @@
'use client';
import { FunctionComponent, KeyboardEvent, useState } from 'react';
import { FunctionComponent, KeyboardEvent } from 'react';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import useMediaQuery from '@mui/material/useMediaQuery';
import { useTranslations } from 'next-intl';
import { AppIcon } from '@/components/common';
import { usePostMessage } from '@/services/tickets';
import { AppIcon, AppIconButton } from '@/components/common';
import { TICKETS_ATTACHMENTS_ENABLED } from '@/services/tickets/constants';
export interface MessageComposerProps {
ticketId: number;
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
sending: boolean;
/** True when the ticket is closed / the user can't post — the input + send are disabled. */
disabled?: boolean;
}
/** A client-generated id so the optimistic bubble reconciles to the server message (never a double-render). */
function makeClientMessageId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID();
return `c-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
/**
* The sticky thread composer. Send is **optimistic** (`usePostMessage`): the bubble appears instantly. The
* draft is **kept until the server confirms** — cleared only in `onSuccess` — so a failure leaves the text in
* place to retry without retyping (the pending bubble is rolled back by the mutation; §3.5). Submit is
* disabled while sending; Enter sends, Shift+Enter newlines.
* The sticky thread composer — a controlled input (the draft lives in `TicketConversationPanel`, which
* owns the send/retry/discard mutation so a failed bubble and the composer share one source of truth).
* **Enter semantics are input-modality aware** (§3.3): on a fine pointer (desktop) Enter sends and
* Shift+Enter inserts a newline; on a coarse pointer (touch) Enter always inserts a newline — the mobile
* shell has no Shift+Enter, so the explicit send button is the only send path there. The attachment
* affordance is designed but rendered only behind `TICKETS_ATTACHMENTS_ENABLED` (off — REQ-060 gate).
* @component MessageComposer
*/
const MessageComposer: FunctionComponent<MessageComposerProps> = ({ ticketId, disabled }) => {
const MessageComposer: FunctionComponent<MessageComposerProps> = ({ value, onChange, onSubmit, sending, disabled }) => {
const t = useTranslations('tickets');
const [draft, setDraft] = useState('');
const postMessage = usePostMessage(ticketId);
const sending = postMessage.isPending;
const submit = () => {
const body = draft.trim();
if (!body || sending || disabled) return;
postMessage.mutate(
{ body, clientMessageId: makeClientMessageId() },
{ onSuccess: () => setDraft('') }, // clear the draft ONLY on server confirm (§3.5)
);
};
const isCoarsePointer = useMediaQuery('(pointer: coarse)');
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (isCoarsePointer) return; // touch keyboards: Enter always inserts a newline; the send button sends
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submit();
onSubmit();
}
};
return (
<Stack sx={{ gap: 0.5 }}>
{postMessage.isError ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }} data-testid="composer-send-failed">
{t('send_failed')}
</Typography>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-end' }}>
{TICKETS_ATTACHMENTS_ENABLED ? (
<AppIconButton icon="attachment" title={t('attach_photo')} disabled={disabled || sending} />
) : null}
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-end' }}>
<TextField
fullWidth
multiline
maxRows={4}
size="small"
placeholder={t('composer_placeholder')}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={onKeyDown}
disabled={disabled}
/>
<IconButton
onClick={submit}
disabled={disabled || sending || draft.trim().length === 0}
aria-label={t('send')}
sx={{
bgcolor: 'var(--bal-primary)',
color: 'var(--bal-primary-contrast)',
'&:hover': { bgcolor: 'var(--bal-primary-dark)' },
'&.Mui-disabled': { bgcolor: 'var(--bal-divider)', color: 'var(--bal-text-secondary)' },
}}
>
{sending ? (
<CircularProgress size={18} sx={{ color: 'var(--bal-primary-contrast)' }} />
) : (
<AppIcon icon="send" size={18} color="var(--bal-primary-contrast)" />
)}
</IconButton>
</Stack>
<TextField
fullWidth
multiline
maxRows={4}
size="small"
placeholder={t('composer_placeholder')}
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={onKeyDown}
disabled={disabled}
/>
<IconButton
onClick={onSubmit}
disabled={disabled || sending || value.trim().length === 0}
aria-label={t('send')}
sx={{
bgcolor: 'var(--bal-primary)',
color: 'var(--bal-primary-contrast)',
'&:hover': { bgcolor: 'var(--bal-primary-dark)' },
'&.Mui-disabled': { bgcolor: 'var(--bal-divider)', color: 'var(--bal-text-secondary)' },
}}
>
{sending ? (
<CircularProgress size={18} sx={{ color: 'var(--bal-primary-contrast)' }} />
) : (
<AppIcon icon="send" size={18} color="var(--bal-primary-contrast)" />
)}
</IconButton>
</Stack>
);
};
@@ -0,0 +1,80 @@
'use client';
import { FunctionComponent, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useTranslations } from 'next-intl';
import { useDiscardFailedMessage, usePostMessage } from '@/services/tickets';
import type { TicketMessage } from '@/services/tickets/types';
import { makeClientMessageId } from './clientMessageId';
import MessageComposer from './MessageComposer';
import TicketMessageList from './TicketMessageList';
export interface TicketConversationPanelProps {
ticketId: number;
/** Closed tickets show a notice instead of the composer. */
closed: boolean;
}
/**
* The interactive body of a thread — the message list + the sticky composer, owning the one
* `usePostMessage` mutation + draft state both share (so a failed bubble's retry/discard and the
* composer's own send are the same optimistic pipeline, never two disconnected ones). Mounted **keyed by
* `ticketId`** from `TicketThreadScreen` so navigating thread→thread (the App Router reuses the `[id]`
* subtree) remounts this whole panel — the draft/in-flight/failed state never crosses tickets (§5).
* @component TicketConversationPanel
*/
const TicketConversationPanel: FunctionComponent<TicketConversationPanelProps> = ({ ticketId, closed }) => {
const t = useTranslations('tickets');
const [draft, setDraft] = useState('');
const postMessage = usePostMessage(ticketId);
const discardFailedMessage = useDiscardFailedMessage();
const sending = postMessage.isPending;
const submit = () => {
const body = draft.trim();
if (!body || sending || closed) return;
postMessage.mutate(
{ body, clientMessageId: makeClientMessageId() },
{ onSuccess: () => setDraft('') }, // clear the draft ONLY on server confirm (§3.5)
);
};
const retry = (message: TicketMessage) => {
if (!message.clientMessageId) return;
postMessage.mutate({ body: message.body, clientMessageId: message.clientMessageId });
};
const discard = (message: TicketMessage) => {
if (!message.clientMessageId) return;
discardFailedMessage(ticketId, message.clientMessageId);
setDraft(message.body); // a failure never loses typed text — restore it for editing (§3.3)
};
return (
<>
<TicketMessageList ticketId={ticketId} onRetry={retry} onDiscard={discard} />
<Box
sx={{
position: 'sticky',
bottom: 0,
bgcolor: 'var(--bal-bg-default)',
borderTop: '1px solid',
borderColor: 'divider',
pt: 1,
pb: 0.5,
}}
>
{closed ? (
<Typography variant="body2" sx={{ textAlign: 'center', color: 'var(--bal-text-secondary)', py: 1 }}>
{t('closed_notice')}
</Typography>
) : (
<MessageComposer value={draft} onChange={setDraft} onSubmit={submit} sending={sending} />
)}
</Box>
</>
);
};
export default TicketConversationPanel;
@@ -1,5 +1,6 @@
'use client';
import { FunctionComponent, useState } from 'react';
import Chip from '@mui/material/Chip';
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
@@ -9,21 +10,29 @@ import AppButton from '@/components/common/AppButton';
import { AppIcon } from '@/components/common';
import { ticketThreadPath } from '@/constants';
import { useMyTickets } from '@/services/tickets';
import { formatShamsiDateTime } from '@/utils';
import { TICKETS_PAGE_SIZE } from '@/services/tickets/constants';
import type { TicketStatus } from '@/services/tickets/types';
import { formatRelativeTime, formatShamsiDate } from '@/utils';
import { authorLabelKey } from './authorLabel';
import ContactSupportDialog from './ContactSupportDialog';
import EmergencyBanner from './EmergencyBanner';
import EmergencyPlaybookRow from './EmergencyPlaybookRow';
import TicketListCard from './TicketListCard';
export interface TicketInboxScreenProps {
role: 'customer' | 'nurse';
}
/** Status filter chips — `undefined` (همه) plus every `TicketStatus`, in display order. */
const STATUS_FILTERS: Array<TicketStatus | undefined> = [undefined, 'open', 'closed'];
/**
* The "My Tickets" inbox — the emergency playbook banner (support entry), a "Contact support" CTA that opens a
* new ticket (and shows its `referenceCode`), and the paginated ticket list. Cards show the **`referenceCode`
* prominently**, the status chip, an unread indicator, and a null-safe linked-booking/refund hint.
* Empty / loading-skeleton / error→retry states. Shared by the customer and nurse inbox pages (role decides
* the thread route + the ticket shell, not the components).
* The "My Tickets" inbox — a status filter chip row, the compact emergency playbook row, a "Contact
* support" CTA that opens a new ticket (and shows its `referenceCode`), and the paginated ticket list.
* Cards show the **`referenceCode`** prominently, the status chip, an unread indicator + last-message
* preview + relative last-activity time (mock-tolerant: they degrade gracefully when the real API hasn't
* shipped the enrichment fields yet, REQ-059), and a null-safe linked-booking/refund hint. Empty /
* loading-skeleton / error→retry states. Shared by the customer and nurse inbox pages (role decides the
* thread route + the ticket shell, not the components).
* @component TicketInboxScreen
*/
const TicketInboxScreen: FunctionComponent<TicketInboxScreenProps> = ({ role }) => {
@@ -31,14 +40,22 @@ const TicketInboxScreen: FunctionComponent<TicketInboxScreenProps> = ({ role })
const locale = useLocale();
const router = useRouter();
const [dialogOpen, setDialogOpen] = useState(false);
const [status, setStatus] = useState<TicketStatus | undefined>(undefined);
const [limit, setLimit] = useState(TICKETS_PAGE_SIZE);
const { data, isLoading, isError, refetch } = useMyTickets({});
const { data, isLoading, isError, isFetching, refetch } = useMyTickets({ status, pageSize: limit, page: 1 });
const tickets = data?.items ?? [];
const total = data?.total ?? 0;
const selectStatus = (next: TicketStatus | undefined) => {
setStatus(next);
setLimit(TICKETS_PAGE_SIZE);
};
const openThread = (ticketId: number) => router.push(`/${locale}${ticketThreadPath(role, ticketId)}`);
return (
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%' }}>
<Stack sx={{ gap: 2, maxWidth: 720, mx: 'auto', width: '100%' }}>
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 800 }}>
{t('title')}
@@ -53,7 +70,19 @@ const TicketInboxScreen: FunctionComponent<TicketInboxScreenProps> = ({ role })
</AppButton>
</Stack>
<EmergencyBanner onOpenTicket={() => setDialogOpen(true)} />
<EmergencyPlaybookRow onOpenTicket={() => setDialogOpen(true)} />
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{STATUS_FILTERS.map((filter) => (
<Chip
key={filter ?? 'all'}
label={filter ? t(`status_${filter}`) : t('filter_all')}
onClick={() => selectStatus(filter)}
color={status === filter ? 'primary' : 'default'}
variant={status === filter ? 'filled' : 'outlined'}
/>
))}
</Stack>
{isLoading ? (
<Stack sx={{ gap: 1 }}>
@@ -89,12 +118,30 @@ const TicketInboxScreen: FunctionComponent<TicketInboxScreenProps> = ({ role })
ticket={ticket}
categoryLabel={t(`category_${ticket.category}`)}
statusLabel={t(`status_${ticket.status}`)}
timeLabel={formatShamsiDateTime(ticket.lastMessageAt ?? ticket.createdAt, locale)}
timeLabel={formatRelativeTime(ticket.lastMessageAt ?? ticket.createdAt, locale, formatShamsiDate)}
previewLabel={
ticket.lastMessagePreview
? ticket.lastAuthorRole
? `${t(authorLabelKey(ticket.lastAuthorRole))}: ${ticket.lastMessagePreview}`
: ticket.lastMessagePreview
: null
}
linkedBookingLabel={ticket.bookingId != null ? t('linked_booking', { id: ticket.bookingId }) : null}
linkedRefundLabel={ticket.refundId != null ? t('linked_refund', { id: ticket.refundId }) : null}
onOpen={() => openThread(ticket.id)}
/>
))}
{total > tickets.length ? (
<AppButton
variant="text"
color="primary"
onClick={() => setLimit((current) => current + TICKETS_PAGE_SIZE)}
disabled={isFetching}
sx={{ alignSelf: 'center' }}
>
{t('load_more')}
</AppButton>
) : null}
</Stack>
)}
@@ -66,4 +66,27 @@ describe('<TicketListCard/> component', () => {
fireEvent.click(screen.getByTestId('ticket-card'));
expect(onOpen).toHaveBeenCalledTimes(1);
});
it('renders the last-message preview when the enrichment field is present', () => {
renderCard({}, { previewLabel: 'پرستار: ساعت ۵ عصر هماهنگ شد' });
expect(screen.getByText('پرستار: ساعت ۵ عصر هماهنگ شد')).toBeInTheDocument();
});
it('degrades gracefully (no empty slot) when the preview enrichment field is absent', () => {
const { container } = render(
<ThemeProvider>
<TicketListCard
ticket={base}
categoryLabel="هماهنگی"
statusLabel="باز"
timeLabel="۲ ساعت پیش"
previewLabel={null}
linkedBookingLabel={null}
linkedRefundLabel={null}
onOpen={() => {}}
/>
</ThemeProvider>,
);
expect(container.querySelector('[data-testid="ticket-card"]')).toBeInTheDocument();
});
});
@@ -16,8 +16,15 @@ export interface TicketListCardProps {
categoryLabel: string;
/** Translated status label. */
statusLabel: string;
/** Pre-formatted last-activity time — the caller owns locale. */
/** Pre-formatted last-activity time (relative, decaying to Shamsi) — the caller owns locale. */
timeLabel: string;
/**
* Pre-formatted "last message" preview line, already prefixed with the translated author label (e.g.
* "پرستار: ساعت ۵ عصر هماهنگ شد") when `lastAuthorRole` is known. `null` when the enrichment fields are
* absent (the real path today, REQ-059 gap) — the card degrades to subject + status + time, never an
* empty slot.
*/
previewLabel?: string | null;
/** e.g. "رزرو #۵۰۰۱" — rendered only when the ticket is booking-linked (null-safe). */
linkedBookingLabel?: string | null;
/** e.g. "بازپرداخت #۹۰۰۱" — rendered only when refund-linked (null-safe). */
@@ -28,9 +35,11 @@ export interface TicketListCardProps {
/**
* One ticket in the "My Tickets" inbox. The **`referenceCode` is shown prominently** (§5 — it's what a user
* quotes to support); the status chip reuses the shared `StatusChip`, the linked-booking/refund hint renders
* only when present (null-safe), and an **unread indicator** (a count dot + bolded subject) shows when the
* ticket has unread activity. Purely presentational — the caller supplies translated labels + the formatted
* time and handles navigation via `onOpen`.
* only when present (null-safe), and an **unread indicator** (a count pill + bolded subject) shows when the
* ticket has unread activity. `previewLabel` renders a one-line last-message snippet when the enrichment
* fields exist (mock today, REQ-059 on the real path) — its absence never leaves an empty slot. Purely
* presentational — the caller supplies translated labels + the formatted time and handles navigation via
* `onOpen`.
* @component TicketListCard
*/
const TicketListCard: FunctionComponent<TicketListCardProps> = ({
@@ -38,6 +47,7 @@ const TicketListCard: FunctionComponent<TicketListCardProps> = ({
categoryLabel,
statusLabel,
timeLabel,
previewLabel,
linkedBookingLabel,
linkedRefundLabel,
onOpen,
@@ -58,6 +68,7 @@ const TicketListCard: FunctionComponent<TicketListCardProps> = ({
border: '1px solid',
borderColor: 'divider',
bgcolor: 'var(--bal-bg-paper)',
'&:focus-visible': { outline: '2px solid var(--bal-focus-ring)', outlineOffset: 2 },
}}
>
<Stack sx={{ gap: 0.75, width: '100%' }}>
@@ -96,6 +107,21 @@ const TicketListCard: FunctionComponent<TicketListCardProps> = ({
{ticket.referenceCode}
</Typography>
{previewLabel ? (
<Typography
variant="body2"
sx={{
color: hasUnread ? 'var(--bal-text-primary)' : 'var(--bal-text-secondary)',
fontWeight: hasUnread ? 600 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{previewLabel}
</Typography>
) : null}
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
{linkedBookingLabel ? (
<Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)' }}>
@@ -1,30 +1,86 @@
'use client';
import { FunctionComponent } from 'react';
import Box from '@mui/material/Box';
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { useLocale, useTranslations } from 'next-intl';
import AppButton from '@/components/common/AppButton';
import { useTicketThread } from '@/services/tickets';
import { formatShamsiDateTime } from '@/utils';
import MessageBubble from './MessageBubble';
import type { TicketAuthorRole, TicketMessage } from '@/services/tickets/types';
import { formatDaySeparator, formatShamsiDate, formatShamsiTime } from '@/utils';
import { authorLabelKey } from './authorLabel';
import MessageBubble from './MessageBubble';
import { useThreadScroll } from './useThreadScroll';
export interface TicketMessageListProps {
ticketId: number;
/** Re-mutates a failed send with the same `clientMessageId` (never a duplicate bubble — §3.3). */
onRetry: (message: TicketMessage) => void;
/** Discards a failed bubble and restores its text to the composer draft. */
onDiscard: (message: TicketMessage) => void;
}
type MessageBlock =
| { kind: 'separator'; key: string; label: string }
| { kind: 'system'; key: string; message: TicketMessage }
| { kind: 'group'; key: string; authorRole: TicketAuthorRole; isMine: boolean; messages: TicketMessage[] };
/** Groups messages into date separators, centered system events, and consecutive same-author bubbles. */
function buildBlocks(messages: TicketMessage[], locale: string, todayLabel: string, yesterdayLabel: string): MessageBlock[] {
const blocks: MessageBlock[] = [];
let lastDayKey: string | null = null;
for (const message of messages) {
const dayKey = formatShamsiDate(message.createdAt, locale);
if (dayKey !== lastDayKey) {
blocks.push({
kind: 'separator',
key: `sep-${message.clientMessageId ?? message.id}`,
label: formatDaySeparator(message.createdAt, locale, todayLabel, yesterdayLabel),
});
lastDayKey = dayKey;
}
if (message.authorRole === 'system') {
blocks.push({ kind: 'system', key: `sys-${message.clientMessageId ?? message.id}`, message });
continue;
}
const last = blocks[blocks.length - 1];
if (last?.kind === 'group' && last.authorRole === message.authorRole && last.isMine === message.isMine) {
last.messages.push(message);
} else {
blocks.push({
kind: 'group',
key: `grp-${message.clientMessageId ?? message.id}`,
authorRole: message.authorRole,
isMine: message.isMine,
messages: [message],
});
}
}
return blocks;
}
/**
* The message list of a thread — a `select` over the ticket detail (`useTicketThread`), so it re-renders on a
* new (optimistic) message without re-rendering the thread header. Empty ("no messages yet — start
* coordinating"), skeleton, and populated states. Each bubble's author label + Shamsi time are resolved here;
* the bubbles never render an internal note (there are none in the user view — §5).
* new (optimistic) message without re-rendering the thread header. Reads as a **timeline**: centered Shamsi
* date separators (امروز/دیروز/older date), consecutive same-author messages grouped under one author label,
* `hh:mm`-only bubble stamps, and `system` messages as centered neutral event chips instead of bubbles.
* Opens scrolled to the **newest** message and auto-scrolls on send/receive (`useThreadScroll`, §3.2), with a
* floating "new message" pill when the viewer has scrolled up. Empty / skeleton / populated states. Never
* renders an internal note — there are none in the user view (§5).
* @component TicketMessageList
*/
const TicketMessageList: FunctionComponent<TicketMessageListProps> = ({ ticketId }) => {
const TicketMessageList: FunctionComponent<TicketMessageListProps> = ({ ticketId, onRetry, onDiscard }) => {
const t = useTranslations('tickets');
const locale = useLocale();
const { data: messages, isLoading } = useTicketThread(ticketId);
const lastMessage = messages?.[messages.length - 1];
const { bottomRef, showNewMessagePill, scrollToNewest } = useThreadScroll(messages?.length ?? 0, lastMessage?.isMine ?? false);
if (isLoading) {
return (
<Stack sx={{ gap: 1 }}>
@@ -48,18 +104,79 @@ const TicketMessageList: FunctionComponent<TicketMessageListProps> = ({ ticketId
);
}
const blocks = buildBlocks(messages, locale, t('day_today'), t('day_yesterday'));
return (
<Stack sx={{ gap: 1 }}>
{messages.map((message) => (
<MessageBubble
key={message.clientMessageId ?? String(message.id)}
message={message}
authorLabel={t(authorLabelKey(message.authorRole))}
timeLabel={formatShamsiDateTime(message.createdAt, locale)}
sendingLabel={t('sending')}
/>
))}
</Stack>
<Box sx={{ position: 'relative' }}>
<Stack sx={{ gap: 1.5 }}>
{blocks.map((block) => {
if (block.kind === 'separator') {
return (
<Typography
key={block.key}
variant="caption"
sx={{ textAlign: 'center', color: 'var(--bal-text-secondary)', fontWeight: 700 }}
>
{block.label}
</Typography>
);
}
if (block.kind === 'system') {
return (
<Typography
key={block.key}
variant="caption"
data-testid="system-event"
sx={{
alignSelf: 'center',
color: 'var(--bal-text-secondary)',
bgcolor: 'var(--bal-divider)',
borderRadius: 10,
px: 1.5,
py: 0.5,
}}
>
{block.message.body}
</Typography>
);
}
return (
<Stack key={block.key} sx={{ gap: 0.25 }}>
{block.messages.map((message, index) => (
<MessageBubble
key={message.clientMessageId ?? String(message.id)}
message={message}
authorLabel={t(authorLabelKey(message.authorRole))}
showAuthorLabel={index === 0 && !message.isMine}
timeLabel={formatShamsiTime(message.createdAt, locale)}
sendingLabel={t('sending')}
failedLabel={t('send_failed')}
retryLabel={t('retry')}
discardLabel={t('discard_failed')}
onRetry={() => onRetry(message)}
onDiscard={() => onDiscard(message)}
/>
))}
</Stack>
);
})}
<Box ref={bottomRef} sx={{ height: 1 }} />
</Stack>
{showNewMessagePill ? (
<Box sx={{ position: 'sticky', bottom: 8, display: 'flex', justifyContent: 'center', pt: 1 }}>
<AppButton
variant="contained"
color="primary"
size="small"
onClick={scrollToNewest}
sx={{ borderRadius: 10, boxShadow: 'var(--bal-shadow-2)' }}
>
{t('new_message_pill')}
</AppButton>
</Box>
) : null}
</Box>
);
};
@@ -1,6 +1,5 @@
'use client';
import { FunctionComponent } from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Paper from '@mui/material/Paper';
import Skeleton from '@mui/material/Skeleton';
@@ -13,8 +12,7 @@ import { AppIcon } from '@/components/common';
import StatusChip from '@/components/StatusChip';
import { ROUTES, nurseBookingDetailPath, ticketsBasePath } from '@/constants';
import { useTicket } from '@/services/tickets';
import MessageComposer from './MessageComposer';
import TicketMessageList from './TicketMessageList';
import TicketConversationPanel from './TicketConversationPanel';
import { ticketCategoryIcon, ticketStatusKind } from './statusKind';
export interface TicketThreadScreenProps {
@@ -95,30 +93,9 @@ const TicketThreadScreen: FunctionComponent<TicketThreadScreenProps> = ({ role,
</Stack>
</Paper>
<TicketMessageList ticketId={ticketId} />
<Box
sx={{
position: 'sticky',
bottom: 0,
bgcolor: 'var(--bal-bg-default)',
pt: 1,
pb: 0.5,
}}
>
{ticket.status === 'closed' ? (
<Typography
variant="body2"
sx={{ textAlign: 'center', color: 'var(--bal-text-secondary)', py: 1 }}
>
{t('closed_notice')}
</Typography>
) : (
// Key on ticketId so navigating thread→thread (the App Router reuses the [id] subtree)
// remounts the composer — its draft + in-flight/failed send state never cross tickets.
<MessageComposer key={ticketId} ticketId={ticketId} />
)}
</Box>
{/* Key on ticketId so navigating thread→thread (the App Router reuses the [id] subtree)
remounts the whole panel — its draft + in-flight/failed send state never cross tickets. */}
<TicketConversationPanel key={ticketId} ticketId={ticketId} closed={ticket.status === 'closed'} />
</>
)}
</Stack>
@@ -0,0 +1,5 @@
/** A client-generated id so the optimistic bubble reconciles to the server message (never a double-render). */
export function makeClientMessageId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID();
return `c-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
+7
View File
@@ -12,8 +12,15 @@ export { default as TicketListCard } from './TicketListCard';
export type { TicketListCardProps } from './TicketListCard';
export { default as EmergencyBanner } from './EmergencyBanner';
export type { EmergencyBannerProps } from './EmergencyBanner';
export { default as EmergencyPlaybookRow } from './EmergencyPlaybookRow';
export type { EmergencyPlaybookRowProps } from './EmergencyPlaybookRow';
export { default as ContactSupportDialog } from './ContactSupportDialog';
export type { ContactSupportDialogProps } from './ContactSupportDialog';
export { default as TicketInboxScreen } from './TicketInboxScreen';
export { default as TicketThreadScreen } from './TicketThreadScreen';
export { default as TicketConversationPanel } from './TicketConversationPanel';
export type { TicketConversationPanelProps } from './TicketConversationPanel';
export { default as BookingSupportEntry } from './BookingSupportEntry';
// Consumable by phase 11's admin thread (same inverse-scroll bug) — §3.7 handshake.
export { useThreadScroll } from './useThreadScroll';
export type { UseThreadScrollResult } from './useThreadScroll';
@@ -0,0 +1,77 @@
import { useEffect, useRef, useState } from 'react';
/** How close to the bottom edge (in px) still counts as "already at the bottom" (§3.2). */
const NEAR_BOTTOM_THRESHOLD_PX = 120;
export interface UseThreadScrollResult {
/** Attach to a zero-height sentinel element rendered after the last message. */
bottomRef: (node: HTMLDivElement | null) => void;
/** A floating «پیام جدید ↓» pill should render while this is true. */
showNewMessagePill: boolean;
/** Scrolls to the newest message and dismisses the pill (the pill's own tap handler). */
scrollToNewest: () => void;
}
/**
* Chat scroll orchestration (§3.2), reusable across any message-list surface (this thread now; phase 11's
* admin thread scrollbox next — it has the inverse bug, opening scrolled to the top). A thread opens
* scrolled to the **newest** message; sending always scrolls to the new bubble; receiving auto-scrolls only
* when the viewer is already near the bottom, otherwise shows a dismissible "new message" pill.
*
* Uses an `IntersectionObserver` on a bottom sentinel (not a tracked scroll container) so it works whether
* the *page* scrolls or an inner box does — the observer's root is the viewport by default, which correctly
* accounts for whichever ancestor actually scrolls. Guarded for environments without
* `IntersectionObserver` (e.g. jsdom in tests): it degrades to "always treat as near the bottom".
*/
export function useThreadScroll(messageCount: number, lastMessageIsMine: boolean): UseThreadScrollResult {
const [showNewMessagePill, setShowNewMessagePill] = useState(false);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const isNearBottomRef = useRef(true);
const didInitialScrollRef = useRef(false);
const prevCountRef = useRef(0);
const scrollToNewest = () => {
sentinelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
setShowNewMessagePill(false);
};
const bottomRef = (node: HTMLDivElement | null) => {
sentinelRef.current = node;
};
useEffect(() => {
const node = sentinelRef.current;
if (!node || typeof IntersectionObserver === 'undefined') return undefined;
const observer = new IntersectionObserver(
([entry]) => {
isNearBottomRef.current = entry.isIntersecting;
if (entry.isIntersecting) setShowNewMessagePill(false);
},
{ rootMargin: `0px 0px ${NEAR_BOTTOM_THRESHOLD_PX}px 0px` },
);
observer.observe(node);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (messageCount === 0) return;
if (!didInitialScrollRef.current) {
// Opens at the newest message (§3.2) — instant, no animation, on first load.
sentinelRef.current?.scrollIntoView({ behavior: 'auto', block: 'end' });
didInitialScrollRef.current = true;
prevCountRef.current = messageCount;
return;
}
if (messageCount > prevCountRef.current) {
if (lastMessageIsMine || isNearBottomRef.current) {
sentinelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
setShowNewMessagePill(false);
} else {
setShowNewMessagePill(true);
}
}
prevCountRef.current = messageCount;
}, [messageCount, lastMessageIsMine]);
return { bottomRef, showNewMessagePill, scrollToNewest };
}
@@ -1,9 +1,12 @@
'use client';
import { FunctionComponent } from 'react';
import { FunctionComponent, useState } from 'react';
import useMediaQuery from '@mui/material/useMediaQuery';
import { useTheme } from '@mui/material/styles';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { notificationsPath } from '@/constants';
import { useUnreadCount } from '@/services/notifications';
import NotificationBellPopover from './NotificationBellPopover';
import NotificationBellView from './NotificationBellView';
export interface NotificationBellProps {
@@ -13,9 +16,10 @@ export interface NotificationBellProps {
/**
* The notification bell **container** mounted in the app chrome. It subscribes to the polling
* `useUnreadCount` (stale-while-revalidate) and navigates to the role's notification center on click. Because
* only this small container reads the fast-changing count, a count change re-renders just the bell — not the
* whole shell.
* `useUnreadCount` (stale-while-revalidate) — the only thing in this component that re-renders on a count
* change, so the shell around it never does (§5 isolation). On the **nurse desktop** shell (§3.6) it opens
* a popover preview instead of navigating; every other case (customer — mobile-first, always navigates;
* nurse mobile; admin) keeps the direct full-page navigation.
* @component NotificationBell
*/
const NotificationBell: FunctionComponent<NotificationBellProps> = ({ role }) => {
@@ -23,13 +27,33 @@ const NotificationBell: FunctionComponent<NotificationBellProps> = ({ role }) =>
const router = useRouter();
const locale = useLocale();
const t = useTranslations('notifications');
const theme = useTheme();
const isDesktop = useMediaQuery(theme.breakpoints.up('md'));
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const [popoverOpen, setPopoverOpen] = useState(false);
const usesPopover = role === 'nurse' && isDesktop;
const onClick = () => {
if (usesPopover) {
setPopoverOpen(true);
return;
}
router.push(`/${locale}${notificationsPath(role)}`);
};
return (
<NotificationBellView
count={count}
label={t('bell_aria', { count })}
onClick={() => router.push(`/${locale}${notificationsPath(role)}`)}
/>
<>
<NotificationBellView ref={setAnchorEl} count={count} label={t('bell_aria', { count })} onClick={onClick} />
{usesPopover ? (
<NotificationBellPopover
open={popoverOpen}
anchorEl={anchorEl}
onClose={() => setPopoverOpen(false)}
role="nurse"
/>
) : null}
</>
);
};
@@ -0,0 +1,123 @@
'use client';
import { FunctionComponent } from 'react';
import Divider from '@mui/material/Divider';
import Popover from '@mui/material/Popover';
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { useTheme } from '@mui/material/styles';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import AppButton from '@/components/common/AppButton';
import { notificationsPath } from '@/constants';
import {
notificationDeepLink,
useMarkAllRead,
useMarkNotificationRead,
useNotifications,
} from '@/services/notifications';
import type { AppNotification } from '@/services/notifications/types';
import { formatRelativeTime, formatShamsiDate } from '@/utils';
import NotificationRow from './NotificationRow';
/** The 5 most recent — a preview, not the full center (§3.6). */
const POPOVER_RECENT_COUNT = 5;
export interface NotificationBellPopoverProps {
open: boolean;
anchorEl: HTMLElement | null;
onClose: () => void;
role: 'nurse';
}
/**
* The nurse-desktop bell's popover preview (§3.6) — the 5 most recent notifications, mark-all-read, and
* «مشاهده همه» to the full center. Fetches **on open** (`enabled: open`), reusing the same
* `notificationKeys` cache the full center reads — never on the polled count's tick, and it never reads
* `useUnreadCount` itself (only the bell container does — §5 isolation).
* @component NotificationBellPopover
*/
const NotificationBellPopover: FunctionComponent<NotificationBellPopoverProps> = ({ open, anchorEl, onClose, role }) => {
const t = useTranslations('notifications');
const locale = useLocale();
const router = useRouter();
const theme = useTheme();
const edge = theme.direction === 'rtl' ? 'left' : 'right';
const { data, isLoading } = useNotifications(POPOVER_RECENT_COUNT, { enabled: open });
const markRead = useMarkNotificationRead();
const markAll = useMarkAllRead();
const items = data?.items ?? [];
const hasUnread = items.some((n) => !n.isRead);
const openNotification = (notification: AppNotification) => {
if (!notification.isRead) markRead.mutate(notification.id);
const target = notificationDeepLink(notification, role);
if (target) {
router.push(`/${locale}${target}`);
onClose();
}
};
const viewAll = () => {
router.push(`/${locale}${notificationsPath(role)}`);
onClose();
};
return (
<Popover
open={open}
anchorEl={anchorEl}
onClose={onClose}
anchorOrigin={{ vertical: 'bottom', horizontal: edge }}
transformOrigin={{ vertical: 'top', horizontal: edge }}
>
<Stack sx={{ width: 360, maxWidth: '90vw', p: 1.5, gap: 1.5 }}>
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{t('title')}
</Typography>
{hasUnread ? (
<AppButton variant="text" color="primary" size="small" onClick={() => markAll.mutate()} disabled={markAll.isPending}>
{t('mark_all_read')}
</AppButton>
) : null}
</Stack>
{isLoading ? (
<Stack sx={{ gap: 1 }}>
{[0, 1].map((i) => (
<Skeleton key={i} variant="rounded" height={64} />
))}
</Stack>
) : items.length === 0 ? (
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)', textAlign: 'center', py: 2 }}>
{t('empty_title')}
</Typography>
) : (
<Stack sx={{ gap: 1 }}>
{items.map((notification) => {
const target = notificationDeepLink(notification, role);
return (
<NotificationRow
key={notification.id}
notification={notification}
timeLabel={formatRelativeTime(notification.createdAt, locale, formatShamsiDate)}
navigable={target != null}
onOpen={() => openNotification(notification)}
/>
);
})}
</Stack>
)}
<Divider />
<AppButton variant="text" color="primary" onClick={viewAll} sx={{ alignSelf: 'center' }}>
{t('view_all')}
</AppButton>
</Stack>
</Popover>
);
};
export default NotificationBellPopover;
@@ -1,5 +1,5 @@
'use client';
import { FunctionComponent } from 'react';
import { forwardRef } from 'react';
import Badge from '@mui/material/Badge';
import IconButton from '@mui/material/IconButton';
import { AppIcon } from '@/components/common';
@@ -15,12 +15,13 @@ export interface NotificationBellViewProps {
/**
* The notification bell — a pure badge + icon button. `count` drives the badge (hidden at 0, capped at 99+).
* Presentational and self-contained so the fast-changing count re-renders only the bell, never the shell
* around it (the count is fed by the polling `useUnreadCount` in the `NotificationBell` container).
* around it (the count is fed by the polling `useUnreadCount` in the `NotificationBell` container). Forwards
* its ref to the underlying button so the container can anchor a desktop popover to it (§3.6).
* @component NotificationBellView
*/
const NotificationBellView: FunctionComponent<NotificationBellViewProps> = ({ count, label, onClick }) => {
const NotificationBellView = forwardRef<HTMLButtonElement, NotificationBellViewProps>(({ count, label, onClick }, ref) => {
return (
<IconButton onClick={onClick} aria-label={label} data-testid="notification-bell" color="inherit">
<IconButton ref={ref} onClick={onClick} aria-label={label} data-testid="notification-bell" color="inherit">
<Badge
badgeContent={count}
max={99}
@@ -31,6 +32,8 @@ const NotificationBellView: FunctionComponent<NotificationBellViewProps> = ({ co
</Badge>
</IconButton>
);
};
});
NotificationBellView.displayName = 'NotificationBellView';
export default NotificationBellView;
@@ -15,18 +15,64 @@ import {
useNotifications,
} from '@/services/notifications';
import type { AppNotification } from '@/services/notifications/types';
import { formatShamsiDateTime } from '@/utils';
import { formatRelativeTime, formatShamsiDate } from '@/utils';
import NotificationRow from './NotificationRow';
export interface NotificationCenterProps {
role: 'customer' | 'nurse';
}
interface NotificationGroup {
key: string;
label: string;
items: AppNotification[];
}
/**
* The notification center — a paged, **unread-first** list. Each row **marks itself read on open** (optimistic)
* and **deep-links via `notificationDeepLink`** (role-aware) when its `data` points somewhere. A "mark all read"
* action clears the badge at once. Empty / loading-skeleton / error→retry states. Shared by the customer and
* nurse notification pages (role decides only the deep-link shell).
* Buckets notifications into امروز/دیروز/این هفته, then per-day Shamsi headers for anything older —
* emitted **in list order** (the server's unread-first-then-newest ordering is preserved; §5 "keep the
* mark-read UX as is"), so a bucket can recur if an older unread item is pinned above newer read ones.
* Compares already-formatted date strings (not raw ms diffs) so a day boundary is calendar-exact.
*/
function groupByDay(items: AppNotification[], locale: string, todayLabel: string, yesterdayLabel: string, thisWeekLabel: string): NotificationGroup[] {
const now = new Date();
const todayStr = formatShamsiDate(now, locale);
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = formatShamsiDate(yesterday, locale);
const thisWeekStrs = new Set<string>();
for (let i = 2; i < 7; i += 1) {
const d = new Date(now);
d.setDate(d.getDate() - i);
thisWeekStrs.add(formatShamsiDate(d, locale));
}
const groups: NotificationGroup[] = [];
for (const item of items) {
const dayStr = formatShamsiDate(item.createdAt, locale);
const bucket =
dayStr === todayStr
? { key: 'today', label: todayLabel }
: dayStr === yesterdayStr
? { key: 'yesterday', label: yesterdayLabel }
: thisWeekStrs.has(dayStr)
? { key: 'this_week', label: thisWeekLabel }
: { key: dayStr, label: dayStr };
const last = groups[groups.length - 1];
if (last && last.key === bucket.key) last.items.push(item);
else groups.push({ ...bucket, items: [item] });
}
return groups;
}
/**
* The notification center — a paged, **unread-first** list, day-grouped (امروز/دیروز/این هفته, then Shamsi
* dates) with relative timestamps decaying to Shamsi. Each row **marks itself read on open** (optimistic);
* a row whose `data` deep-links renders interactive with a trailing chevron, a row with nothing to open
* renders as a plain, non-rippling surface. A "mark all read" action clears the badge at once. Empty /
* loading-skeleton / error→retry states. Shared by the customer and nurse notification pages (role decides
* only the deep-link shell).
* @component NotificationCenter
*/
const NotificationCenter: FunctionComponent<NotificationCenterProps> = ({ role }) => {
@@ -42,6 +88,7 @@ const NotificationCenter: FunctionComponent<NotificationCenterProps> = ({ role }
const items = data?.items ?? [];
const total = data?.total ?? 0;
const hasUnread = items.some((n) => !n.isRead);
const groups = groupByDay(items, locale, t('group_today'), t('group_yesterday'), t('group_this_week'));
const openNotification = (notification: AppNotification) => {
if (!notification.isRead) markRead.mutate(notification.id);
@@ -94,14 +141,27 @@ const NotificationCenter: FunctionComponent<NotificationCenterProps> = ({ role }
</Typography>
</Stack>
) : (
<Stack sx={{ gap: 1 }}>
{items.map((notification) => (
<NotificationRow
key={notification.id}
notification={notification}
timeLabel={formatShamsiDateTime(notification.createdAt, locale)}
onOpen={() => openNotification(notification)}
/>
<Stack sx={{ gap: 2 }}>
{groups.map((group) => (
<Stack key={`${group.key}-${group.items[0].id}`} sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'var(--bal-text-secondary)' }}>
{group.label}
</Typography>
<Stack sx={{ gap: 1 }}>
{group.items.map((notification) => {
const target = notificationDeepLink(notification, role);
return (
<NotificationRow
key={notification.id}
notification={notification}
timeLabel={formatRelativeTime(notification.createdAt, locale, formatShamsiDate)}
navigable={target != null}
onOpen={() => openNotification(notification)}
/>
);
})}
</Stack>
</Stack>
))}
{total > items.length ? (
<AppButton
@@ -13,11 +13,11 @@ const base: AppNotification = {
data: { kind: 'booking', bookingId: 5001 },
};
function renderRow(overrides: Partial<AppNotification>) {
function renderRow(overrides: Partial<AppNotification>, navigable = true) {
const onOpen = jest.fn();
render(
<ThemeProvider>
<NotificationRow notification={{ ...base, ...overrides }} timeLabel="۱۰:۰۰" onOpen={onOpen} />
<NotificationRow notification={{ ...base, ...overrides }} timeLabel="۱۰:۰۰" navigable={navigable} onOpen={onOpen} />
</ThemeProvider>,
);
return onOpen;
@@ -47,4 +47,17 @@ describe('<NotificationRow/> component', () => {
fireEvent.click(screen.getByTestId('notification-row'));
expect(onOpen).toHaveBeenCalledTimes(1);
});
it('renders a non-interactive surface (no button role) when not navigable, but still marks read', () => {
const onOpen = renderRow({}, false);
const row = screen.getByTestId('notification-row');
expect(row).toHaveAttribute('data-navigable', 'false');
fireEvent.click(row);
expect(onOpen).toHaveBeenCalledTimes(1);
});
it('renders as an interactive button with a real role when navigable', () => {
renderRow({}, true);
expect(screen.getByTestId('notification-row')).toHaveAttribute('data-navigable', 'true');
});
});
@@ -1,38 +1,121 @@
'use client';
import { FunctionComponent } from 'react';
import { FunctionComponent, KeyboardEvent } from 'react';
import Box from '@mui/material/Box';
import ButtonBase from '@mui/material/ButtonBase';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { AppIcon } from '@/components/common';
import type { AppNotification } from '@/services/notifications/types';
import { notificationIcon } from './notificationIcon';
import { notificationIcon, notificationTint } from './notificationIcon';
export interface NotificationRowProps {
notification: AppNotification;
/** Pre-formatted Shamsi time — the caller owns locale. */
/** Pre-formatted relative time (decaying to Shamsi) — the caller owns locale. */
timeLabel: string;
/** Marks the notification read (optimistic) and, when it deep-links, navigates. */
/** Whether `notificationDeepLink` resolved a route — drives interactive chrome vs a plain, static surface. */
navigable: boolean;
/** Marks the notification read (optimistic) and, when navigable, navigates. */
onOpen: () => void;
}
/**
* One row in the notification center. **Unread** rows are emphasised (a leading dot + bolded title + a soft
* tint); opening a row marks it read (optimistic) and, when its `data` deep-links, navigates there. The
* icon is chosen from the parsed deep-link class. Purely presentational — the caller supplies the formatted
* time and the `onOpen` behaviour (mark-read + `notificationDeepLink`). `title`/`body` are server-rendered
* copy (not client i18n keys).
* tint); a **per-kind tinted icon container** (booking teal / payout success / ticket terracotta / refund
* info / nurse-trust) replaces the uniform primary icon. **Navigable** rows (their `data` deep-links
* somewhere) render as a real `ButtonBase` with a trailing chevron and a visible `:focus-visible` ring;
* **non-navigable** rows (`data.kind === 'none'` or a target that doesn't apply to this role) render as a
* plain, static surface — no ripple, no pointer cursor, no chevron — so a tap that does nothing never
* *looks* like it should do something, while still marking the row read. Purely presentational — the caller
* supplies the formatted time, whether it's navigable, and the `onOpen` behaviour (mark-read +
* `notificationDeepLink`). `title`/`body` are server-rendered copy (not client i18n keys).
* @component NotificationRow
*/
const NotificationRow: FunctionComponent<NotificationRowProps> = ({ notification, timeLabel, onOpen }) => {
const NotificationRow: FunctionComponent<NotificationRowProps> = ({ notification, timeLabel, navigable, onOpen }) => {
const unread = !notification.isRead;
const tint = notificationTint(notification.data.kind);
const content = (
<Stack direction="row" sx={{ gap: 1.25, alignItems: 'flex-start', width: '100%' }}>
<Box
sx={{
width: 36,
height: 36,
borderRadius: '50%',
bgcolor: tint.bg,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<AppIcon icon={notificationIcon(notification.data.kind)} size={18} color={tint.fg} />
</Box>
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
{unread ? (
<Box
data-testid="notification-unread-dot"
sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'var(--bal-primary)', flexShrink: 0 }}
/>
) : null}
<Typography variant="subtitle2" sx={{ fontWeight: unread ? 800 : 600 }}>
{notification.title}
</Typography>
</Stack>
{notification.body ? (
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
{notification.body}
</Typography>
) : null}
<Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)' }}>
{timeLabel}
</Typography>
</Stack>
{navigable ? <AppIcon icon="forward" size={18} color="var(--bal-text-secondary)" /> : null}
</Stack>
);
if (navigable) {
return (
<ButtonBase
onClick={onOpen}
data-testid="notification-row"
data-unread={unread ? 'true' : 'false'}
data-navigable="true"
sx={{
display: 'block',
width: '100%',
textAlign: 'start',
p: 1.5,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
bgcolor: unread ? 'var(--bal-primary-soft)' : 'var(--bal-bg-paper)',
'&:focus-visible': { outline: '2px solid var(--bal-focus-ring)', outlineOffset: 2 },
}}
>
{content}
</ButtonBase>
);
}
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpen();
}
};
return (
<ButtonBase
<Box
onClick={onOpen}
onKeyDown={onKeyDown}
role="button"
tabIndex={0}
data-testid="notification-row"
data-unread={unread ? 'true' : 'false'}
data-navigable="false"
sx={{
display: 'block',
width: '100%',
textAlign: 'start',
p: 1.5,
@@ -40,33 +123,12 @@ const NotificationRow: FunctionComponent<NotificationRowProps> = ({ notification
border: '1px solid',
borderColor: 'divider',
bgcolor: unread ? 'var(--bal-primary-soft)' : 'var(--bal-bg-paper)',
cursor: 'default',
'&:focus-visible': { outline: '2px solid var(--bal-focus-ring)', outlineOffset: 2 },
}}
>
<Stack direction="row" sx={{ gap: 1.25, alignItems: 'flex-start', width: '100%' }}>
<AppIcon icon={notificationIcon(notification.data.kind)} size={20} color="var(--bal-primary)" />
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
{unread ? (
<Box
data-testid="notification-unread-dot"
sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'var(--bal-primary)', flexShrink: 0 }}
/>
) : null}
<Typography variant="subtitle2" sx={{ fontWeight: unread ? 800 : 600 }}>
{notification.title}
</Typography>
</Stack>
{notification.body ? (
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
{notification.body}
</Typography>
) : null}
<Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)' }}>
{timeLabel}
</Typography>
</Stack>
</Stack>
</ButtonBase>
{content}
</Box>
);
};
@@ -7,6 +7,9 @@ export { default as NotificationBell } from './NotificationBell';
export type { NotificationBellProps } from './NotificationBell';
export { default as NotificationBellView } from './NotificationBellView';
export type { NotificationBellViewProps } from './NotificationBellView';
// Consumable by phase 11's admin shell once it has a real feed (§3.7 handshake) — same popover, admin role.
export { default as NotificationBellPopover } from './NotificationBellPopover';
export type { NotificationBellPopoverProps } from './NotificationBellPopover';
export { default as NotificationRow } from './NotificationRow';
export type { NotificationRowProps } from './NotificationRow';
export { default as NotificationCenter } from './NotificationCenter';
@@ -18,3 +18,35 @@ export function notificationIcon(kind: NotificationData['kind']): string {
return 'info';
}
}
export interface NotificationTint {
/** Soft-tint background for the icon container. */
bg: string;
/** Foreground — the icon color + used for any accent on the row. */
fg: string;
}
/**
* Per-kind visual identity (§3.5) a soft-tinted icon container instead of the uniform primary icon, all
* from `--bal-*` semantic tokens so dark mode holds by construction. Booking reads teal (the brand's main
* color), payout success-green, a ticket/support notification warm terracotta (the one deliberate secondary
* accent, echoing "a human from Balinyaar"), a refund/nurse-trust signal info/trust, and a non-deep-linking
* notification a fully neutral tint.
*/
export function notificationTint(kind: NotificationData['kind']): NotificationTint {
switch (kind) {
case 'booking':
return { bg: 'var(--bal-primary-soft)', fg: 'var(--bal-primary)' };
case 'payout':
return { bg: 'var(--bal-success-soft)', fg: 'var(--bal-success)' };
case 'ticket':
return { bg: 'var(--bal-secondary-soft)', fg: 'var(--bal-secondary)' };
case 'refund':
return { bg: 'var(--bal-info-soft)', fg: 'var(--bal-info)' };
case 'nurse_profile':
return { bg: 'var(--bal-trust-soft)', fg: 'var(--bal-trust)' };
case 'none':
default:
return { bg: 'var(--bal-divider)', fg: 'var(--bal-text-secondary)' };
}
}
+5 -4
View File
@@ -1,7 +1,6 @@
'use client';
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { NotificationBell } from '@/components/notifications';
import { ProfileSummary } from '@/components';
import { ROUTES } from '@/constants';
import { useAdminCapabilities } from '@/hooks';
@@ -13,8 +12,11 @@ import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
* Admin / backoffice shell the desktop ops console (f15). The sidebar is **sectioned**
* (اعتماد/مالی/پشتیبانی/سیستم) and **role-gated**: each console appears only when the current
* admin role can act on it (`useAdminCapabilities`), unchanged from before grouping never adds,
* removes, or loosens a gate. The TopBar carries a page title, the notification bell (widened to
* the `'admin'` role), and a compact identity chip showing the admin's fine-grained role.
* removes, or loosens a gate. The TopBar carries a page title and a compact identity chip showing
* the admin's fine-grained role. **No notification bell** (ui-phase-10): admin notifications have no
* real feed yet (phase 11 owns that decision) and the bell was the only entry into the dead
* `admin/notifications` placeholder re-add it once phase 11 ships a feed, reusing
* `NotificationBellPopover` (already exported for that handoff).
* @layout AdminLayout
*/
const AdminLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
@@ -50,7 +52,6 @@ const AdminLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
return (
<TopBarAndSideBarLayout
sidebarItems={sidebarItems}
headerActions={<NotificationBell role="admin" />}
identity={
me && primaryRoleCode ? (
<ProfileSummary
+17 -7
View File
@@ -1,11 +1,12 @@
'use client';
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
import { Box, Stack, Tab, Tabs } from '@mui/material';
import { Badge, Box, Stack, Tab, Tabs } from '@mui/material';
import { useTranslations } from 'next-intl';
import { AppIcon, AppIconButton, ErrorBoundary } from '@/components';
import { NotificationBell } from '@/components/notifications';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { useSupportUnreadTotal } from '@/services/tickets';
import { LinkToPage } from '@/utils';
import { usePathname, useRouter } from '@/i18n/navigation';
import { TopBar, BottomBar } from './components';
@@ -50,18 +51,27 @@ const CustomerHeader: FunctionComponent<{ bottomNavItems: Array<LinkToPage> }> =
const router = useRouter();
const title = useRouteTitle();
const onRootTab = isCustomerRootTab(pathname);
const supportUnreadTotal = useSupportUnreadTotal();
return (
<TopBar
align={onRootTab ? 'center' : 'start'}
startNode={
onRootTab ? (
<AppIconButton
icon="support"
color="inherit"
title={t('support')}
onClick={() => router.push(ROUTES.SUPPORT_TICKETS)}
/>
<Badge
badgeContent={supportUnreadTotal ?? 0}
max={99}
overlap="circular"
invisible={!supportUnreadTotal}
sx={{ '& .MuiBadge-badge': { bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)' } }}
>
<AppIconButton
icon="support"
color="inherit"
title={t('support')}
onClick={() => router.push(ROUTES.SUPPORT_TICKETS)}
/>
</Badge>
) : (
<AppIconButton icon="back" title={tc('back')} onClick={() => router.back()} />
)
+10 -2
View File
@@ -8,6 +8,7 @@ import { ROUTES } from '@/constants';
import { LinkToPage } from '@/utils';
import { useMe } from '@/services/auth';
import { useNurseProfile } from '@/services/profiles';
import { useSupportUnreadTotal } from '@/services/tickets';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
@@ -29,6 +30,7 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const { data: me } = useMe();
const { data: nurseProfile } = useNurseProfile();
const { data: verification } = useVerificationStatus();
const supportUnreadTotal = useSupportUnreadTotal();
const identityLoading = !me;
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
@@ -48,9 +50,15 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
{ title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification', group: groupProfession },
{ title: t('earnings'), path: ROUTES.NURSE_EARNINGS, icon: 'earnings', group: groupFinance },
{ title: t('bank'), path: ROUTES.NURSE_BANK, icon: 'bank', group: groupFinance },
{ title: t('support'), path: ROUTES.NURSE_SUPPORT_TICKETS, icon: 'support', group: groupSupport },
{
title: t('support'),
path: ROUTES.NURSE_SUPPORT_TICKETS,
icon: 'support',
group: groupSupport,
badgeCount: supportUnreadTotal ?? undefined,
},
];
}, [t]);
}, [t, supportUnreadTotal]);
const mobileTabs = useMemo(
(): Array<LinkToPage> => [
@@ -1,6 +1,6 @@
'use client';
import { FunctionComponent, MouseEventHandler } from 'react';
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import { Badge, ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import { AppIcon } from '@/components';
import { Link } from '@/i18n/navigation';
import { LinkToPage } from '@/utils';
@@ -14,13 +14,36 @@ interface Props extends LinkToPage {
* Renders a single SideBar navigation item over the locale-aware `Link` (`@/i18n/navigation`)
* `href` is the unprefixed `ROUTES.*` path; the wrapper adds the active locale, so a click is one
* navigation with no middleware redirect hop. `selected` is computed by the caller (`SideBarNavList`)
* via the shared `matchActivePath` helper.
* via the shared `matchActivePath` helper. `badgeCount` renders a small unread dot on the icon (the
* nurse support entry, §3.1) omitted entirely when falsy, never a bare "0".
* @component SideBarNavItem
*/
const SideBarNavItem: FunctionComponent<Props> = ({ icon, path, selected = false, subtitle, title, onClick }) => {
const SideBarNavItem: FunctionComponent<Props> = ({
icon,
path,
selected = false,
subtitle,
title,
onClick,
badgeCount,
}) => {
const iconNode = icon && <AppIcon icon={icon} />;
return (
<ListItemButton component={Link} href={path ?? '#'} selected={selected} onClick={onClick}>
<ListItemIcon>{icon && <AppIcon icon={icon} />}</ListItemIcon>
<ListItemIcon>
{badgeCount ? (
<Badge
badgeContent={badgeCount}
max={99}
overlap="circular"
sx={{ '& .MuiBadge-badge': { bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)' } }}
>
{iconNode}
</Badge>
) : (
iconNode
)}
</ListItemIcon>
<ListItemText primary={title} secondary={subtitle} />
</ListItemButton>
);
@@ -42,6 +42,7 @@ const SideBarNavList: FunctionComponent<Props> = ({ items, showIcons, onClick })
subtitle={item.subtitle}
selected={item.path === activePath}
onClick={onClick}
badgeCount={item.badgeCount}
/>
</Fragment>
);
@@ -10,7 +10,7 @@ import { NOTIFICATIONS_GC_TIME, NOTIFICATIONS_LIST_STALE_TIME, NOTIFICATIONS_PAG
* avoids a flash. **Not polled** only `useUnreadCount` revalidates on an interval; opening a notification /
* mark-all `setQueryData`s this cache and invalidates on settle.
*/
export function useNotifications(limit: number = NOTIFICATIONS_PAGE_SIZE) {
export function useNotifications(limit: number = NOTIFICATIONS_PAGE_SIZE, options?: { enabled?: boolean }) {
const params = { page: 1, pageSize: limit };
return useQuery({
queryKey: notificationKeys.list(params),
@@ -18,5 +18,6 @@ export function useNotifications(limit: number = NOTIFICATIONS_PAGE_SIZE) {
placeholderData: keepPreviousData,
staleTime: NOTIFICATIONS_LIST_STALE_TIME,
gcTime: NOTIFICATIONS_GC_TIME,
enabled: options?.enabled ?? true,
});
}
@@ -33,6 +33,7 @@ interface TicketSummaryWire {
createdAt: string;
lastMessageAt: string | null;
unreadCount: number;
// REQ-059 gap (extends REQ-028) — not yet on the wire; mapped as absent below until delivered.
}
/** Wire `TicketMessageDto`. `isInternal` is present on the DTO but is `false` in the user view (server-stripped). */
@@ -72,6 +73,9 @@ function mapSummary(w: TicketSummaryWire): TicketSummary {
// REQ-028 (delivered): the inbox unread badge + last-activity sort now come off the wire.
lastMessageAt: w.lastMessageAt,
unreadCount: w.unreadCount,
// REQ-059 gap — the wire summary has neither field yet; the card degrades gracefully (§3.1).
lastMessagePreview: null,
lastAuthorRole: null,
};
}
@@ -215,6 +219,9 @@ export const ticketsClientApi: TicketsApi = {
}),
),
// No wire aggregate yet (REQ-059) — the chrome badge (§3.1) renders only when a signal exists.
getUnreadTotal: async (): Promise<number | null> => null,
// ── Admin lens (b15). Global queue + admin thread (internal INCLUDED) + staff message post. ──
listAdminTickets: async (
filters: AdminTicketFilters,
+30 -3
View File
@@ -167,10 +167,28 @@ function findTicket(id: number): StoredTicket {
return t;
}
/** Last non-internal message time — the inbox's "last activity" (REQ-028 stand-in for `lastMessageAt`). */
function lastMessageAt(t: StoredTicket): string {
/** Last non-internal message — the inbox's "last activity" (visible messages only; internal notes never surface). */
function lastVisibleMessage(t: StoredTicket): StoredMessage | undefined {
const visible = t.messages.filter((m) => !m.internal);
return visible.length ? visible[visible.length - 1].sentAt : t.messages[0]?.sentAt ?? new Date().toISOString();
return visible.length ? visible[visible.length - 1] : t.messages[0];
}
function lastMessageAt(t: StoredTicket): string {
return lastVisibleMessage(t)?.sentAt ?? new Date().toISOString();
}
/** First ~80 chars of the last visible message — the REQ-059 inbox preview line, mock-only until delivered. */
const PREVIEW_MAX_CHARS = 80;
function lastMessagePreview(t: StoredTicket): string | null {
const body = lastVisibleMessage(t)?.body?.trim();
if (!body) return null;
return body.length > PREVIEW_MAX_CHARS ? `${body.slice(0, PREVIEW_MAX_CHARS)}` : body;
}
function lastAuthorRole(t: StoredTicket): TicketAuthorRole | null {
const senderId = lastVisibleMessage(t)?.senderId;
if (senderId == null) return null;
return t.participants.find((p) => p.userId === senderId)?.roleOnTicket ?? 'system';
}
function toSummary(t: StoredTicket): TicketSummary {
@@ -185,6 +203,8 @@ function toSummary(t: StoredTicket): TicketSummary {
createdAt: t.messages[0]?.sentAt ?? new Date().toISOString(),
lastMessageAt: lastMessageAt(t),
unreadCount: t.unread,
lastMessagePreview: lastMessagePreview(t),
lastAuthorRole: lastAuthorRole(t),
};
}
@@ -331,6 +351,13 @@ export const ticketsMockApi: TicketsApi = {
return { ticketId: id, referenceCode: ticket.referenceCode, status: 'open', category: ticket.category };
},
// §3.1 chrome support-badge — sums unread across every seeded ticket (a demo-world stand-in for a
// real server-side aggregate; REQ-059).
getUnreadTotal: async (): Promise<number | null> => {
await sleep(MOCK_LATENCY_MS);
return tickets.reduce((sum, t) => sum + t.unread, 0);
},
postMessage: async (ticketId: number, body: PostMessageRequest): Promise<PostMessageResult> => {
await sleep(MOCK_LATENCY_MS);
const t = findTicket(ticketId);
+14
View File
@@ -26,6 +26,20 @@ export const TICKETS_LIST_STALE_TIME = 30 * 1000;
export const TICKET_THREAD_STALE_TIME = 15 * 1000;
export const TICKETS_GC_TIME = 5 * 60 * 1000;
/**
* Poll the open thread while it's mounted (§3.2) TanStack Query only polls while the query has an
* active observer, so this is automatically scoped to the thread screen. Proportionate to a support
* conversation, not a real-time chat; SSE can replace this later behind the same query.
*/
export const TICKET_THREAD_REFETCH_INTERVAL = 15 * 1000;
/**
* Photo-attachment affordance capability gate (§3.3) the composer's attachment button is designed but
* renders only when this is on. Default **off**: the object-storage linkage for ticket messages is a
* backend gap (REQ-060); flip once that contract lands no component change beyond this flag.
*/
export const TICKETS_ATTACHMENTS_ENABLED = false;
/** The admin global queue is a live worklist — a short stale window keeps it fresh without hammering. */
export const ADMIN_TICKETS_LIST_STALE_TIME = 20 * 1000;
@@ -0,0 +1,20 @@
import { useQueryClient } from '@tanstack/react-query';
import { ticketKeys } from '../keys';
import type { TicketDetail } from '../types';
/**
* The composer's "discard and retype" affordance on a failed bubble (§3.3) removes it from the cached
* thread so the caller can restore its text into the composer draft. Not a mutation: a message that never
* left the client has nothing to tell the server.
*/
export function useDiscardFailedMessage() {
const queryClient = useQueryClient();
return (ticketId: number, clientMessageId: string): void => {
const key = ticketKeys.detail(ticketId);
queryClient.setQueryData<TicketDetail>(key, (current) =>
current
? { ...current, messages: current.messages.filter((m) => m.clientMessageId !== clientMessageId) }
: current,
);
};
}
@@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ticketKeys } from '../keys';
import { ticketsApi } from '../apis';
import type { PostMessageResult, TicketDetail, TicketMessage } from '../types';
import type { PostMessageResult, TicketDetail } from '../types';
import { useTicketViewer } from './useTicketViewer';
interface PostMessageVars {
@@ -10,53 +10,65 @@ interface PostMessageVars {
clientMessageId: string;
}
interface PostMessageContext {
previous?: TicketDetail;
}
/**
* The optimistic message send the interaction that must feel instant (phase §3.5).
* The optimistic message send the interaction that must feel instant, and a failure must never lose the
* typed text (phase §3.3/§5 invariant).
*
* `onMutate` appends a **pending** bubble to `detail(id)` (after `cancelQueries` + a snapshot) so it shows
* immediately with a "sending" state. `onError` **rolls the thread back to the snapshot** (removing the
* pending bubble) and rejects the composer keeps the typed draft and offers retry (never retype). `onSuccess`
* replaces the pending bubble **by `clientMessageId`** with the server message (so it never double-renders).
* `onSettled` invalidates the thread + the inbox lists (last-activity/unread move). The composer clears the
* draft **only** in its own `onSuccess`.
* `onMutate` is idempotent on `clientMessageId`: a **fresh** send appends a pending bubble; a **retry** (the
* same id already sitting in the cache with `sendStatus: 'failed'`) flips it back to `sending` in place
* instead of appending a duplicate so retry and first-send are the same call. `onError` does **not** roll
* the thread back it flips the bubble to `sendStatus: 'failed'` and leaves it in the thread with its typed
* body, so the failed-bubble UI (retry / discard-and-retype) always has something to act on. `onSuccess`
* replaces the bubble **by `clientMessageId`** with the server message. `onSettled` invalidates the thread,
* the inbox lists (last-activity/unread move), and the chrome unread-total badge.
*/
export function usePostMessage(ticketId: number) {
const queryClient = useQueryClient();
const { role } = useTicketViewer();
const key = ticketKeys.detail(ticketId);
return useMutation<PostMessageResult, unknown, PostMessageVars, PostMessageContext>({
return useMutation<PostMessageResult, unknown, PostMessageVars>({
mutationFn: ({ body, clientMessageId }) => ticketsApi.postMessage(ticketId, { body, clientMessageId }),
onMutate: async ({ body, clientMessageId }) => {
const key = ticketKeys.detail(ticketId);
await queryClient.cancelQueries({ queryKey: key });
const previous = queryClient.getQueryData<TicketDetail>(key);
if (previous) {
const pending: TicketMessage = {
id: null,
clientMessageId,
ticketId,
body,
authorRole: role,
createdAt: new Date().toISOString(),
isMine: true,
sendStatus: 'sending',
};
queryClient.setQueryData<TicketDetail>(key, { ...previous, messages: [...previous.messages, pending] });
}
return { previous };
const current = queryClient.getQueryData<TicketDetail>(key);
if (!current) return;
const alreadyPending = current.messages.some((m) => m.clientMessageId === clientMessageId);
queryClient.setQueryData<TicketDetail>(key, {
...current,
messages: alreadyPending
? current.messages.map((m) =>
m.clientMessageId === clientMessageId ? { ...m, body, sendStatus: 'sending' as const } : m,
)
: [
...current.messages,
{
id: null,
clientMessageId,
ticketId,
body,
authorRole: role,
createdAt: new Date().toISOString(),
isMine: true,
sendStatus: 'sending' as const,
},
],
});
},
onError: (_err, _vars, context) => {
if (context?.previous) queryClient.setQueryData(ticketKeys.detail(ticketId), context.previous);
onError: (_err, { clientMessageId }) => {
const current = queryClient.getQueryData<TicketDetail>(key);
if (!current) return;
queryClient.setQueryData<TicketDetail>(key, {
...current,
messages: current.messages.map((m) =>
m.clientMessageId === clientMessageId ? { ...m, sendStatus: 'failed' as const } : m,
),
});
},
onSuccess: (result, { clientMessageId }) => {
const key = ticketKeys.detail(ticketId);
const current = queryClient.getQueryData<TicketDetail>(key);
if (current) {
queryClient.setQueryData<TicketDetail>(key, {
@@ -71,8 +83,9 @@ export function usePostMessage(ticketId: number) {
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ticketKeys.detail(ticketId) });
queryClient.invalidateQueries({ queryKey: key });
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
queryClient.invalidateQueries({ queryKey: ticketKeys.unreadTotal() });
},
});
}
@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys';
import { TICKETS_GC_TIME, TICKETS_LIST_STALE_TIME } from '../constants';
/**
* The chrome support-entry unread badge (§3.1) a single number summed across the caller's tickets, or
* `null` when there's no signal to show (the real path, until REQ-059 lands). Callers must render the
* badge only when this is a positive number never a fake "0 unread" placeholder. Gated on
* authentication, same posture as `useUnreadCount`.
*/
export function useSupportUnreadTotal(): number | null {
const isAuthenticated = useIsAuthenticated();
const { data } = useQuery({
queryKey: ticketKeys.unreadTotal(),
queryFn: () => ticketsApi.getUnreadTotal(),
enabled: isAuthenticated,
staleTime: TICKETS_LIST_STALE_TIME,
gcTime: TICKETS_GC_TIME,
});
return data ?? null;
}
@@ -1,14 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys';
import { TICKETS_GC_TIME, TICKET_THREAD_STALE_TIME } from '../constants';
import { TICKETS_GC_TIME, TICKET_THREAD_REFETCH_INTERVAL, TICKET_THREAD_STALE_TIME } from '../constants';
import { useTicketViewer } from './useTicketViewer';
/**
* The full ticket thread (header + participants + messages, user view internal messages already stripped).
* A single cached `detail(id)` entry: the contract returns the whole thread in one call (no message
* pagination). The viewer id (from `/me`, or the mock fallback) drives which bubbles are "mine".
* `usePostMessage` mutates this same entry optimistically.
* `usePostMessage` mutates this same entry optimistically. **Polls while mounted** (§3.2) TanStack Query
* scopes `refetchInterval` to active observers, so this only ticks while a thread screen is open; no
* `refetchIntervalInBackground` (the global polling posture stays polite §5).
*/
export function useTicket(ticketId: number | undefined) {
const { userId } = useTicketViewer();
@@ -18,5 +20,6 @@ export function useTicket(ticketId: number | undefined) {
enabled: ticketId != null && ticketId > 0,
staleTime: TICKET_THREAD_STALE_TIME,
gcTime: TICKETS_GC_TIME,
refetchInterval: TICKET_THREAD_REFETCH_INTERVAL,
});
}
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys';
import { TICKETS_GC_TIME, TICKET_THREAD_STALE_TIME } from '../constants';
import { TICKETS_GC_TIME, TICKET_THREAD_REFETCH_INTERVAL, TICKET_THREAD_STALE_TIME } from '../constants';
import type { TicketMessage } from '../types';
import { useTicketViewer } from './useTicketViewer';
@@ -9,7 +9,7 @@ import { useTicketViewer } from './useTicketViewer';
* Just the messages of a thread a `select` over the same `detail(id)` cache the header reads (mirrors the
* f8 `useBookingSessions = select over detail`). One network fetch feeds both; the message list re-renders on
* a new message without re-rendering the thread header. Optimistic sends mutate `detail(id)`, so the list
* updates instantly.
* updates instantly. Polls while mounted, same as `useTicket` (§3.2) both observers share one cache entry.
*/
export function useTicketThread(ticketId: number | undefined) {
const { userId } = useTicketViewer();
@@ -19,6 +19,7 @@ export function useTicketThread(ticketId: number | undefined) {
enabled: ticketId != null && ticketId > 0,
staleTime: TICKET_THREAD_STALE_TIME,
gcTime: TICKETS_GC_TIME,
refetchInterval: TICKET_THREAD_REFETCH_INTERVAL,
select: (detail): TicketMessage[] => detail.messages,
});
}
+2
View File
@@ -7,6 +7,8 @@ export { useTicket } from './hooks/useTicket';
export { useTicketThread } from './hooks/useTicketThread';
export { useOpenTicket } from './hooks/useOpenTicket';
export { usePostMessage } from './hooks/usePostMessage';
export { useSupportUnreadTotal } from './hooks/useSupportUnreadTotal';
export { useDiscardFailedMessage } from './hooks/useDiscardFailedMessage';
// Admin ticket lens (b15) — the global queue + admin thread (internal INCLUDED) + staff internal-note post.
export { useAdminTickets } from './hooks/useAdminTickets';
+3
View File
@@ -20,6 +20,9 @@ export const ticketKeys = {
details: () => [...ticketKeys.all, 'detail'] as const,
detail: (ticketId: number) => [...ticketKeys.details(), ticketId] as const,
/** The chrome support-badge total (§3.1) — its own tiny key so it never collides with a list page's cache. */
unreadTotal: () => [...ticketKeys.all, 'unread_total'] as const,
// Admin lens — a separate subtree so the internal-carrying admin caches never collide with the user
// caches above (and invalidating one never touches the other). Filters + page key the global queue.
adminLists: () => [...ticketKeys.all, 'admin', 'list'] as const,
+14 -5
View File
@@ -40,9 +40,10 @@ export type TicketAuthorRole = 'customer' | 'nurse' | 'admin' | 'system';
export type MessageSendStatus = 'sent' | 'sending' | 'failed';
/**
* A ticket row for the "My Tickets" inbox (`TicketSummaryDto`). `lastMessageAt`/`unreadCount` are **not**
* on the wire summary (REQ-028) they are optional and only the mock supplies them today; the inbox
* renders the unread indicator / last-activity time only when present, else falls back to `createdAt`.
* A ticket row for the "My Tickets" inbox (`TicketSummaryDto`). `lastMessageAt`/`unreadCount` are real
* (REQ-028, delivered). `lastMessagePreview`/`lastAuthorRole` are **not** on the wire summary yet
* (REQ-059, an extension of REQ-028) they are optional and only the mock supplies them today; the
* inbox card degrades gracefully (no preview line, no empty slot) when they're absent.
*/
export interface TicketSummary {
id: number;
@@ -53,10 +54,12 @@ export interface TicketSummary {
bookingId: number | null;
refundId: number | null;
createdAt: string;
/** REQ-028 gap — the wire summary has no last-activity timestamp; mock-only until delivered. */
lastMessageAt?: string | null;
/** REQ-028 gap — the wire summary has no unread count; mock-only until delivered. */
unreadCount?: number;
/** REQ-059 gap — the first ~80 chars of the last non-internal message; mock-only until delivered. */
lastMessagePreview?: string | null;
/** REQ-059 gap — the last message's author role (labels the preview "you:" vs "them:"); mock-only. */
lastAuthorRole?: TicketAuthorRole | null;
}
/** A participant on a ticket (`TicketParticipantDto`) — used to derive a message's author role. */
@@ -213,6 +216,12 @@ export interface TicketsApi {
*/
openTicket(body: OpenTicketRequest, viewerUserId?: number): Promise<OpenTicketResult>;
postMessage(ticketId: number, body: PostMessageRequest): Promise<PostMessageResult>;
/**
* The chrome support-badge total (§3.1) the sum of unread across the caller's tickets. There is no
* wire endpoint for this yet (REQ-059); the real implementation returns `null` (no signal the badge
* renders only when a signal exists) and the mock sums its own `unreadCount`s.
*/
getUnreadTotal(): Promise<number | null>;
/* Admin lens (b15). Distinct methods so the internal-carrying admin view can never be reached through a
* user-view call. `listAdminTickets` is the global queue (all tickets, filterable); `getAdminTicket`
+35
View File
@@ -41,3 +41,38 @@ export function formatShamsiDateTime(iso: string | Date, locale: string = 'fa'):
export function formatShamsiMonthYear(iso: string | Date, locale: string = 'fa'): string {
return formatShamsiDate(iso, locale, { year: 'numeric', month: 'long' });
}
/** Time only (hh:mm) — a chat bubble's stamp once its date lives on a separator instead (§3.2). */
export function formatShamsiTime(iso: string | Date, locale: string = 'fa'): string {
return formatShamsiDate(iso, locale, { hour: '2-digit', minute: '2-digit' });
}
/**
* Whole calendar days between two timestamps, comparing the **formatted date string** (not raw ms
* subtraction, which is wrong across a DST/timezone boundary) two timestamps format identically iff
* they fall on the same calendar day in the active calendar system.
*/
function isSameCalendarDay(a: Date, b: Date, locale: string): boolean {
return formatShamsiDate(a, locale) === formatShamsiDate(b, locale);
}
/**
* A chat-style day-separator label: the caller's «امروز»/«دیروز» for the last two calendar days, else the
* plain Shamsi date (e.g. «۲۵ تیر ۱۴۰۵»). Calendar-agnostic it never does its own Jalali arithmetic,
* only compares already-formatted date strings (see `isSameCalendarDay`).
*/
export function formatDaySeparator(
iso: string | Date,
locale: string,
todayLabel: string,
yesterdayLabel: string,
): string {
const date = iso instanceof Date ? iso : new Date(iso);
if (Number.isNaN(date.getTime())) return '';
const now = new Date();
if (isSameCalendarDay(date, now, locale)) return todayLabel;
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (isSameCalendarDay(date, yesterday, locale)) return yesterdayLabel;
return formatShamsiDate(date, locale);
}
+1
View File
@@ -11,4 +11,5 @@ export type LinkToPage = {
subtitle?: string; // Sub-title or secondary text to display
group?: string; // Already-translated section label; consecutive items sharing a group render under one subheader
onSelect?: () => void; // When set, BottomBar runs this instead of navigating (e.g. a "more" tab opening a drawer)
badgeCount?: number; // Renders a small unread-count badge on the item's icon when > 0 (e.g. the support entry)
};