Skip to content
11 changes: 11 additions & 0 deletions apps/admin/src/app/(shell)/support/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { SupportContainer } from '@/containers/SupportContainer';
import { Suspense } from 'react';

/** A-13, the messages of « Nous écrire ». The open message is read from the address, in the browser. */
export default function SupportPage() {
return (
<Suspense>
<SupportContainer />
</Suspense>
);
}
16 changes: 16 additions & 0 deletions apps/admin/src/components/support/SupportActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { messages } from '@/messages/fr';
import { Button } from '@widoo/ui';

type SupportActionsProps = {
onMarkHandled: () => void;
isBusy: boolean;
};

/** The action of the card of a message that is still to handle (A-13): mark it handled. */
export function SupportActions({ onMarkHandled, isBusy }: SupportActionsProps) {
return (
<Button onClick={onMarkHandled} disabled={isBusy}>
{messages.support.panel.markHandled}
</Button>
);
}
66 changes: 66 additions & 0 deletions apps/admin/src/components/support/SupportPanelContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { formatFullDate } from '@/lib/dates';
import { messages } from '@/messages/fr';
import type { AdminSupportMessage } from '@widoo/shared';
import Link from 'next/link';
import type { ReactNode } from 'react';
import { SupportStatusBadge } from './SupportStatusBadge';

const texts = messages.support.panel;

function Line({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex gap-12 text-body-s">
<dt className="shrink-0 text-muted-foreground">{label}</dt>
<dd className="min-w-0 flex-1 break-words text-right">{children}</dd>
</div>
);
}

type SupportPanelContentProps = {
message: AdminSupportMessage;
/** Address of the card of an account (A-06), to open it from the message. */
accountPathOf: (userId: string) => string;
};

/** A message in the detail panel (A-13): what was written, who wrote it, and what the app joined. */
export function SupportPanelContent({ message, accountPathOf }: SupportPanelContentProps) {
return (
<div className="flex flex-col gap-16">
<dl className="flex flex-col gap-8">
<Line label={texts.status}>
<SupportStatusBadge isHandled={message.handledAt !== null} />
</Line>
<Line label={texts.sentAt}>{formatFullDate(new Date(message.createdAt))}</Line>
{message.handledAt ? (
<Line label={texts.handledAt}>{formatFullDate(new Date(message.handledAt))}</Line>
) : null}
<Line label={texts.account}>
{message.author ? (
<Link href={accountPathOf(message.author.id)} className="text-blue-ink underline">
{message.author.firstName ?? messages.support.unnamedAccount}
</Link>
) : (
<span className="text-muted-foreground">{messages.support.noAccount}</span>
)}
</Line>
<Line label={texts.replyEmail}>
<a href={`mailto:${message.replyEmail}`} className="text-blue-ink underline">
{message.replyEmail}
</a>
</Line>
<Line label={texts.appVersion}>{message.appVersion}</Line>
<Line label={texts.device}>{message.device}</Line>
<Line label={texts.locale}>{texts.locales[message.locale]}</Line>
</dl>
<section className="flex flex-col gap-8">
<h3 className="text-label-strong">{texts.message}</h3>
<p className="whitespace-pre-line break-words text-body-s">{message.message}</p>
</section>
{message.handledAt ? null : (
<p className="rounded-photo bg-secondary p-12 text-body-s text-muted-foreground">
{texts.howToAnswer(message.locale)}
</p>
)}
</div>
);
}
11 changes: 11 additions & 0 deletions apps/admin/src/components/support/SupportStatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { messages } from '@/messages/fr';
import { StatusBadge } from '@widoo/ui';

/** To handle in blue, handled in green. */
export function SupportStatusBadge({ isHandled }: { isHandled: boolean }) {
return (
<StatusBadge tone={isHandled ? 'success' : 'info'}>
{isHandled ? messages.support.statuses.handled : messages.support.statuses.unhandled}
</StatusBadge>
);
}
64 changes: 64 additions & 0 deletions apps/admin/src/components/support/SupportTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { formatRelativeDate } from '@/lib/dates';
import { messages } from '@/messages/fr';
import type { AdminSupportItem } from '@widoo/shared';
import { Table, TableCell, TableHeaderCell, TableRow, TableSkeleton } from '@widoo/ui';
import { SupportStatusBadge } from './SupportStatusBadge';

const texts = messages.support;
const columnCount = 4;

type SupportTableProps = {
/** The messages of the page; ignored while `isLoading`. */
items: AdminSupportItem[];
isLoading: boolean;
/** The message whose card is open in the panel. */
openId: string | null;
onOpen: (message: AdminSupportItem) => void;
};

/** The messages of « Nous écrire » as a table (A-13): subject, account, date, status. */
export function SupportTable({ items, isLoading, openId, onOpen }: SupportTableProps) {
return (
<Table>
<thead>
<tr>
<TableHeaderCell>{texts.columns.subject}</TableHeaderCell>
<TableHeaderCell>{texts.columns.account}</TableHeaderCell>
<TableHeaderCell>{texts.columns.date}</TableHeaderCell>
<TableHeaderCell>{texts.columns.status}</TableHeaderCell>
</tr>
</thead>
<tbody>
{isLoading ? (
<TableSkeleton rows={8} columns={columnCount} label={messages.list.loading} />
) : (
items.map((message) => (
<TableRow
key={message.id}
onOpen={() => onOpen(message)}
isOpen={message.id === openId}
>
<TableCell className="py-8">
<span className="flex flex-col">
<span className="text-control">{messages.users.topics[message.topic]}</span>
<span className="text-label text-muted-foreground">{message.preview}</span>
</span>
</TableCell>
<TableCell>
{message.author ? (
(message.author.firstName ?? texts.unnamedAccount)
) : (
<span className="text-muted-foreground">{texts.noAccount}</span>
)}
</TableCell>
<TableCell>{formatRelativeDate(new Date(message.createdAt))}</TableCell>
<TableCell>
<SupportStatusBadge isHandled={message.handledAt !== null} />
</TableCell>
</TableRow>
))
)}
</tbody>
</Table>
);
}
24 changes: 24 additions & 0 deletions apps/admin/src/config/support-list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { toSupportParams } from './support-list';

const base = { search: '', sort: { column: 'date', direction: 'asc' }, cursor: null } as const;

describe('toSupportParams', () => {
it('asks for every message without a filter', () => {
expect(toSupportParams({ ...base, filters: {} })).toEqual({
status: undefined,
cursor: undefined,
});
});

it('reads the status and the page', () => {
expect(toSupportParams({ ...base, filters: { statut: 'unhandled' }, cursor: 'NTA' })).toEqual({
status: 'unhandled',
cursor: 'NTA',
});
});

it('drops a status it does not know', () => {
expect(toSupportParams({ ...base, filters: { statut: 'answered' } }).status).toBeUndefined();
});
});
21 changes: 21 additions & 0 deletions apps/admin/src/config/support-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { ListQuery, ListQueryOptions } from '@/lib/list-query';
import { adminSupportStatuses, type AdminSupportQuery } from '@widoo/shared';

/** The messages are filtered by their status; the ones to handle come first, and cannot be sorted. */
export type SupportFilter = 'statut';
export type SupportColumn = 'date';
export type SupportListQuery = ListQuery<SupportFilter, SupportColumn>;

export const supportListOptions: ListQueryOptions<SupportFilter, SupportColumn> = {
filters: ['statut'],
sortableColumns: [],
defaultSort: { column: 'date', direction: 'asc' },
};

/** What to ask the API for the list shown at an address; a status it does not know is dropped. */
export function toSupportParams(query: SupportListQuery): Partial<AdminSupportQuery> {
return {
status: adminSupportStatuses.find((status) => status === query.filters.statut),
cursor: query.cursor ?? undefined,
};
}
166 changes: 166 additions & 0 deletions apps/admin/src/containers/SupportContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
'use client';

import { PageMessage } from '@/components/states/PageMessage';
import { SupportActions } from '@/components/support/SupportActions';
import { SupportPanelContent } from '@/components/support/SupportPanelContent';
import { SupportStatusBadge } from '@/components/support/SupportStatusBadge';
import { SupportTable } from '@/components/support/SupportTable';
import { sectionPaths } from '@/config/navigation';
import { supportListOptions, toSupportParams } from '@/config/support-list';
import { useCardOnScreen } from '@/hooks/useCardOnScreen';
import { useListQuery } from '@/hooks/useListQuery';
import { useSupportActions } from '@/hooks/useSupportActions';
import { useRemoteData } from '@/hooks/useRemoteData';
import { getSessionClients } from '@/lib/auth/clients';
import { serializeListQuery } from '@/lib/list-query';
import { messages } from '@/messages/fr';
import { adminSupportStatuses, type AdminSupportMessage } from '@widoo/shared';
import { Button, DetailPanel, DetailPanelSkeleton, FilterMenu, Pagination } from '@widoo/ui';
import { useRouter, useSearchParams } from 'next/navigation';

const texts = messages.support;
/** Parameter of the address that holds the message whose card is open next to the list. */
const openMessageParam = 'message';

const statusOptions = adminSupportStatuses.map((status) => ({
value: status,
label: texts.statuses[status],
}));

/**
* A-13, the messages of « Nous écrire »: the list the address asks for, the ones to handle first,
* and next to it the card of the message the address names.
*/
export function SupportContainer() {
const router = useRouter();
const openId = useSearchParams().get(openMessageParam);
const { query, handleFilter, handlePage } = useListQuery(supportListOptions);
const listAddress = serializeListQuery(query, supportListOptions);
const messagesList = useRemoteData(listAddress, (signal) =>
getSessionClients().api.getAdminSupport(toSupportParams(query), signal),
);
const card = useRemoteData(`card:${openId ?? ''}`, (signal) =>
openId ? getSessionClients().api.getAdminSupportMessage(openId, signal) : Promise.resolve(null),
);
const { fresh: openMessage, isLoading: isCardLoading, stale } = useCardOnScreen(openId, card);
const data = messagesList.status === 'ready' ? messagesList.data : undefined;
const actions = useSupportActions(() => {
if (messagesList.status !== 'loading') messagesList.reload();
if (card.status !== 'loading') card.reload();
});

/** The address of the list as it is, with the card of `messageId` open, or none. */
const showMessage = (messageId: string | null) => {
const params = new URLSearchParams(listAddress);
if (messageId) {
params.set(openMessageParam, messageId);
}
const address = params.toString();
router.replace(address ? `${sectionPaths.support}?${address}` : sectionPaths.support, {
scroll: false,
});
};

const renderCard = (message: AdminSupportMessage) => (
<DetailPanel
key={message.id}
title={messages.users.topics[message.topic]}
status={<SupportStatusBadge isHandled={message.handledAt !== null} />}
closeLabel={messages.confirm.close}
onClose={() => showMessage(null)}
actions={
message.handledAt === null ? (
<SupportActions
onMarkHandled={() => void actions.markHandled(message.id)}
isBusy={actions.isBusy}
/>
) : undefined
}
>
<SupportPanelContent
message={message}
accountPathOf={(userId) =>
`${sectionPaths.users}?${new URLSearchParams({ compte: userId }).toString()}`
}
/>
</DetailPanel>
);

/** The card open next to the list: the fresh one, or the previous one while the next loads. */
const renderPanel = () => {
if (openMessage) {
return renderCard(openMessage);
}
if (!isCardLoading) {
return null;
}
return stale ? (
<div inert className="contents">
{renderCard(stale)}
</div>
) : (
<DetailPanelSkeleton label={texts.panel.loading} />
);
};

const renderList = () => {
if (messagesList.status === 'error') {
return (
<PageMessage
role="alert"
title={texts.error}
action={<Button onClick={messagesList.reload}>{messages.list.retry}</Button>}
/>
);
}
if (data?.items.length === 0) {
return <PageMessage title={texts.empty.title} text={texts.empty.text} />;
}
return (
<SupportTable
items={data?.items ?? []}
isLoading={messagesList.status === 'loading'}
openId={openId}
onOpen={(message) => showMessage(message.id)}
/>
);
};

return (
<div className="flex items-stretch gap-24">
<div className="flex min-w-0 flex-1 flex-col gap-16">
<div className="flex flex-wrap items-center gap-16">
<div role="group" aria-label={texts.filters.label}>
<FilterMenu
label={texts.filters.status}
options={statusOptions}
value={query.filters.statut ?? null}
onChange={(status) => handleFilter('statut', status ?? query.filters.statut ?? '')}
/>
</div>
{data ? (
<p className="text-body-s text-muted-foreground">{texts.total(data.total)}</p>
) : null}
</div>
{renderList()}
{data && data.items.length > 0 ? (
<div className="flex items-center justify-between gap-16">
<p className="text-label text-muted-foreground">
{messages.list.range(data.offset + 1, data.offset + data.items.length, data.total)}
</p>
<Pagination
label={messages.list.pages}
previousLabel={messages.list.previous}
nextLabel={messages.list.next}
onPrevious={
data.previousCursor === null ? undefined : () => handlePage(data.previousCursor)
}
onNext={data.nextCursor === null ? undefined : () => handlePage(data.nextCursor)}
/>
</div>
) : null}
</div>
{renderPanel()}
</div>
);
}
Loading
Loading