mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
feat: WIP Contnet managemtn
This commit is contained in:
@@ -56,6 +56,7 @@ import NoAccessPage from "./pages/NoAccessPage";
|
||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
||||
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
||||
import PortalContentPage from "./pages/portal_content/PortalContentPage";
|
||||
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||
import WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
|
||||
@@ -866,6 +867,20 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="portal-content"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.settings.supportContent.view,
|
||||
FREIGHT_PERMS.settings.supportContent.manage,
|
||||
FREIGHT_PERMS.admin,
|
||||
]}
|
||||
>
|
||||
<PortalContentPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="configuration"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
BookOpen,
|
||||
Boxes,
|
||||
Building2,
|
||||
BarChart3,
|
||||
@@ -496,6 +497,16 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
|
||||
FREIGHT_PERMS.admin,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Portal content",
|
||||
href: "/dashboard/portal-content",
|
||||
icon: <BookOpen />,
|
||||
permission: [
|
||||
FREIGHT_PERMS.settings.supportContent.view,
|
||||
FREIGHT_PERMS.settings.supportContent.manage,
|
||||
FREIGHT_PERMS.admin,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Audit logs",
|
||||
href: "/dashboard/audit-logs",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { SupportDocPayload, SupportDocSlug } from "@edr/types";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { portalContentService } from "@/services/portal-content.service";
|
||||
|
||||
/**
|
||||
* Every key shares the `portal-content` prefix so one invalidate after a save
|
||||
* or a restore sweeps the document and its version list together.
|
||||
*/
|
||||
const KEYS = {
|
||||
ROOT: ["portal-content"] as const,
|
||||
bySlug: (slug: string) => ["portal-content", "detail", slug] as const,
|
||||
versions: (slug: string) => ["portal-content", "versions", slug] as const,
|
||||
};
|
||||
|
||||
export function usePortalDoc(slug: SupportDocSlug) {
|
||||
return useQuery({
|
||||
queryKey: KEYS.bySlug(slug),
|
||||
queryFn: () => portalContentService.getBySlug(slug),
|
||||
});
|
||||
}
|
||||
|
||||
/** Version history. Stays idle until the history modal is opened. */
|
||||
export function usePortalDocVersions(slug: SupportDocSlug, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: KEYS.versions(slug),
|
||||
queryFn: () => portalContentService.listVersions(slug),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/** One historical payload, fetched only when a version is previewed. */
|
||||
export function usePortalDocVersion(
|
||||
slug: SupportDocSlug,
|
||||
version: number | null,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...KEYS.versions(slug), version],
|
||||
queryFn: () => portalContentService.getVersion(slug, version as number),
|
||||
enabled: version !== null,
|
||||
});
|
||||
}
|
||||
|
||||
function usePortalContentMutation<TVariables>(
|
||||
mutationFn: (vars: TVariables) => Promise<unknown>,
|
||||
successMessage: string,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
onSuccess: () => {
|
||||
toast.success(successMessage);
|
||||
void queryClient.invalidateQueries({ queryKey: KEYS.ROOT });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Something went wrong";
|
||||
toast.error(Array.isArray(message) ? message.join(", ") : message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePortalDoc(slug: SupportDocSlug) {
|
||||
return usePortalContentMutation(
|
||||
(vars: { payload: SupportDocPayload; note?: string }) =>
|
||||
portalContentService.update(slug, vars.payload, vars.note),
|
||||
"Portal content saved",
|
||||
);
|
||||
}
|
||||
|
||||
export function useRestorePortalVersion(slug: SupportDocSlug) {
|
||||
return usePortalContentMutation(
|
||||
(version: number) => portalContentService.restore(slug, version),
|
||||
"Version restored",
|
||||
);
|
||||
}
|
||||
@@ -326,6 +326,11 @@ export const FREIGHT_PERMS = {
|
||||
delete: "edr_freight_app:settings:contract_templates:delete",
|
||||
read: "edr_freight_app:settings:contract_templates:read",
|
||||
},
|
||||
// Portal-facing help/FAQ/legal copy, edited from Portal content.
|
||||
supportContent: {
|
||||
view: "edr_freight_app:settings:support_content:view",
|
||||
manage: "edr_freight_app:settings:support_content:manage",
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
view: "edr_freight_app:audit:view",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Accordion, ActionIcon, Center, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface AccordionRowProps {
|
||||
value: string;
|
||||
/** Collapsed summary — the heading, question or card title. */
|
||||
title: string;
|
||||
/** Small dimmed line under the title, e.g. a body excerpt. */
|
||||
subtitle?: string;
|
||||
index: number;
|
||||
length: number;
|
||||
onMove: (delta: number) => void;
|
||||
onRemove: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* One collapsible item with reorder and delete controls in its header.
|
||||
*
|
||||
* Collapsing is the point: a legal document has fifteen sections and the FAQ
|
||||
* seventeen answers, and rendering every textarea expanded turned each tab into
|
||||
* an unnavigable mile of boxes. Collapsed, the tab reads as the list of
|
||||
* headings the customer actually sees.
|
||||
*
|
||||
* The buttons sit outside `Accordion.Control` so clicking one does not also
|
||||
* toggle the panel.
|
||||
*/
|
||||
export function AccordionRow({
|
||||
value,
|
||||
title,
|
||||
subtitle,
|
||||
index,
|
||||
length,
|
||||
onMove,
|
||||
onRemove,
|
||||
children,
|
||||
}: AccordionRowProps) {
|
||||
return (
|
||||
<Accordion.Item value={value}>
|
||||
<Center>
|
||||
<Accordion.Control>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text fw={500} truncate>
|
||||
{title || <Text span c="dimmed">(untitled)</Text>}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Accordion.Control>
|
||||
|
||||
<Group gap={2} wrap="nowrap" pr="sm">
|
||||
<Tooltip label="Move up">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
disabled={index === 0}
|
||||
onClick={() => onMove(-1)}
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Move down">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
disabled={index === length - 1}
|
||||
onClick={() => onMove(1)}
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove">
|
||||
<ActionIcon variant="subtle" color="red" onClick={onRemove}>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Center>
|
||||
|
||||
<Accordion.Panel>{children}</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
}
|
||||
|
||||
/** First line of a markdown body, for an accordion subtitle. */
|
||||
export function excerpt(markdown: string, max = 90): string {
|
||||
const line = markdown.replace(/[#*`>-]/g, "").trim().split("\n")[0] ?? "";
|
||||
return line.length > max ? `${line.slice(0, max)}…` : line;
|
||||
}
|
||||
|
||||
export default AccordionRow;
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { PortalFaqContent, PortalFaqGroup } from "@edr/types";
|
||||
import {
|
||||
Accordion,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Switch,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { AccordionRow, excerpt } from "./AccordionRow";
|
||||
import { moveAt, newId, removeAt, replaceAt } from "./array-helpers";
|
||||
import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor";
|
||||
|
||||
interface FaqEditorProps {
|
||||
value: PortalFaqContent;
|
||||
onChange: (next: PortalFaqContent) => void;
|
||||
}
|
||||
|
||||
const EMPTY_FOOTER = {
|
||||
heading: "Still need a hand?",
|
||||
body: "",
|
||||
ctaLabel: "Go to Help & Support",
|
||||
ctaTo: "/help",
|
||||
};
|
||||
|
||||
export function FaqEditor({ value, onChange }: FaqEditorProps) {
|
||||
const setGroups = (groups: PortalFaqGroup[]) => onChange({ ...value, groups });
|
||||
|
||||
const setGroup = (index: number, next: PortalFaqGroup) =>
|
||||
setGroups(replaceAt(value.groups, index, next));
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Card withBorder padding="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Page title"
|
||||
value={value.title}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, title: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Subtitle"
|
||||
value={value.subtitle}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, subtitle: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<MarkdownHint />
|
||||
|
||||
<Accordion variant="separated" radius="md" chevronPosition="left">
|
||||
{value.groups.map((group, groupIndex) => (
|
||||
<AccordionRow
|
||||
key={group.id}
|
||||
value={group.id}
|
||||
title={group.title}
|
||||
subtitle={`${group.items.length} question${group.items.length === 1 ? "" : "s"}`}
|
||||
index={groupIndex}
|
||||
length={value.groups.length}
|
||||
onMove={(delta) => setGroups(moveAt(value.groups, groupIndex, delta))}
|
||||
onRemove={() => setGroups(removeAt(value.groups, groupIndex))}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Group title"
|
||||
value={group.title}
|
||||
onChange={(e) =>
|
||||
setGroup(groupIndex, {
|
||||
...group,
|
||||
title: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<Accordion variant="contained" radius="sm" chevronPosition="left">
|
||||
{group.items.map((item, itemIndex) => (
|
||||
<AccordionRow
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
title={item.question}
|
||||
subtitle={excerpt(item.answer, 70)}
|
||||
index={itemIndex}
|
||||
length={group.items.length}
|
||||
onMove={(delta) =>
|
||||
setGroup(groupIndex, {
|
||||
...group,
|
||||
items: moveAt(group.items, itemIndex, delta),
|
||||
})
|
||||
}
|
||||
onRemove={() =>
|
||||
setGroup(groupIndex, {
|
||||
...group,
|
||||
items: removeAt(group.items, itemIndex),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Question"
|
||||
value={item.question}
|
||||
onChange={(e) =>
|
||||
setGroup(groupIndex, {
|
||||
...group,
|
||||
items: replaceAt(group.items, itemIndex, {
|
||||
...item,
|
||||
question: e.currentTarget.value,
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
label="Answer"
|
||||
value={item.answer}
|
||||
onChange={(answer) =>
|
||||
setGroup(groupIndex, {
|
||||
...group,
|
||||
items: replaceAt(group.items, itemIndex, {
|
||||
...item,
|
||||
answer,
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</AccordionRow>
|
||||
))}
|
||||
</Accordion>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={<Plus size={14} />}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
onClick={() =>
|
||||
setGroup(groupIndex, {
|
||||
...group,
|
||||
items: [
|
||||
...group.items,
|
||||
{ id: newId(), question: "New question", answer: "" },
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
Add question
|
||||
</Button>
|
||||
</Stack>
|
||||
</AccordionRow>
|
||||
))}
|
||||
</Accordion>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Plus size={16} />}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
onClick={() =>
|
||||
setGroups([
|
||||
...value.groups,
|
||||
{ id: newId(), title: "New group", items: [] },
|
||||
])
|
||||
}
|
||||
>
|
||||
Add group
|
||||
</Button>
|
||||
|
||||
<Card withBorder padding="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Switch
|
||||
label="Closing card"
|
||||
checked={Boolean(value.footer)}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...value,
|
||||
footer: e.currentTarget.checked ? EMPTY_FOOTER : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{!value.footer && <Badge variant="light" color="gray">Hidden</Badge>}
|
||||
</Group>
|
||||
|
||||
{value.footer && (
|
||||
<>
|
||||
<TextInput
|
||||
label="Heading"
|
||||
value={value.footer.heading}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...value,
|
||||
footer: { ...value.footer!, heading: e.currentTarget.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
label="Body"
|
||||
value={value.footer.body}
|
||||
onChange={(body) =>
|
||||
onChange({ ...value, footer: { ...value.footer!, body } })
|
||||
}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Button label"
|
||||
value={value.footer.ctaLabel}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...value,
|
||||
footer: {
|
||||
...value.footer!,
|
||||
ctaLabel: e.currentTarget.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Button link"
|
||||
description="A portal route (/help) or an https:// URL"
|
||||
value={value.footer.ctaTo}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...value,
|
||||
footer: { ...value.footer!, ctaTo: e.currentTarget.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default FaqEditor;
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { PortalHelpContent, PortalHelpSection } from "@edr/types";
|
||||
import { Accordion, Button, Card, Divider, Stack, TextInput } from "@mantine/core";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { AccordionRow, excerpt } from "./AccordionRow";
|
||||
import { moveAt, newId, removeAt, replaceAt } from "./array-helpers";
|
||||
import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor";
|
||||
import { MediaManager } from "./MediaManager";
|
||||
|
||||
interface HelpEditorProps {
|
||||
value: PortalHelpContent;
|
||||
onChange: (next: PortalHelpContent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The help page is built, not filled in: an ordered list of sections, each a
|
||||
* heading plus free markdown plus any images or videos. Nothing about the page
|
||||
* is fixed except its title, so support can add, reorder or drop a section
|
||||
* without a code change.
|
||||
*/
|
||||
export function HelpEditor({ value, onChange }: HelpEditorProps) {
|
||||
// A row written before the free-form conversion has no `sections` at all.
|
||||
// Tolerate it rather than crashing the tab: the migration rewrites it, but
|
||||
// an environment can be mid-deploy.
|
||||
const sections = value.sections ?? [];
|
||||
|
||||
const setSections = (next: PortalHelpSection[]) =>
|
||||
onChange({ ...value, sections: next });
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Card withBorder padding="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Page title"
|
||||
value={value.title}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, title: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Subtitle"
|
||||
value={value.subtitle}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, subtitle: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<MarkdownHint />
|
||||
|
||||
<Accordion variant="separated" radius="md" chevronPosition="left">
|
||||
{sections.map((section, index) => (
|
||||
<AccordionRow
|
||||
key={section.id}
|
||||
value={section.id}
|
||||
title={section.heading}
|
||||
subtitle={
|
||||
section.media.length
|
||||
? `${excerpt(section.body, 60)} · ${section.media.length} attachment${section.media.length === 1 ? "" : "s"}`
|
||||
: excerpt(section.body)
|
||||
}
|
||||
index={index}
|
||||
length={sections.length}
|
||||
onMove={(delta) => setSections(moveAt(sections, index, delta))}
|
||||
onRemove={() => setSections(removeAt(sections, index))}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Heading"
|
||||
value={section.heading}
|
||||
onChange={(e) =>
|
||||
setSections(
|
||||
replaceAt(sections, index, {
|
||||
...section,
|
||||
heading: e.currentTarget.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<MarkdownEditor
|
||||
label="Body"
|
||||
value={section.body}
|
||||
onChange={(body) =>
|
||||
setSections(
|
||||
replaceAt(sections, index, { ...section, body }),
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<MediaManager
|
||||
value={section.media}
|
||||
onChange={(media) =>
|
||||
setSections(
|
||||
replaceAt(sections, index, { ...section, media }),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</AccordionRow>
|
||||
))}
|
||||
</Accordion>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Plus size={16} />}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
onClick={() =>
|
||||
setSections([
|
||||
...sections,
|
||||
{ id: newId(), heading: "New section", body: "", media: [] },
|
||||
])
|
||||
}
|
||||
>
|
||||
Add section
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default HelpEditor;
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { PortalLegalContent } from "@edr/types";
|
||||
import { Accordion, Button, Card, Group, Stack, TextInput } from "@mantine/core";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { AccordionRow, excerpt } from "./AccordionRow";
|
||||
import { moveAt, newId, removeAt, replaceAt } from "./array-helpers";
|
||||
import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor";
|
||||
|
||||
interface LegalDocEditorProps {
|
||||
value: PortalLegalContent;
|
||||
onChange: (next: PortalLegalContent) => void;
|
||||
}
|
||||
|
||||
/** Shared by the Privacy and Terms tabs — the two documents have one shape. */
|
||||
export function LegalDocEditor({ value, onChange }: LegalDocEditorProps) {
|
||||
const setSections = (sections: PortalLegalContent["sections"]) =>
|
||||
onChange({ ...value, sections });
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Card withBorder padding="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group grow align="flex-start">
|
||||
<TextInput
|
||||
label="Page title"
|
||||
value={value.title}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, title: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Last updated"
|
||||
description="Free text, e.g. 6 August 2026"
|
||||
value={value.lastUpdated}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, lastUpdated: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
label="Subtitle"
|
||||
value={value.subtitle}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, subtitle: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<MarkdownHint />
|
||||
|
||||
<Accordion variant="separated" radius="md" chevronPosition="left">
|
||||
{value.sections.map((section, index) => (
|
||||
<AccordionRow
|
||||
key={section.id}
|
||||
value={section.id}
|
||||
title={section.heading}
|
||||
subtitle={excerpt(section.body)}
|
||||
index={index}
|
||||
length={value.sections.length}
|
||||
onMove={(delta) => setSections(moveAt(value.sections, index, delta))}
|
||||
onRemove={() => setSections(removeAt(value.sections, index))}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Heading"
|
||||
value={section.heading}
|
||||
onChange={(e) =>
|
||||
setSections(
|
||||
replaceAt(value.sections, index, {
|
||||
...section,
|
||||
heading: e.currentTarget.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<MarkdownEditor
|
||||
label="Body"
|
||||
value={section.body}
|
||||
onChange={(body) =>
|
||||
setSections(
|
||||
replaceAt(value.sections, index, { ...section, body }),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</AccordionRow>
|
||||
))}
|
||||
</Accordion>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Plus size={16} />}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
onClick={() =>
|
||||
setSections([
|
||||
...value.sections,
|
||||
{ id: newId(), heading: "New section", body: "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
Add section
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default LegalDocEditor;
|
||||
@@ -0,0 +1,24 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
// Same preflight fix the editor needs — Mantine's `Typography` defines its list
|
||||
// and margin rules with `:where()`, which Tailwind's preflight outranks, so
|
||||
// bullets rendered without markers here too.
|
||||
import "./markdown-editor.css";
|
||||
|
||||
/**
|
||||
* Read-only markdown rendering for the version-history preview. Editing goes
|
||||
* through `MarkdownEditor` (MDXEditor); this is only for showing what an old
|
||||
* version said.
|
||||
*
|
||||
* Same options as the portal's renderer — no `rehype-raw`, no custom
|
||||
* `urlTransform` — so neither app grows an HTML-injection surface.
|
||||
*/
|
||||
export function Markdown({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="edr-md-content">
|
||||
<ReactMarkdown>{children}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Markdown;
|
||||
@@ -0,0 +1,142 @@
|
||||
import { PORTAL_MEDIA_URI_SCHEME } from "@edr/types";
|
||||
import { Box, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
BlockTypeSelect,
|
||||
BoldItalicUnderlineToggles,
|
||||
CreateLink,
|
||||
InsertImage,
|
||||
InsertThematicBreak,
|
||||
ListsToggle,
|
||||
MDXEditor,
|
||||
UndoRedo,
|
||||
headingsPlugin,
|
||||
imagePlugin,
|
||||
linkDialogPlugin,
|
||||
linkPlugin,
|
||||
listsPlugin,
|
||||
markdownShortcutPlugin,
|
||||
quotePlugin,
|
||||
thematicBreakPlugin,
|
||||
toolbarPlugin,
|
||||
} from "@mdxeditor/editor";
|
||||
import "@mdxeditor/editor/style.css";
|
||||
|
||||
import { portalContentService } from "@/services/portal-content.service";
|
||||
|
||||
// Undoes Tailwind's preflight inside the editor's content area — see the file.
|
||||
import "./markdown-editor.css";
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed URLs are per-request and short-lived, so previews are memoised for the
|
||||
* life of the page rather than re-signed on every keystroke re-render.
|
||||
*/
|
||||
const previewCache = new Map<string, Promise<string>>();
|
||||
|
||||
/**
|
||||
* Inserted images are stored as `minio:<key>`, never as the signed URL the
|
||||
* upload returns: a presigned URL expires, so persisting one would leave every
|
||||
* embedded image broken a few hours later. `imagePreviewHandler` resolves the
|
||||
* ref back to a temporary URL purely for display, on both sides of the wire.
|
||||
*/
|
||||
function resolvePreview(url: string): Promise<string> {
|
||||
if (!url.startsWith(PORTAL_MEDIA_URI_SCHEME)) return Promise.resolve(url);
|
||||
|
||||
const key = url.slice(PORTAL_MEDIA_URI_SCHEME.length);
|
||||
let pending = previewCache.get(key);
|
||||
if (!pending) {
|
||||
pending = portalContentService
|
||||
.mediaUrl(key)
|
||||
.catch(() => url); // show a broken image rather than blowing up the editor
|
||||
previewCache.set(key, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function MarkdownEditor({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
description,
|
||||
}: MarkdownEditorProps) {
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Box
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
}}
|
||||
>
|
||||
<MDXEditor
|
||||
markdown={value}
|
||||
contentEditableClassName="edr-md-content"
|
||||
// MDXEditor re-serialises the markdown once on mount, which differs
|
||||
// harmlessly from what was stored (spacing, escaping). Reporting that
|
||||
// as an edit made every tab open "unsaved" and let a Save write a
|
||||
// no-op version, so the normalisation pass is ignored.
|
||||
onChange={(markdown, initialMarkdownNormalize) => {
|
||||
if (!initialMarkdownNormalize) onChange(markdown);
|
||||
}}
|
||||
plugins={[
|
||||
headingsPlugin(),
|
||||
listsPlugin(),
|
||||
quotePlugin(),
|
||||
linkPlugin(),
|
||||
linkDialogPlugin(),
|
||||
thematicBreakPlugin(),
|
||||
imagePlugin({
|
||||
imageUploadHandler: async (file) => {
|
||||
const { key } = await portalContentService.uploadMedia(file);
|
||||
return `${PORTAL_MEDIA_URI_SCHEME}${key}`;
|
||||
},
|
||||
imagePreviewHandler: resolvePreview,
|
||||
}),
|
||||
markdownShortcutPlugin(),
|
||||
toolbarPlugin({
|
||||
toolbarContents: () => (
|
||||
<>
|
||||
<UndoRedo />
|
||||
<BoldItalicUnderlineToggles />
|
||||
<BlockTypeSelect />
|
||||
<ListsToggle />
|
||||
<CreateLink />
|
||||
<InsertImage />
|
||||
<InsertThematicBreak />
|
||||
</>
|
||||
),
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reminder of the substitution tokens, rendered once per tab. */
|
||||
export function MarkdownHint() {
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
Placeholders resolve from the Contact tab, so one edit there updates every
|
||||
page: <code>{"{{supportEmail}}"}</code> · <code>{"{{supportPhone}}"}</code>{" "}
|
||||
· <code>{"{{supportOffice}}"}</code> · <code>{"{{supportHours}}"}</code> ·{" "}
|
||||
<code>{"{{supportPhoneTel}}"}</code> (inside a tel: link).
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownEditor;
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { PortalMedia } from "@edr/types";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Film, Image as ImageIcon, Trash2, Upload } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { portalContentService } from "@/services/portal-content.service";
|
||||
|
||||
import { newId, removeAt, replaceAt } from "./array-helpers";
|
||||
|
||||
interface MediaManagerProps {
|
||||
value: PortalMedia[];
|
||||
onChange: (next: PortalMedia[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachments for one help section. Uploads store the MinIO object *key*; the
|
||||
* signed URL the upload returns is short-lived and is never persisted, so the
|
||||
* list shows the key rather than pretending to be a gallery.
|
||||
*/
|
||||
export function MediaManager({ value, onChange }: MediaManagerProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const upload = async (file: File) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
const { key, kind } = await portalContentService.uploadMedia(file);
|
||||
onChange([...value, { id: newId(), kind, src: key, caption: null }]);
|
||||
} catch (error) {
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Upload failed";
|
||||
toast.error(Array.isArray(message) ? message.join(", ") : message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
Attachments
|
||||
</Text>
|
||||
|
||||
{value.map((item, index) => (
|
||||
<Paper key={item.id} withBorder p="xs" radius="sm">
|
||||
<Group wrap="nowrap" align="center" gap="sm">
|
||||
{item.kind === "video" ? (
|
||||
<Film size={18} />
|
||||
) : (
|
||||
<ImageIcon size={18} />
|
||||
)}
|
||||
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{item.src}
|
||||
</Text>
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="Caption (optional)"
|
||||
value={item.caption ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
replaceAt(value, index, {
|
||||
...item,
|
||||
caption: e.currentTarget.value || null,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Tooltip label="Remove attachment">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => onChange(removeAt(value, index))}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.currentTarget.files?.[0];
|
||||
if (file) void upload(file);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
loading={uploading}
|
||||
leftSection={<Upload size={14} />}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
Upload image or video
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default MediaManager;
|
||||
@@ -0,0 +1,261 @@
|
||||
import type {
|
||||
PortalFaqContent,
|
||||
PortalHelpContent,
|
||||
PortalLegalContent,
|
||||
PortalSupportContact,
|
||||
SupportDocPayload,
|
||||
SupportDocSlug,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { History, RotateCcw, Save } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import {
|
||||
usePortalDoc,
|
||||
useUpdatePortalDoc,
|
||||
} from "@/hooks/portal-content/usePortalContentAdmin";
|
||||
|
||||
import { FaqEditor } from "./FaqEditor";
|
||||
import { HelpEditor } from "./HelpEditor";
|
||||
import { LegalDocEditor } from "./LegalDocEditor";
|
||||
import { VersionHistoryModal } from "./VersionHistoryModal";
|
||||
|
||||
const TABS: { slug: SupportDocSlug; label: string }[] = [
|
||||
{ slug: "CONTACT", label: "Contact" },
|
||||
{ slug: "HELP", label: "Help" },
|
||||
{ slug: "FAQ", label: "FAQ" },
|
||||
{ slug: "PRIVACY", label: "Privacy" },
|
||||
{ slug: "TERMS", label: "Terms" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Edits the copy on the freight portal's public pages — /help, /faq, /terms,
|
||||
* /privacy — and the support contact block all four quote.
|
||||
*
|
||||
* Each tab is a local draft saved in one PATCH of the whole document, rather
|
||||
* than a mutation per field. That is what makes one editorial change equal one
|
||||
* version, which is the difference between a history you can read and a history
|
||||
* of keystrokes.
|
||||
*/
|
||||
export default function PortalContentPage() {
|
||||
const [active, setActive] = useState<SupportDocSlug>("CONTACT");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Portal content"
|
||||
subtitle="Help, FAQ and legal copy shown to customers on the public portal pages. Body text is markdown, every save is versioned, and any version can be restored."
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
value={active}
|
||||
onChange={(value) => setActive(value as SupportDocSlug)}
|
||||
keepMounted={false}
|
||||
>
|
||||
<Tabs.List mb="lg">
|
||||
{TABS.map((tab) => (
|
||||
<Tabs.Tab key={tab.slug} value={tab.slug}>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
{TABS.map((tab) => (
|
||||
<Tabs.Panel key={tab.slug} value={tab.slug}>
|
||||
<DocumentTab slug={tab.slug} />
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentTab({ slug }: { slug: SupportDocSlug }) {
|
||||
const { data, isLoading } = usePortalDoc(slug);
|
||||
const update = useUpdatePortalDoc(slug);
|
||||
|
||||
const [draft, setDraft] = useState<SupportDocPayload | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
|
||||
// Reseed only when the server's version number moves (load, save, restore).
|
||||
// Keying off `data` itself would let a background refetch wipe edits that are
|
||||
// still in progress.
|
||||
const seededVersion = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (data && seededVersion.current !== data.version) {
|
||||
seededVersion.current = data.version;
|
||||
setDraft(data.payload);
|
||||
setNote("");
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (isLoading || !data || !draft) return <Loader size="sm" />;
|
||||
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(data.payload);
|
||||
|
||||
const reset = () => {
|
||||
setDraft(data.payload);
|
||||
setNote("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Sticky: these tabs are long lists, and a Save button that scrolls out
|
||||
of reach is the fastest way to lose an edit. */}
|
||||
<Card
|
||||
withBorder
|
||||
padding="sm"
|
||||
radius="md"
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
backgroundColor: "var(--mantine-color-body)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color={dirty ? "orange" : "gray"}>
|
||||
v{data.version}
|
||||
</Badge>
|
||||
<Text size="sm" c={dirty ? "orange" : "dimmed"}>
|
||||
{dirty ? "Unsaved changes" : "Saved"}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" align="center">
|
||||
{dirty && (
|
||||
<TextInput
|
||||
size="sm"
|
||||
placeholder="Change note (optional)"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
w={240}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<History size={16} />}
|
||||
onClick={() => setHistoryOpen(true)}
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<RotateCcw size={16} />}
|
||||
disabled={!dirty}
|
||||
onClick={reset}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<Save size={16} />}
|
||||
disabled={!dirty}
|
||||
loading={update.isPending}
|
||||
onClick={() =>
|
||||
update.mutate({ payload: draft, note: note || undefined })
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
<DocumentEditor slug={slug} value={draft} onChange={setDraft} />
|
||||
|
||||
<VersionHistoryModal
|
||||
slug={slug}
|
||||
opened={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
hasUnsavedChanges={dirty}
|
||||
onRestored={reset}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentEditor({
|
||||
slug,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
slug: SupportDocSlug;
|
||||
value: SupportDocPayload;
|
||||
onChange: (next: SupportDocPayload) => void;
|
||||
}) {
|
||||
switch (slug) {
|
||||
case "CONTACT":
|
||||
return (
|
||||
<ContactEditor
|
||||
value={value as PortalSupportContact}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case "HELP":
|
||||
return (
|
||||
<HelpEditor value={value as PortalHelpContent} onChange={onChange} />
|
||||
);
|
||||
case "FAQ":
|
||||
return <FaqEditor value={value as PortalFaqContent} onChange={onChange} />;
|
||||
case "PRIVACY":
|
||||
case "TERMS":
|
||||
return (
|
||||
<LegalDocEditor
|
||||
value={value as PortalLegalContent}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Four fields, so no separate file. These values feed the help page's contact
|
||||
* cards and resolve the `{{supportEmail}}`-style placeholders used throughout
|
||||
* the FAQ and legal copy — editing them here updates every page at once.
|
||||
*/
|
||||
function ContactEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: PortalSupportContact;
|
||||
onChange: (next: PortalSupportContact) => void;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="md" maw={640}>
|
||||
<TextInput
|
||||
label="Support email"
|
||||
value={value.email}
|
||||
onChange={(e) => onChange({ ...value, email: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Support phone"
|
||||
description="Displayed as typed; tel: links strip the spacing automatically."
|
||||
value={value.phone}
|
||||
onChange={(e) => onChange({ ...value, phone: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Head office"
|
||||
value={value.office}
|
||||
onChange={(e) => onChange({ ...value, office: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Support hours"
|
||||
value={value.hours}
|
||||
onChange={(e) => onChange({ ...value, hours: e.currentTarget.value })}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { SupportDocSlug } from "@edr/types";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
usePortalDocVersion,
|
||||
usePortalDocVersions,
|
||||
useRestorePortalVersion,
|
||||
} from "@/hooks/portal-content/usePortalContentAdmin";
|
||||
|
||||
import { Markdown } from "./Markdown";
|
||||
import { summarizeVersion } from "./version-preview";
|
||||
|
||||
interface VersionHistoryModalProps {
|
||||
slug: SupportDocSlug;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** True when the tab holds unsaved edits a restore would discard. */
|
||||
hasUnsavedChanges: boolean;
|
||||
onRestored: () => void;
|
||||
}
|
||||
|
||||
function formatSavedAt(value: string): string {
|
||||
return new Date(value).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Version history for one document. Restoring re-saves the old payload as a new
|
||||
* version server-side, so the list only ever grows and a restore is itself
|
||||
* undoable — there is nothing here that can destroy history.
|
||||
*/
|
||||
export function VersionHistoryModal({
|
||||
slug,
|
||||
opened,
|
||||
onClose,
|
||||
hasUnsavedChanges,
|
||||
onRestored,
|
||||
}: VersionHistoryModalProps) {
|
||||
const { data: versions, isLoading } = usePortalDocVersions(slug, opened);
|
||||
const [previewing, setPreviewing] = useState<number | null>(null);
|
||||
const [confirming, setConfirming] = useState<number | null>(null);
|
||||
const { data: preview } = usePortalDocVersion(slug, previewing);
|
||||
const restore = useRestorePortalVersion(slug);
|
||||
|
||||
const close = () => {
|
||||
setPreviewing(null);
|
||||
setConfirming(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const latest = versions?.[0]?.version;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
size="xl"
|
||||
title={`Version history — ${slug}`}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{hasUnsavedChanges && (
|
||||
<Alert
|
||||
color="orange"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Unsaved changes"
|
||||
>
|
||||
This tab has edits that have not been saved. Restoring a version
|
||||
discards them.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isLoading && <Loader size="sm" />}
|
||||
|
||||
{versions?.map((version) => (
|
||||
<Card key={version.id} withBorder padding="md" radius="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Stack gap={2}>
|
||||
<Group gap="xs">
|
||||
<Text fw={600}>v{version.version}</Text>
|
||||
{version.version === latest && (
|
||||
<Badge size="sm" variant="light">
|
||||
Current
|
||||
</Badge>
|
||||
)}
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatSavedAt(version.createdAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
{version.note && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{version.note}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{confirming === version.version ? (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="sm">Restore v{version.version}?</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
color="red"
|
||||
loading={restore.isPending}
|
||||
onClick={() =>
|
||||
restore.mutate(version.version, {
|
||||
onSuccess: () => {
|
||||
onRestored();
|
||||
close();
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={() => setConfirming(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
setPreviewing(
|
||||
previewing === version.version ? null : version.version,
|
||||
)
|
||||
}
|
||||
>
|
||||
{previewing === version.version ? "Hide" : "Preview"}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
disabled={version.version === latest}
|
||||
onClick={() => {
|
||||
setConfirming(version.version);
|
||||
setPreviewing(null);
|
||||
}}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{previewing === version.version && (
|
||||
<Card mt="sm" withBorder padding="sm" radius="sm" bg="gray.0">
|
||||
{preview ? (
|
||||
<Stack gap="sm">
|
||||
{summarizeVersion(slug, preview.payload).map((entry, i) => (
|
||||
<Stack key={`${entry.label}-${i}`} gap={2}>
|
||||
<Text size="sm" fw={600}>
|
||||
{entry.label}
|
||||
</Text>
|
||||
<Markdown>{entry.body}</Markdown>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Loader size="xs" />
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{versions?.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No history yet.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default VersionHistoryModal;
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Immutable list edits shared by the three payload editors. */
|
||||
|
||||
export function replaceAt<T>(items: T[], index: number, next: T): T[] {
|
||||
return items.map((item, i) => (i === index ? next : item));
|
||||
}
|
||||
|
||||
export function removeAt<T>(items: T[], index: number): T[] {
|
||||
return items.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps an item with its neighbour. Out-of-range moves return the list
|
||||
* unchanged, so the ▲/▼ buttons need no disabled-state bookkeeping of their own.
|
||||
*/
|
||||
export function moveAt<T>(items: T[], index: number, delta: number): T[] {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= items.length) return items;
|
||||
|
||||
const next = [...items];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
return next;
|
||||
}
|
||||
|
||||
export const newId = () => crypto.randomUUID();
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Tailwind's preflight zeroes margins on `p`, strips `list-style` from `ul`/`ol`
|
||||
* and flattens heading sizes. MDXEditor's own stylesheet assumes browser
|
||||
* defaults, so inside this app its content area renders as one undifferentiated
|
||||
* block — paragraphs run together and bullets lose their markers.
|
||||
*
|
||||
* This restores the handful of element styles the editor needs, scoped to its
|
||||
* content area so nothing leaks back into the rest of the backoffice. It is a
|
||||
* deliberate alternative to pulling in @tailwindcss/typography for one widget.
|
||||
*/
|
||||
|
||||
.edr-md-content p {
|
||||
margin: 0 0 0.75rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.edr-md-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.edr-md-content ul,
|
||||
.edr-md-content ol {
|
||||
margin: 0 0 0.75rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.edr-md-content ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.edr-md-content ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.edr-md-content li {
|
||||
margin: 0.25rem 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Nested lists — the editor's indent button produces these. */
|
||||
.edr-md-content li > ul,
|
||||
.edr-md-content li > ol {
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.edr-md-content h1,
|
||||
.edr-md-content h2,
|
||||
.edr-md-content h3,
|
||||
.edr-md-content h4 {
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
margin: 1rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.edr-md-content h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.edr-md-content h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.edr-md-content h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.edr-md-content h4 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.edr-md-content strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edr-md-content em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.edr-md-content a {
|
||||
color: var(--mantine-color-blue-6);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.edr-md-content blockquote {
|
||||
margin: 0 0 0.75rem;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 3px solid var(--mantine-color-gray-3);
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.edr-md-content hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--mantine-color-gray-3);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.edr-md-content code {
|
||||
font-family: var(--mantine-font-family-monospace);
|
||||
font-size: 0.875em;
|
||||
background: var(--mantine-color-gray-1);
|
||||
padding: 0.05rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.edr-md-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
PortalFaqContent,
|
||||
PortalHelpContent,
|
||||
PortalLegalContent,
|
||||
PortalSupportContact,
|
||||
SupportDocPayload,
|
||||
SupportDocSlug,
|
||||
} from "@edr/types";
|
||||
|
||||
export interface PreviewEntry {
|
||||
label: string;
|
||||
/** Markdown, rendered read-only. */
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens a stored payload into labelled markdown blocks for the history
|
||||
* modal. An editor deciding whether to roll back needs to read the wording of
|
||||
* that version — a raw JSON dump technically shows it, but not in a form
|
||||
* anyone can compare legal prose in.
|
||||
*/
|
||||
export function summarizeVersion(
|
||||
slug: SupportDocSlug,
|
||||
payload: SupportDocPayload,
|
||||
): PreviewEntry[] {
|
||||
switch (slug) {
|
||||
case "CONTACT": {
|
||||
const contact = payload as PortalSupportContact;
|
||||
return [
|
||||
{ label: "Email", body: contact.email },
|
||||
{ label: "Phone", body: contact.phone },
|
||||
{ label: "Head office", body: contact.office },
|
||||
{ label: "Support hours", body: contact.hours },
|
||||
];
|
||||
}
|
||||
|
||||
case "HELP": {
|
||||
const help = payload as PortalHelpContent;
|
||||
return [
|
||||
{ label: "Title", body: help.title },
|
||||
{ label: "Subtitle", body: help.subtitle },
|
||||
...help.sections.map((section) => ({
|
||||
label: section.heading,
|
||||
body: section.media.length
|
||||
? `${section.body}\n\n_${section.media.length} attachment${section.media.length === 1 ? "" : "s"}: ${section.media.map((m) => m.src).join(", ")}_`
|
||||
: section.body,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
case "FAQ": {
|
||||
const faq = payload as PortalFaqContent;
|
||||
return [
|
||||
{ label: "Title", body: faq.title },
|
||||
...faq.groups.flatMap((group) =>
|
||||
group.items.map((item) => ({
|
||||
label: `${group.title} — ${item.question}`,
|
||||
body: item.answer,
|
||||
})),
|
||||
),
|
||||
...(faq.footer
|
||||
? [{ label: faq.footer.heading, body: faq.footer.body }]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
case "PRIVACY":
|
||||
case "TERMS": {
|
||||
const legal = payload as PortalLegalContent;
|
||||
return [
|
||||
{ label: "Last updated", body: legal.lastUpdated },
|
||||
...legal.sections.map((section) => ({
|
||||
label: section.heading,
|
||||
body: section.body,
|
||||
})),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type {
|
||||
PortalMediaKind,
|
||||
SupportDocPayload,
|
||||
SupportDocSlug,
|
||||
SupportDocumentDetail,
|
||||
SupportDocVersionDetail,
|
||||
SupportDocVersionSummary,
|
||||
} from "@edr/types";
|
||||
|
||||
import { api as client } from "../auth/http";
|
||||
|
||||
const ROOT = "/support-content";
|
||||
const BASE = `${ROOT}/documents`;
|
||||
|
||||
/**
|
||||
* Customer-facing help/FAQ/legal copy for the freight portal. The client's
|
||||
* response interceptor already unwraps the `{ success, data }` envelope, so
|
||||
* every method is a one-liner.
|
||||
*/
|
||||
export const portalContentService = {
|
||||
async getBySlug(slug: SupportDocSlug): Promise<SupportDocumentDetail> {
|
||||
const { data } = await client.get<SupportDocumentDetail>(`${BASE}/${slug}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Replaces the document's whole payload. Whole-payload rather than per-field
|
||||
* on purpose: one Save becomes exactly one version, which is what keeps the
|
||||
* history list readable.
|
||||
*/
|
||||
async update(
|
||||
slug: SupportDocSlug,
|
||||
payload: SupportDocPayload,
|
||||
note?: string,
|
||||
): Promise<SupportDocumentDetail> {
|
||||
const { data } = await client.patch<SupportDocumentDetail>(
|
||||
`${BASE}/${slug}`,
|
||||
{ payload, note },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async listVersions(slug: SupportDocSlug): Promise<SupportDocVersionSummary[]> {
|
||||
const { data } = await client.get<SupportDocVersionSummary[]>(
|
||||
`${BASE}/${slug}/versions`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async getVersion(
|
||||
slug: SupportDocSlug,
|
||||
version: number,
|
||||
): Promise<SupportDocVersionDetail> {
|
||||
const { data } = await client.get<SupportDocVersionDetail>(
|
||||
`${BASE}/${slug}/versions/${version}`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Uploads an image or video and returns its object *key*. The key is what
|
||||
* gets saved in the document; `url` is only for showing the editor a preview
|
||||
* right now, and expires.
|
||||
*/
|
||||
async uploadMedia(
|
||||
file: File,
|
||||
): Promise<{ key: string; kind: PortalMediaKind; url: string }> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
|
||||
const { data } = await client.post<{
|
||||
key: string;
|
||||
kind: PortalMediaKind;
|
||||
url: string;
|
||||
}>(`${ROOT}/media`, form);
|
||||
return data;
|
||||
},
|
||||
|
||||
/** Resolves one stored key to a temporary URL, for editor previews. */
|
||||
async mediaUrl(key: string): Promise<string> {
|
||||
const { data } = await client.get<{ url: string }>(`${ROOT}/media-url`, {
|
||||
params: { key },
|
||||
});
|
||||
return data.url;
|
||||
},
|
||||
|
||||
/** Re-saves an old payload as a new version — never destructive. */
|
||||
async restore(
|
||||
slug: SupportDocSlug,
|
||||
version: number,
|
||||
): Promise<SupportDocumentDetail> {
|
||||
const { data } = await client.post<SupportDocumentDetail>(
|
||||
`${BASE}/${slug}/versions/${version}/restore`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user