ui phase 10
This commit is contained in:
@@ -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)}`;
|
||||
}
|
||||
@@ -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)' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()} />
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user