ui phase 10
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user