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