frontend phase 14
This commit is contained in:
@@ -80,6 +80,9 @@ import RoutineIcon from '@mui/icons-material/EventRepeatOutlined';
|
||||
import TasksIcon from '@mui/icons-material/ChecklistOutlined';
|
||||
import HistoryIcon from '@mui/icons-material/HistoryOutlined';
|
||||
import FamilyIcon from '@mui/icons-material/FamilyRestroomOutlined';
|
||||
// Messaging (tickets) & notifications (f14/b15): support inbox + the message-send action
|
||||
import SupportIcon from '@mui/icons-material/SupportAgentOutlined';
|
||||
import SendIcon from '@mui/icons-material/SendOutlined';
|
||||
|
||||
/**
|
||||
* List of all available Icon names
|
||||
@@ -167,4 +170,6 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
|
||||
tasks: TasksIcon,
|
||||
history: HistoryIcon,
|
||||
family: FamilyIcon,
|
||||
support: SupportIcon,
|
||||
send: SendIcon,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import { useBookingDetail, useCareInstructions } from '@/services/bookings';
|
||||
import { isBookingConfirmedOrBeyond } from '@/services/bookings/types';
|
||||
import ContactSupportDialog from './ContactSupportDialog';
|
||||
import EmergencyBanner from './EmergencyBanner';
|
||||
|
||||
export interface BookingSupportEntryProps {
|
||||
bookingId: number;
|
||||
role: 'customer' | 'nurse';
|
||||
}
|
||||
|
||||
/**
|
||||
* The support / emergency entry that hangs off the f8 booking-detail screen (page-local glue, mounted below
|
||||
* `BookingDetailView`). It **reuses the cached booking query** (same key/viewer as the view — no refetch) and,
|
||||
* for the nurse on a **post-confirmation** booking, the cached care-instructions read to surface the emergency
|
||||
* banner's `tel:` contact. It never fetches the booking again and never decrypts client-side.
|
||||
*
|
||||
* - **Nurse, confirmed+**: the emergency banner (playbook + `tel:` to the care emergency contact) **and** a
|
||||
* "Get support / Open ticket" CTA. Pre-confirmation → nothing (the care read is gated off).
|
||||
* - **Customer**: the "Get support / Open ticket" CTA only (the customer is not entitled to the clinical
|
||||
* emergency contact — it stays in the nurse-gated care read).
|
||||
*
|
||||
* The CTA opens a ticket pre-linked to this booking (`coordination`); the mock's coordination idempotency
|
||||
* **jumps to the existing coordination thread** rather than duplicating.
|
||||
* @component BookingSupportEntry
|
||||
*/
|
||||
const BookingSupportEntry: FunctionComponent<BookingSupportEntryProps> = ({ bookingId, role }) => {
|
||||
const t = useTranslations('tickets');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const { data: booking } = useBookingDetail(bookingId, role);
|
||||
const confirmed = !!booking && isBookingConfirmedOrBeyond(booking.status);
|
||||
const careEnabled = role === 'nurse' && confirmed;
|
||||
const { data: care } = useCareInstructions(bookingId, { enabled: careEnabled });
|
||||
|
||||
if (!booking) return null;
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5, maxWidth: 640, mx: 'auto', width: '100%' }}>
|
||||
{role === 'nurse' && confirmed ? (
|
||||
<EmergencyBanner
|
||||
contactName={care?.emergencyContactName}
|
||||
contactPhone={care?.emergencyContactPhone}
|
||||
onOpenTicket={() => setDialogOpen(true)}
|
||||
/>
|
||||
) : null}
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="support"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('open_from_booking')}
|
||||
</AppButton>
|
||||
<ContactSupportDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
role={role}
|
||||
bookingId={bookingId}
|
||||
defaultCategory="coordination"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingSupportEntry;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
|
||||
const mockMutate = jest.fn();
|
||||
const mockReset = jest.fn();
|
||||
|
||||
jest.mock('@/services/tickets', () => ({
|
||||
useOpenTicket: () => ({ mutate: mockMutate, isPending: false, isError: false, reset: mockReset }),
|
||||
}));
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
...jest.requireActual('next/navigation'),
|
||||
useRouter: () => ({ push: jest.fn() }),
|
||||
}));
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useLocale: () => 'fa',
|
||||
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 ContactSupportDialog from './ContactSupportDialog';
|
||||
|
||||
function renderDialog() {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ContactSupportDialog open onClose={() => {}} role="customer" defaultCategory="support" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<ContactSupportDialog/> component', () => {
|
||||
beforeEach(() => {
|
||||
mockMutate.mockClear();
|
||||
});
|
||||
|
||||
it('opens a ticket with the typed message on submit', () => {
|
||||
renderDialog();
|
||||
fireEvent.change(screen.getByLabelText(/پیام/), { target: { value: 'سوال من درباره ویزیت' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ارسال' }));
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ category: 'support', body: 'سوال من درباره ویزیت' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('disables submit until a message is typed', () => {
|
||||
renderDialog();
|
||||
expect(screen.getByRole('button', { name: 'ارسال' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import { AppIcon } from '@/components/common';
|
||||
import { ticketThreadPath } from '@/constants';
|
||||
import { useOpenTicket } from '@/services/tickets';
|
||||
import type { OpenTicketResult, TicketCategory } from '@/services/tickets/types';
|
||||
|
||||
export interface ContactSupportDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Which shell to route the new thread into on success. */
|
||||
role: 'customer' | 'nurse';
|
||||
/** Pre-link the ticket to a booking (from the booking-detail entry point). */
|
||||
bookingId?: number | null;
|
||||
/** Initial category — `coordination` from a booking, else `support`. */
|
||||
defaultCategory?: TicketCategory;
|
||||
}
|
||||
|
||||
/** Categories a user may open directly (refund is staff-linked; emergency uses its own flow). */
|
||||
const OPENABLE_CATEGORIES: TicketCategory[] = ['support', 'coordination'];
|
||||
|
||||
/**
|
||||
* The "Contact support" flow — open a new ticket (category select → subject/message → submit) and, on
|
||||
* success, show the new **`referenceCode`** with a link to the thread. Used from the inbox and the
|
||||
* booking-detail entry point (pre-linked to a `bookingId`; the mock's coordination idempotency **jumps to the
|
||||
* existing coordination thread** rather than duplicating). Domain 4xx keep the draft (never cleared on
|
||||
* failure). On success the inbox is invalidated, so the new ticket appears at the top without a manual refresh.
|
||||
* @component ContactSupportDialog
|
||||
*/
|
||||
const ContactSupportDialog: FunctionComponent<ContactSupportDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
role,
|
||||
bookingId,
|
||||
defaultCategory = 'support',
|
||||
}) => {
|
||||
const t = useTranslations('tickets');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const openTicket = useOpenTicket();
|
||||
|
||||
const [category, setCategory] = useState<TicketCategory>(defaultCategory);
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [created, setCreated] = useState<OpenTicketResult | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setSubject('');
|
||||
setBody('');
|
||||
setCreated(null);
|
||||
setCategory(defaultCategory);
|
||||
openTicket.reset();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed || openTicket.isPending) return;
|
||||
openTicket.mutate(
|
||||
{ category, subject: subject.trim() || null, body: trimmed, bookingId: bookingId ?? null },
|
||||
{ onSuccess: (result) => setCreated(result) },
|
||||
);
|
||||
};
|
||||
|
||||
const goToThread = () => {
|
||||
if (!created) return;
|
||||
router.push(`/${locale}${ticketThreadPath(role, created.ticketId)}`);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
|
||||
{created ? (
|
||||
<>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AppIcon icon="verified" size={22} color="var(--bal-success)" />
|
||||
{t('created_title')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
|
||||
{t('created_body')}
|
||||
</Typography>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)' }}>
|
||||
{t('ref_code_label')}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, letterSpacing: 0.5, direction: 'ltr' }}>
|
||||
{created.referenceCode}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<AppButton variant="text" color="primary" onClick={handleClose}>
|
||||
{t('close')}
|
||||
</AppButton>
|
||||
<AppButton variant="contained" color="primary" onClick={goToThread}>
|
||||
{t('view_thread')}
|
||||
</AppButton>
|
||||
</DialogActions>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogTitle>{t('new_ticket_title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack sx={{ gap: 2, pt: 1 }}>
|
||||
<TextField
|
||||
select
|
||||
label={t('category_label')}
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value as TicketCategory)}
|
||||
fullWidth
|
||||
size="small"
|
||||
>
|
||||
{OPENABLE_CATEGORIES.map((code) => (
|
||||
<MenuItem key={code} value={code}>
|
||||
{t(`category_${code}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label={t('subject_label')}
|
||||
value={subject}
|
||||
onChange={(event) => setSubject(event.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
label={t('message_label')}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
fullWidth
|
||||
required
|
||||
multiline
|
||||
minRows={3}
|
||||
size="small"
|
||||
/>
|
||||
{openTicket.isError ? (
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
|
||||
{t('open_failed')}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<AppButton variant="text" color="primary" onClick={handleClose}>
|
||||
{t('cancel')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={submit}
|
||||
disabled={openTicket.isPending || body.trim().length === 0}
|
||||
>
|
||||
{openTicket.isPending ? t('submitting') : t('submit')}
|
||||
</AppButton>
|
||||
</DialogActions>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactSupportDialog;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
|
||||
// next-intl mocked to read the REAL fa message file so the emergency playbook copy is pinned as it ships.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: (namespace: string) => (key: string, params?: Record<string, unknown>) => {
|
||||
const messages = jest.requireActual('../../../messages/fa.json') as Record<string, Record<string, string>>;
|
||||
let value = messages[namespace]?.[key] ?? key;
|
||||
if (params) for (const [k, v] of Object.entries(params)) value = value.replace(`{${k}}`, String(v));
|
||||
return value;
|
||||
},
|
||||
}));
|
||||
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import EmergencyBanner from './EmergencyBanner';
|
||||
|
||||
describe('<EmergencyBanner/> component', () => {
|
||||
it('surfaces a tel: click-to-call when an emergency contact phone is provided', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<EmergencyBanner contactName="زهرا موسوی" contactPhone="09121234567" onOpenTicket={() => {}} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('emergency-banner')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('emergency-call')).toHaveAttribute('href', 'tel:09121234567');
|
||||
});
|
||||
|
||||
it('degrades to the open-ticket path (no tel: link) when the contact cannot be loaded', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<EmergencyBanner onOpenTicket={() => {}} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('emergency-banner')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('emergency-call')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('invokes onOpenTicket from the "open a ticket" action', () => {
|
||||
const onOpenTicket = jest.fn();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<EmergencyBanner onOpenTicket={onOpenTicket} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(onOpenTicket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppIcon } from '@/components/common';
|
||||
|
||||
export interface EmergencyBannerProps {
|
||||
/** The emergency-contact name (from the booking's post-confirmation care instructions), if loaded. */
|
||||
contactName?: string | null;
|
||||
/** The emergency-contact phone — the **only** sanctioned click-to-call surface; omit if not available. */
|
||||
contactPhone?: string | null;
|
||||
/** Opens the support-ticket flow ("…then open a ticket"). */
|
||||
onOpenTicket: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The emergency **operational playbook** — not a real-time feature. "For emergencies, call the emergency
|
||||
* contact, then open a ticket." When a `contactPhone` is available (the nurse's post-confirmation care read),
|
||||
* it surfaces a `tel:` **click-to-call** — the platform's only sanctioned out-of-band surface; the platform
|
||||
* **never** exposes a general phone number or a contact directory. When the contact can't be loaded (e.g. the
|
||||
* customer/support-entry side, which is not entitled to the clinical read), the banner still renders with a
|
||||
* path to open a support ticket. `tel:` only — no VoIP/calling seam (telephony is out-of-platform by design).
|
||||
* The caller decides when to render it (post-confirmation only; nothing pre-confirmation).
|
||||
* @component EmergencyBanner
|
||||
*/
|
||||
const EmergencyBanner: FunctionComponent<EmergencyBannerProps> = ({ contactName, contactPhone, onOpenTicket }) => {
|
||||
const t = useTranslations('tickets');
|
||||
const phone = contactPhone?.trim() || null;
|
||||
const name = contactName?.trim() || null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-testid="emergency-banner"
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'var(--bal-error)',
|
||||
borderInlineStart: '4px solid var(--bal-error)',
|
||||
bgcolor: 'var(--bal-bg-paper)',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
|
||||
<AppIcon icon="emergency" size={22} color="var(--bal-error)" />
|
||||
<Stack sx={{ gap: 1, flexGrow: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'var(--bal-error)' }}>
|
||||
{t('emergency_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
|
||||
{t('emergency_body')}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center', mt: 0.5 }}>
|
||||
{phone ? (
|
||||
<Button
|
||||
component="a"
|
||||
href={`tel:${phone}`}
|
||||
variant="contained"
|
||||
data-testid="emergency-call"
|
||||
startIcon={<AppIcon icon="emergency" size={18} color="var(--bal-error-contrast)" />}
|
||||
sx={{
|
||||
bgcolor: 'var(--bal-error)',
|
||||
color: 'var(--bal-error-contrast)',
|
||||
fontWeight: 700,
|
||||
'&:hover': { bgcolor: 'var(--bal-error)', filter: 'brightness(0.95)' },
|
||||
}}
|
||||
>
|
||||
{name ? t('emergency_call', { name }) : t('emergency_call_generic')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={onOpenTicket}
|
||||
startIcon={<AppIcon icon="support" size={18} color="var(--bal-primary)" />}
|
||||
sx={{ color: 'var(--bal-primary)', fontWeight: 700 }}
|
||||
>
|
||||
{t('emergency_open_ticket')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmergencyBanner;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import MessageBubble from './MessageBubble';
|
||||
import type { TicketMessage } from '@/services/tickets/types';
|
||||
|
||||
const base: TicketMessage = {
|
||||
id: 1,
|
||||
ticketId: 10,
|
||||
body: 'سلام، ساعت ویزیت را تغییر دهید',
|
||||
authorRole: 'admin',
|
||||
createdAt: '2026-07-01T10:00:00Z',
|
||||
isMine: false,
|
||||
sendStatus: 'sent',
|
||||
};
|
||||
|
||||
function renderBubble(overrides: Partial<TicketMessage>) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<MessageBubble
|
||||
message={{ ...base, ...overrides }}
|
||||
authorLabel="پشتیبانی"
|
||||
timeLabel="۱۰:۰۰"
|
||||
sendingLabel="در حال ارسال…"
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<MessageBubble/> component', () => {
|
||||
it("aligns others' messages to the start and shows the author label + time", () => {
|
||||
renderBubble({ isMine: false });
|
||||
expect(screen.getByTestId('message-bubble')).toHaveAttribute('data-mine', 'false');
|
||||
expect(screen.getByText('پشتیبانی')).toBeInTheDocument();
|
||||
expect(screen.getByText('سلام، ساعت ویزیت را تغییر دهید')).toBeInTheDocument();
|
||||
expect(screen.getByText('۱۰:۰۰')).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');
|
||||
expect(screen.queryByText('پشتیبانی')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the sending label (not the time) while a message is pending', () => {
|
||||
renderBubble({ isMine: true, id: null, clientMessageId: 'c1', sendStatus: 'sending' });
|
||||
expect(screen.getByText('در حال ارسال…')).toBeInTheDocument();
|
||||
expect(screen.queryByText('۱۰:۰۰')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
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). */
|
||||
timeLabel: string;
|
||||
/** "در حال ارسال…" — shown while an optimistic message is still sending (in place of the time). */
|
||||
sendingLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* @component MessageBubble
|
||||
*/
|
||||
const MessageBubble: FunctionComponent<MessageBubbleProps> = ({ message, authorLabel, timeLabel, sendingLabel }) => {
|
||||
const isMine = message.isMine;
|
||||
const sending = message.sendStatus === 'sending';
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-testid="message-bubble"
|
||||
data-mine={isMine ? 'true' : 'false'}
|
||||
sx={{ display: 'flex', justifyContent: isMine ? 'flex-end' : 'flex-start', width: '100%' }}
|
||||
>
|
||||
<Stack
|
||||
sx={{
|
||||
maxWidth: '80%',
|
||||
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',
|
||||
// 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 ? (
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'var(--bal-primary)', mb: 0.25 }}>
|
||||
{authorLabel}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{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>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageBubble;
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
import { FunctionComponent, KeyboardEvent, useState } 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 { useTranslations } from 'next-intl';
|
||||
import { AppIcon } from '@/components/common';
|
||||
import { usePostMessage } from '@/services/tickets';
|
||||
|
||||
export interface MessageComposerProps {
|
||||
ticketId: number;
|
||||
/** 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.
|
||||
* @component MessageComposer
|
||||
*/
|
||||
const MessageComposer: FunctionComponent<MessageComposerProps> = ({ ticketId, 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 onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
) : 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>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageComposer;
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
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 { useRouter } from 'next/navigation';
|
||||
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 ContactSupportDialog from './ContactSupportDialog';
|
||||
import EmergencyBanner from './EmergencyBanner';
|
||||
import TicketListCard from './TicketListCard';
|
||||
|
||||
export interface TicketInboxScreenProps {
|
||||
role: 'customer' | 'nurse';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* @component TicketInboxScreen
|
||||
*/
|
||||
const TicketInboxScreen: FunctionComponent<TicketInboxScreenProps> = ({ role }) => {
|
||||
const t = useTranslations('tickets');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useMyTickets({});
|
||||
const tickets = data?.items ?? [];
|
||||
|
||||
const openThread = (ticketId: number) => router.push(`/${locale}${ticketThreadPath(role, ticketId)}`);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%' }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800 }}>
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon="support"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('contact_support')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
|
||||
<EmergencyBanner onOpenTicket={() => setDialogOpen(true)} />
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} variant="rounded" height={96} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center', py: 4 }}>
|
||||
<AppIcon icon="error" size={32} color="var(--bal-error)" />
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
|
||||
{t('error_body')}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" onClick={() => refetch()}>
|
||||
{t('retry')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
) : tickets.length === 0 ? (
|
||||
<Stack sx={{ gap: 1, alignItems: 'center', py: 6 }}>
|
||||
<AppIcon icon="support" size={36} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('empty_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
|
||||
{t('empty_body')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{tickets.map((ticket) => (
|
||||
<TicketListCard
|
||||
key={ticket.id}
|
||||
ticket={ticket}
|
||||
categoryLabel={t(`category_${ticket.category}`)}
|
||||
statusLabel={t(`status_${ticket.status}`)}
|
||||
timeLabel={formatShamsiDateTime(ticket.lastMessageAt ?? ticket.createdAt, locale)}
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<ContactSupportDialog open={dialogOpen} onClose={() => setDialogOpen(false)} role={role} defaultCategory="support" />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketInboxScreen;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import TicketListCard from './TicketListCard';
|
||||
import type { TicketSummary } from '@/services/tickets/types';
|
||||
|
||||
const base: TicketSummary = {
|
||||
id: 12,
|
||||
referenceCode: 'TKT-9F3K2A7Q',
|
||||
subject: 'هماهنگی ویزیت',
|
||||
status: 'open',
|
||||
category: 'coordination',
|
||||
bookingId: 5001,
|
||||
refundId: null,
|
||||
createdAt: '2026-07-01T10:00:00Z',
|
||||
lastMessageAt: '2026-07-01T12:00:00Z',
|
||||
unreadCount: 2,
|
||||
};
|
||||
|
||||
function renderCard(overrides: Partial<TicketSummary>, extra: Partial<React.ComponentProps<typeof TicketListCard>> = {}) {
|
||||
const onOpen = jest.fn();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TicketListCard
|
||||
ticket={{ ...base, ...overrides }}
|
||||
categoryLabel="هماهنگی"
|
||||
statusLabel="باز"
|
||||
timeLabel="۱۲:۰۰"
|
||||
linkedBookingLabel={overrides.bookingId === null ? null : 'رزرو #۵۰۰۱'}
|
||||
linkedRefundLabel={null}
|
||||
onOpen={onOpen}
|
||||
{...extra}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return onOpen;
|
||||
}
|
||||
|
||||
describe('<TicketListCard/> component', () => {
|
||||
it('shows the reference code prominently', () => {
|
||||
renderCard({});
|
||||
expect(screen.getByText('TKT-9F3K2A7Q')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the unread indicator with the count when there is unread activity', () => {
|
||||
renderCard({ unreadCount: 2 });
|
||||
expect(screen.getByTestId('ticket-unread')).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('hides the unread indicator when nothing is unread', () => {
|
||||
renderCard({ unreadCount: 0 });
|
||||
expect(screen.queryByTestId('ticket-unread')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the linked-booking hint only when the ticket is booking-linked', () => {
|
||||
renderCard({});
|
||||
expect(screen.getByText('رزرو #۵۰۰۱')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no linked hint for a pure support ticket', () => {
|
||||
renderCard({ bookingId: null, refundId: null });
|
||||
expect(screen.queryByText('رزرو #۵۰۰۱')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onOpen when clicked', () => {
|
||||
const onOpen = renderCard({});
|
||||
fireEvent.click(screen.getByTestId('ticket-card'));
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
import { FunctionComponent } 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 StatusChip from '@/components/StatusChip';
|
||||
import { AppIcon } from '@/components/common';
|
||||
import type { TicketSummary } from '@/services/tickets/types';
|
||||
import { ticketCategoryIcon, ticketStatusKind } from './statusKind';
|
||||
|
||||
export interface TicketListCardProps {
|
||||
ticket: TicketSummary;
|
||||
/** Translated category label (also the subject fallback when `subject` is null). */
|
||||
categoryLabel: string;
|
||||
/** Translated status label. */
|
||||
statusLabel: string;
|
||||
/** Pre-formatted last-activity time — the caller owns locale. */
|
||||
timeLabel: string;
|
||||
/** e.g. "رزرو #۵۰۰۱" — rendered only when the ticket is booking-linked (null-safe). */
|
||||
linkedBookingLabel?: string | null;
|
||||
/** e.g. "بازپرداخت #۹۰۰۱" — rendered only when refund-linked (null-safe). */
|
||||
linkedRefundLabel?: string | null;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* @component TicketListCard
|
||||
*/
|
||||
const TicketListCard: FunctionComponent<TicketListCardProps> = ({
|
||||
ticket,
|
||||
categoryLabel,
|
||||
statusLabel,
|
||||
timeLabel,
|
||||
linkedBookingLabel,
|
||||
linkedRefundLabel,
|
||||
onOpen,
|
||||
}) => {
|
||||
const hasUnread = (ticket.unreadCount ?? 0) > 0;
|
||||
const subject = ticket.subject?.trim() || categoryLabel;
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={onOpen}
|
||||
data-testid="ticket-card"
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'start',
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'var(--bal-bg-paper)',
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: 0.75, width: '100%' }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<AppIcon icon={ticketCategoryIcon(ticket.category)} size={18} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: hasUnread ? 800 : 600, flexGrow: 1 }}>
|
||||
{subject}
|
||||
</Typography>
|
||||
{hasUnread ? (
|
||||
<Box
|
||||
data-testid="ticket-unread"
|
||||
sx={{
|
||||
minWidth: 20,
|
||||
height: 20,
|
||||
px: 0.5,
|
||||
borderRadius: 10,
|
||||
bgcolor: 'var(--bal-primary)',
|
||||
color: 'var(--bal-primary-contrast)',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{ticket.unreadCount}
|
||||
</Box>
|
||||
) : null}
|
||||
<StatusChip status={ticketStatusKind(ticket.status)} label={statusLabel} />
|
||||
</Stack>
|
||||
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 700, letterSpacing: 0.5, color: 'var(--bal-primary)', direction: 'ltr', alignSelf: 'flex-start' }}
|
||||
>
|
||||
{ticket.referenceCode}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
{linkedBookingLabel ? (
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)' }}>
|
||||
{linkedBookingLabel}
|
||||
</Typography>
|
||||
) : null}
|
||||
{linkedRefundLabel ? (
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)' }}>
|
||||
{linkedRefundLabel}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)', marginInlineStart: 'auto' }}>
|
||||
{timeLabel}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketListCard;
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
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 { useTicketThread } from '@/services/tickets';
|
||||
import { formatShamsiDateTime } from '@/utils';
|
||||
import MessageBubble from './MessageBubble';
|
||||
import { authorLabelKey } from './authorLabel';
|
||||
|
||||
export interface TicketMessageListProps {
|
||||
ticketId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* @component TicketMessageList
|
||||
*/
|
||||
const TicketMessageList: FunctionComponent<TicketMessageListProps> = ({ ticketId }) => {
|
||||
const t = useTranslations('tickets');
|
||||
const locale = useLocale();
|
||||
const { data: messages, isLoading } = useTicketThread(ticketId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} variant="rounded" height={56} sx={{ width: '70%', alignSelf: i % 2 ? 'flex-end' : 'flex-start' }} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
return (
|
||||
<Stack sx={{ gap: 0.5, alignItems: 'center', py: 4 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('thread_empty_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
|
||||
{t('thread_empty_body')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketMessageList;
|
||||
@@ -0,0 +1,128 @@
|
||||
'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';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
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 { ticketCategoryIcon, ticketStatusKind } from './statusKind';
|
||||
|
||||
export interface TicketThreadScreenProps {
|
||||
role: 'customer' | 'nurse';
|
||||
ticketId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A ticket thread — the role-aware bubble stream + a sticky composer. The header shows the **`referenceCode`
|
||||
* prominently**, the category, the status chip, and (when present) a linked-booking chip. Closed tickets show
|
||||
* a notice instead of the composer. **No internal-note affordance anywhere** (§5). Shared by the customer and
|
||||
* nurse thread pages (role decides the back/booking route + composer entitlement, not the components).
|
||||
* @component TicketThreadScreen
|
||||
*/
|
||||
const TicketThreadScreen: FunctionComponent<TicketThreadScreenProps> = ({ role, ticketId }) => {
|
||||
const t = useTranslations('tickets');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { data: ticket, isLoading, isError, refetch } = useTicket(ticketId);
|
||||
|
||||
const goBack = () => router.push(`/${locale}${ticketsBasePath(role)}`);
|
||||
const goToBooking = (bookingId: number) => {
|
||||
const path = role === 'nurse' ? nurseBookingDetailPath(bookingId) : `${ROUTES.BOOKINGS}/${bookingId}`;
|
||||
router.push(`/${locale}${path}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2, maxWidth: 720, mx: 'auto', width: '100%' }}>
|
||||
<AppButton variant="text" color="primary" onClick={goBack} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
||||
{t('back_to_tickets')}
|
||||
</AppButton>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Skeleton variant="rounded" height={72} />
|
||||
<Skeleton variant="rounded" height={56} sx={{ width: '70%' }} />
|
||||
<Skeleton variant="rounded" height={56} sx={{ width: '70%', alignSelf: 'flex-end' }} />
|
||||
</Stack>
|
||||
) : isError || !ticket ? (
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center', py: 4 }}>
|
||||
<AppIcon icon="error" size={32} color="var(--bal-error)" />
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
|
||||
{t('thread_error')}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" onClick={() => refetch()}>
|
||||
{t('retry')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<AppIcon icon={ticketCategoryIcon(ticket.category)} size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, flexGrow: 1 }}>
|
||||
{ticket.subject?.trim() || t(`category_${ticket.category}`)}
|
||||
</Typography>
|
||||
<StatusChip status={ticketStatusKind(ticket.status)} label={t(`status_${ticket.status}`)} />
|
||||
</Stack>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)' }}>
|
||||
{t('ref_code_label')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, letterSpacing: 0.5, direction: 'ltr' }}>
|
||||
{ticket.referenceCode}
|
||||
</Typography>
|
||||
{ticket.bookingId != null ? (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={<AppIcon icon="bookings" size={14} color="var(--bal-primary)" />}
|
||||
label={t('linked_booking', { id: ticket.bookingId })}
|
||||
onClick={() => goToBooking(ticket.bookingId as number)}
|
||||
sx={{ marginInlineStart: 'auto' }}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketThreadScreen;
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { TicketAuthorRole } from '@/services/tickets/types';
|
||||
|
||||
/** The i18n key (in the `tickets` namespace) for a message author's display label. `admin` reads as "support". */
|
||||
export function authorLabelKey(role: TicketAuthorRole): string {
|
||||
switch (role) {
|
||||
case 'nurse':
|
||||
return 'author_nurse';
|
||||
case 'admin':
|
||||
return 'author_support';
|
||||
case 'customer':
|
||||
return 'author_customer';
|
||||
case 'system':
|
||||
default:
|
||||
return 'author_system';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Messaging (tickets) composites — import from `@/components/messaging` (a subfolder barrel, like
|
||||
* `@/components/booking`). Screens (`TicketInboxScreen`/`TicketThreadScreen`) are shared by the customer and
|
||||
* nurse route pages; the pure composites (`MessageBubble`/`TicketListCard`/`EmergencyBanner`) carry co-located
|
||||
* tests.
|
||||
*/
|
||||
export { default as MessageBubble } from './MessageBubble';
|
||||
export type { MessageBubbleProps } from './MessageBubble';
|
||||
export { default as MessageComposer } from './MessageComposer';
|
||||
export { default as TicketMessageList } from './TicketMessageList';
|
||||
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 ContactSupportDialog } from './ContactSupportDialog';
|
||||
export type { ContactSupportDialogProps } from './ContactSupportDialog';
|
||||
export { default as TicketInboxScreen } from './TicketInboxScreen';
|
||||
export { default as TicketThreadScreen } from './TicketThreadScreen';
|
||||
export { default as BookingSupportEntry } from './BookingSupportEntry';
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { StatusKind } from '@/components/StatusChip';
|
||||
import type { TicketCategory, TicketStatus } from '@/services/tickets/types';
|
||||
|
||||
/** Ticket status → the semantic `StatusChip` kind. An open ticket reads as active/info, a closed one neutral. */
|
||||
export function ticketStatusKind(status: TicketStatus): StatusKind {
|
||||
return status === 'open' ? 'info' : 'neutral';
|
||||
}
|
||||
|
||||
/** The `AppIcon` name for a ticket category (inbox card + thread header). */
|
||||
export function ticketCategoryIcon(category: TicketCategory): string {
|
||||
switch (category) {
|
||||
case 'coordination':
|
||||
return 'bookings';
|
||||
case 'refund':
|
||||
return 'payment';
|
||||
case 'emergency':
|
||||
return 'emergency';
|
||||
case 'support':
|
||||
default:
|
||||
return 'support';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { notificationsPath } from '@/constants';
|
||||
import { useUnreadCount } from '@/services/notifications';
|
||||
import NotificationBellView from './NotificationBellView';
|
||||
|
||||
export interface NotificationBellProps {
|
||||
/** The shell the bell lives in — decides which notification center it opens. */
|
||||
role: 'customer' | 'nurse';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @component NotificationBell
|
||||
*/
|
||||
const NotificationBell: FunctionComponent<NotificationBellProps> = ({ role }) => {
|
||||
const count = useUnreadCount();
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('notifications');
|
||||
|
||||
return (
|
||||
<NotificationBellView
|
||||
count={count}
|
||||
label={t('bell_aria', { count })}
|
||||
onClick={() => router.push(`/${locale}${notificationsPath(role)}`)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationBell;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import NotificationBellView from './NotificationBellView';
|
||||
|
||||
describe('<NotificationBellView/> component', () => {
|
||||
it('renders the unread count + aria label and fires onClick', () => {
|
||||
const onClick = jest.fn();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<NotificationBellView count={3} label="۳ اعلان خواندهنشده" onClick={onClick} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const bell = screen.getByTestId('notification-bell');
|
||||
expect(bell).toHaveAttribute('aria-label', '۳ اعلان خواندهنشده');
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
fireEvent.click(bell);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hides the badge when there are no unread notifications', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<NotificationBellView count={0} label="اعلانها" onClick={() => {}} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { AppIcon } from '@/components/common';
|
||||
|
||||
export interface NotificationBellViewProps {
|
||||
/** Unread count for the badge (0 hides the badge). */
|
||||
count: number;
|
||||
/** Accessible label (already translated, includes the count). */
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* @component NotificationBellView
|
||||
*/
|
||||
const NotificationBellView: FunctionComponent<NotificationBellViewProps> = ({ count, label, onClick }) => {
|
||||
return (
|
||||
<IconButton onClick={onClick} aria-label={label} data-testid="notification-bell" color="inherit">
|
||||
<Badge
|
||||
badgeContent={count}
|
||||
max={99}
|
||||
overlap="circular"
|
||||
sx={{ '& .MuiBadge-badge': { bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)', fontWeight: 700 } }}
|
||||
>
|
||||
<AppIcon icon="notifications" />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationBellView;
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
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 { useRouter } from 'next/navigation';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import { AppIcon } from '@/components/common';
|
||||
import { NOTIFICATIONS_PAGE_SIZE } from '@/services/notifications/constants';
|
||||
import {
|
||||
notificationDeepLink,
|
||||
useMarkAllRead,
|
||||
useMarkNotificationRead,
|
||||
useNotifications,
|
||||
} from '@/services/notifications';
|
||||
import type { AppNotification } from '@/services/notifications/types';
|
||||
import { formatShamsiDateTime } from '@/utils';
|
||||
import NotificationRow from './NotificationRow';
|
||||
|
||||
export interface NotificationCenterProps {
|
||||
role: 'customer' | 'nurse';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* @component NotificationCenter
|
||||
*/
|
||||
const NotificationCenter: FunctionComponent<NotificationCenterProps> = ({ role }) => {
|
||||
const t = useTranslations('notifications');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const [limit, setLimit] = useState(NOTIFICATIONS_PAGE_SIZE);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useNotifications(limit);
|
||||
const markRead = useMarkNotificationRead();
|
||||
const markAll = useMarkAllRead();
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
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}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%' }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800 }}>
|
||||
{t('title')}
|
||||
</Typography>
|
||||
{hasUnread ? (
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
onClick={() => markAll.mutate()}
|
||||
disabled={markAll.isPending}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('mark_all_read')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} variant="rounded" height={72} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center', py: 4 }}>
|
||||
<AppIcon icon="error" size={32} color="var(--bal-error)" />
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
|
||||
{t('error_body')}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" onClick={() => refetch()}>
|
||||
{t('retry')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
) : items.length === 0 ? (
|
||||
<Stack sx={{ gap: 1, alignItems: 'center', py: 6 }}>
|
||||
<AppIcon icon="verified" size={36} color="var(--bal-success)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('empty_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
|
||||
{t('empty_body')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{items.map((notification) => (
|
||||
<NotificationRow
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
timeLabel={formatShamsiDateTime(notification.createdAt, locale)}
|
||||
onOpen={() => openNotification(notification)}
|
||||
/>
|
||||
))}
|
||||
{total > items.length ? (
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
onClick={() => setLimit((current) => current + NOTIFICATIONS_PAGE_SIZE)}
|
||||
disabled={isFetching}
|
||||
sx={{ alignSelf: 'center' }}
|
||||
>
|
||||
{t('load_more')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationCenter;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import NotificationRow from './NotificationRow';
|
||||
import type { AppNotification } from '@/services/notifications/types';
|
||||
|
||||
const base: AppNotification = {
|
||||
id: 1,
|
||||
type: 'booking_confirmed',
|
||||
title: 'رزرو شما تایید شد',
|
||||
body: 'ویزیت شما ثبت شد',
|
||||
isRead: false,
|
||||
createdAt: '2026-07-01T10:00:00Z',
|
||||
data: { kind: 'booking', bookingId: 5001 },
|
||||
};
|
||||
|
||||
function renderRow(overrides: Partial<AppNotification>) {
|
||||
const onOpen = jest.fn();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<NotificationRow notification={{ ...base, ...overrides }} timeLabel="۱۰:۰۰" onOpen={onOpen} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return onOpen;
|
||||
}
|
||||
|
||||
describe('<NotificationRow/> component', () => {
|
||||
it('emphasises an unread notification with a dot', () => {
|
||||
renderRow({ isRead: false });
|
||||
expect(screen.getByTestId('notification-row')).toHaveAttribute('data-unread', 'true');
|
||||
expect(screen.getByTestId('notification-unread-dot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no unread dot once read', () => {
|
||||
renderRow({ isRead: true });
|
||||
expect(screen.getByTestId('notification-row')).toHaveAttribute('data-unread', 'false');
|
||||
expect(screen.queryByTestId('notification-unread-dot')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the server-provided title and body', () => {
|
||||
renderRow({});
|
||||
expect(screen.getByText('رزرو شما تایید شد')).toBeInTheDocument();
|
||||
expect(screen.getByText('ویزیت شما ثبت شد')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onOpen when clicked', () => {
|
||||
const onOpen = renderRow({});
|
||||
fireEvent.click(screen.getByTestId('notification-row'));
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
import { FunctionComponent } 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';
|
||||
|
||||
export interface NotificationRowProps {
|
||||
notification: AppNotification;
|
||||
/** Pre-formatted Shamsi time — the caller owns locale. */
|
||||
timeLabel: string;
|
||||
/** Marks the notification read (optimistic) and, when it deep-links, 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).
|
||||
* @component NotificationRow
|
||||
*/
|
||||
const NotificationRow: FunctionComponent<NotificationRowProps> = ({ notification, timeLabel, onOpen }) => {
|
||||
const unread = !notification.isRead;
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={onOpen}
|
||||
data-testid="notification-row"
|
||||
data-unread={unread ? 'true' : 'false'}
|
||||
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)',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationRow;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Notification composites — import from `@/components/notifications`. `NotificationBell` is the chrome
|
||||
* container (subscribes to the polling count); `NotificationCenter` is the shared page body; the pure
|
||||
* `NotificationBellView`/`NotificationRow` carry co-located tests.
|
||||
*/
|
||||
export { default as NotificationBell } from './NotificationBell';
|
||||
export type { NotificationBellProps } from './NotificationBell';
|
||||
export { default as NotificationBellView } from './NotificationBellView';
|
||||
export type { NotificationBellViewProps } from './NotificationBellView';
|
||||
export { default as NotificationRow } from './NotificationRow';
|
||||
export type { NotificationRowProps } from './NotificationRow';
|
||||
export { default as NotificationCenter } from './NotificationCenter';
|
||||
export type { NotificationCenterProps } from './NotificationCenter';
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { NotificationData } from '@/services/notifications/types';
|
||||
|
||||
/** The `AppIcon` name for a notification, by its parsed deep-link class. */
|
||||
export function notificationIcon(kind: NotificationData['kind']): string {
|
||||
switch (kind) {
|
||||
case 'booking':
|
||||
return 'bookings';
|
||||
case 'refund':
|
||||
return 'payment';
|
||||
case 'payout':
|
||||
return 'earnings';
|
||||
case 'ticket':
|
||||
return 'support';
|
||||
case 'nurse_profile':
|
||||
return 'star';
|
||||
case 'none':
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user