feat: WIP Contnet managemtn

This commit is contained in:
Nathnael
2026-08-08 09:37:28 +00:00
parent ee25de8817
commit 41e8c08ba3
48 changed files with 6601 additions and 657 deletions

View File

@@ -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"

View File

@@ -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",

View File

@@ -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",
);
}

View File

@@ -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",

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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,
})),
];
}
}
}

View File

@@ -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;
},
};