Merge pull request #1201 from Tria-plc/dev

y
This commit is contained in:
Nathnael Wondisha
2026-08-09 16:48:09 +03:00
committed by GitHub
40 changed files with 1933 additions and 1098 deletions

View File

@@ -0,0 +1,91 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Folds each help section's `media[]` array into its markdown body.
*
* Attachments used to hang off the section as a separate list, rendered after
* the text — which meant an author could not put a picture next to the sentence
* it illustrates, and had two different places to manage media. They are now
* embedded with markdown's image syntax, and the renderer picks `<img>` or
* `<video>` from the file extension.
*
* Existing entries are converted rather than dropped: an uploaded object
* becomes `![caption](minio:<key>)`, and a shipped asset path or external URL
* is kept verbatim. Sections are left alone once they have no `media` key, so
* re-running does nothing.
*/
interface LegacyMedia {
src: string;
caption?: string | null;
}
interface LegacySection {
id: string;
heading: string;
body: string;
media?: LegacyMedia[];
}
/** Uploaded objects are stored as keys; the `minio:` ref is signed on read. */
function toMarkdown(item: LegacyMedia): string {
const isStoredObject = !/^(https?:\/\/|\/)/.test(item.src);
const target = isStoredObject ? `minio:${item.src}` : item.src;
return `![${item.caption ?? ""}](${target})`;
}
export class SupportHelpInlineMedia3370000000000 implements MigrationInterface {
name = "SupportHelpInlineMedia3370000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
const rows: {
id: string;
version: number;
payload: { sections?: LegacySection[] } & Record<string, unknown>;
}[] = await queryRunner.query(`
SELECT id, version, payload
FROM freight.support_documents
WHERE slug = 'HELP'
`);
for (const row of rows) {
const sections = row.payload.sections ?? [];
if (!sections.some((section) => section.media !== undefined)) continue;
const payload = {
...row.payload,
sections: sections.map(({ media, ...section }) => {
const embeds = (media ?? []).map(toMarkdown);
return {
...section,
body: [section.body, ...embeds].filter(Boolean).join("\n\n"),
};
}),
};
const version = row.version + 1;
await queryRunner.query(
`UPDATE freight.support_documents
SET payload = $1::jsonb, version = $2, updated_at = now()
WHERE id = $3`,
[JSON.stringify(payload), version, row.id],
);
await queryRunner.query(
`INSERT INTO freight.support_document_versions
(document_id, version, payload, actor_id, note)
VALUES ($1, $2, $3::jsonb, NULL, $4)`,
[
row.id,
version,
JSON.stringify(payload),
"Moved attachments inline into the section text",
],
);
}
}
/** Not reversible — see the note on SupportHelpSections3360000000000. */
public async down(): Promise<void> {
// no-op
}
}

View File

@@ -279,9 +279,10 @@ export class BookingPricingService {
return {
lineItems,
// Grand total is billed in whole currency units — fractional line sums
// (rate × tons can yield e.g. 260519.2) round to the nearest whole birr/USD.
totalAmount: Math.round(total),
// Grand total keeps its cents, matching the line items it sums — rounding
// to whole birr made the total disagree with the breakdown (135,375.61 of
// lines shown as a 135,376.00 total) and CBE bills this figure to the cent.
totalAmount: round2(total),
currency: booking.paymentCurrency,
usedRates: [...usedRatesMap.values()],
appliedModifiers: ruleResult.appliedModifiers,

View File

@@ -354,6 +354,93 @@ describe("Fayda identity verification binds a person to the company", () => {
).resolves.toBeDefined();
});
// Fayda's email and phone claims are optional and routinely absent. The owner
// is who the company is reached through and the step renders no input for
// their contact details, so the onboarding account — already OTP-proven —
// stands in rather than leaving the company unreachable.
describe("account contact details stand in for absent Fayda claims", () => {
const noContactClaims = {
purpose: "VERIFY",
verified: true,
sub: "new-sub",
fullName: "Haile Gebrselassie",
address: "Addis Ababa",
};
const account = {
email: "account@example.com",
phoneNumber: "+251911777777",
};
it("falls back to the account for an owner Fayda gave no email or phone", async () => {
const { service, ctx } = makeService({ verification: noContactClaims });
await service.completeIdentityVerification(
"user-1",
{ subject: "owner", code: "c", state: "s" },
account,
);
expect(ctx.attributes.ownerEmail).toBe("account@example.com");
expect(ctx.attributes.ownerPhone).toBe("+251911777777");
});
it("prefers the Fayda claim over the account when there is one", async () => {
const { service, ctx } = makeService();
await service.completeIdentityVerification(
"user-1",
{ subject: "owner", code: "c", state: "s" },
account,
);
expect(ctx.attributes.ownerEmail).toBe("haile@example.com");
expect(ctx.attributes.ownerPhone).toBe("+251922000000");
});
it("leaves the PoA alone — the account is not that person", async () => {
const { service, ctx } = makeService({ verification: noContactClaims });
await service.completeIdentityVerification(
"user-1",
{ subject: "poa", code: "c", state: "s" },
account,
);
expect(ctx.attributes.poaEmail).toBeUndefined();
expect(ctx.attributes.poaPhone).toBeUndefined();
});
// Owners verified before the fallback existed hold blank contacts. Copying
// those blanks onto the GM makes generalManagerEmail required by onboarding
// with no field anywhere to satisfy it.
it("fills the GM copy from the account when the stored owner has no contacts", async () => {
const { service, ctx } = makeService({
attributes: { ...OWNER_VERIFIED },
});
await service.setGmSameAsOwner("user-1", account);
expect(ctx.attributes.gmEmail).toBe("account@example.com");
expect(ctx.attributes.generalManagerEmail).toBe("account@example.com");
expect(ctx.attributes.generalManagerPhone).toBe("+251911777777");
});
it("keeps the stored owner contacts when the GM copy has them", async () => {
const { service, ctx } = makeService({
attributes: {
...OWNER_VERIFIED,
ownerEmail: "abebe@example.com",
ownerPhone: "+251911000111",
},
});
await service.setGmSameAsOwner("user-1", account);
expect(ctx.attributes.generalManagerEmail).toBe("abebe@example.com");
expect(ctx.attributes.generalManagerPhone).toBe("+251911000111");
});
});
it("never locks or gates the general manager — it is not the verified subject", async () => {
// GM is a plain typed role; the portal offers a "same as owner" copy, but
// the backend must not treat it as identity-owned or require it verified.

View File

@@ -1,10 +1,9 @@
import { SUPPORT_MEDIA_PREFIX, SupportDocSlug } from "@edr/types";
import { SupportDocSlug } from "@edr/types";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
ArrayMaxSize,
IsArray,
IsIn,
IsObject,
IsOptional,
IsString,
@@ -28,17 +27,6 @@ const LINK_PATTERN = /^(https?:\/\/|mailto:|tel:|\/)/;
const LINK_MESSAGE =
"$property must start with http(s)://, mailto:, tel: or /";
/**
* A media source is either an uploaded MinIO object key, a same-origin path, or
* an https URL. Anything else — notably `javascript:` — is refused, since this
* value lands in an `<img>`/`<video>` src.
*/
const MEDIA_SRC_PATTERN = new RegExp(
`^(https?:\\/\\/|\\/|${SUPPORT_MEDIA_PREFIX.replace("/", "\\/")})`,
);
const MEDIA_SRC_MESSAGE =
`$property must be an uploaded ${SUPPORT_MEDIA_PREFIX} key, a /path, or an http(s):// URL`;
/* ------------------------------- CONTACT ------------------------------- */
export class PortalSupportContactDto {
@@ -208,58 +196,6 @@ export class PortalFaqContentDto {
/* --------------------------------- HELP -------------------------------- */
export class PortalMediaDto {
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
@ApiProperty({ enum: ["image", "video"] })
@IsIn(["image", "video"])
kind!: "image" | "video";
@ApiProperty({
description: `An uploaded ${SUPPORT_MEDIA_PREFIX} key, a same-origin /path, or an https:// URL`,
})
@IsString()
@Matches(MEDIA_SRC_PATTERN, { message: MEDIA_SRC_MESSAGE })
@MaxLength(500)
src!: string;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(300)
caption?: string | null;
}
export class PortalHelpSectionDto {
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
heading!: string;
@ApiProperty({ description: "Markdown" })
@IsString()
@MaxLength(20_000)
body!: string;
@ApiProperty({ type: [PortalMediaDto] })
@IsArray()
@ArrayMaxSize(12)
@ValidateNested({ each: true })
@Type(() => PortalMediaDto)
media!: PortalMediaDto[];
}
export class PortalHelpContentDto {
@ApiProperty()
@IsString()
@@ -272,12 +208,16 @@ export class PortalHelpContentDto {
@MaxLength(500)
subtitle!: string;
@ApiProperty({ type: [PortalHelpSectionDto] })
/**
* Same section shape as the legal documents — images and videos live inside
* the markdown, so a help section needs nothing the others do not have.
*/
@ApiProperty({ type: [PortalDocSectionDto] })
@IsArray()
@ArrayMaxSize(40)
@ValidateNested({ each: true })
@Type(() => PortalHelpSectionDto)
sections!: PortalHelpSectionDto[];
@Type(() => PortalDocSectionDto)
sections!: PortalDocSectionDto[];
}
/* ------------------------------- request ------------------------------- */

View File

@@ -131,7 +131,8 @@ describe("SupportContentService.getBundle", () => {
// findAll returns only CONTACT, so HELP falls back to the defaults — which
// is itself worth asserting: an unseeded row must not break the page.
const bundle = await service.getBundle();
expect(bundle.help.sections[0].media[0].src).toBe(
// A shipped asset path is not a stored object, so nothing is signed.
expect(bundle.help.sections[0].body).toContain(
"/assets/edr-portal-guide.webm",
);
expect(minio.getSignedUrl).not.toHaveBeenCalled();
@@ -141,10 +142,9 @@ describe("SupportContentService.getBundle", () => {
sections: [
{
...help.sections[0],
body: "See ![diagram](minio:support-content/d.png) below.",
media: [
{ id: "m1", kind: "image" as const, src: "support-content/a.png" },
],
body:
"See ![diagram](minio:support-content/d.png) and " +
"![clip](minio:support-content/c.mp4).",
},
],
};
@@ -154,14 +154,14 @@ describe("SupportContentService.getBundle", () => {
help: withUpload,
});
expect(signed.help.sections[0].media[0].src).toBe(
"https://minio.test/support-content/a.png?sig=x",
);
expect(signed.help.sections[0].body).toContain(
"https://minio.test/support-content/d.png?sig=x",
);
expect(signed.help.sections[0].body).toContain(
"https://minio.test/support-content/c.mp4?sig=x",
);
// The stored copy must never be mutated into a URL — that is what would rot.
expect(withUpload.sections[0].media[0].src).toBe("support-content/a.png");
expect(withUpload.sections[0].body).toContain("minio:support-content/d.png");
});
});
@@ -176,40 +176,33 @@ describe("validatePayload", () => {
}
});
const sectionWithMedia = (src: string) => ({
const sectionWithBody = (body: string) => ({
...help,
sections: [{ ...help.sections[0], media: [{ kind: "video", src }] }],
sections: [{ ...help.sections[0], body }],
});
it("rejects a javascript: media source", () => {
// The markdown renderer drops raw HTML, so src attributes like this one are
// the only place a script URL could still execute.
expect(() =>
validatePayload("HELP", sectionWithMedia("javascript:alert(1)")),
).toThrow(BadRequestException);
});
it("accepts an uploaded key, a rooted path and an https URL", () => {
for (const src of [
"support-content/9f1c.png",
"/assets/edr-portal-guide.webm",
"https://cdn.example.com/clip.mp4",
it("stores embedded media as refs, whatever the scheme", () => {
// Media now lives in the markdown, so the API no longer validates its URL
// scheme — the portal's renderer does, by dropping anything that is not
// http(s)/mailto/tel/relative. These all persist fine.
for (const body of [
"![](minio:support-content/9f1c.png)",
"![](/assets/edr-portal-guide.webm)",
"![](https://cdn.example.com/clip.mp4)",
]) {
expect(() => validatePayload("HELP", sectionWithMedia(src))).not.toThrow();
expect(() => validatePayload("HELP", sectionWithBody(body))).not.toThrow();
}
});
it("rejects a media kind that is neither image nor video", () => {
it("rejects a section body past the cap", () => {
expect(() =>
validatePayload("HELP", {
...help,
sections: [
{
...help.sections[0],
media: [{ kind: "pdf", src: "support-content/a.pdf" }],
},
],
}),
validatePayload("HELP", sectionWithBody("x".repeat(20_001))),
).toThrow(BadRequestException);
});
it("rejects an unknown property, which is how a stale payload is caught", () => {
expect(() =>
validatePayload("HELP", { ...help, channels: [] }),
).toThrow(BadRequestException);
});

View File

@@ -51,9 +51,6 @@ const MEDIA_REF = new RegExp(
"g",
);
/** Anything not already a URL or a rooted path is a MinIO object key. */
const isObjectKey = (src: string) => !/^(https?:\/\/|\/)/.test(src);
@Injectable()
export class SupportContentService {
constructor(
@@ -127,9 +124,9 @@ export class SupportContentService {
}
/**
* Swaps every stored MinIO reference for a freshly signed URL: attachment
* `src` keys, and `minio:<key>` references embedded in markdown by the
* editor's image button.
* Swaps every `minio:<key>` reference embedded in the copy for a freshly
* signed URL. Media lives inside the markdown, so this one pass covers every
* document.
*
* Each distinct key is signed once per request, and a signing failure
* degrades to MinIO's public URL rather than failing the whole page (see
@@ -140,12 +137,6 @@ export class SupportContentService {
): Promise<PortalContentBundle> {
const keys = new Set<string>();
for (const section of bundle.help.sections ?? []) {
for (const item of section.media ?? []) {
if (isObjectKey(item.src)) keys.add(item.src);
}
}
const collect = (value: unknown): void => {
if (typeof value === "string") {
for (const match of value.matchAll(MEDIA_REF)) keys.add(match[1]);
@@ -183,15 +174,7 @@ export class SupportContentService {
return value;
};
const resolved = rewrite(bundle) as PortalContentBundle;
for (const section of resolved.help.sections ?? []) {
for (const item of section.media ?? []) {
if (isObjectKey(item.src)) item.src = signed.get(item.src) ?? item.src;
}
}
return resolved;
return rewrite(bundle) as PortalContentBundle;
}
/** Admin list — metadata only, no payloads. */
@@ -339,13 +322,7 @@ function withGeneratedIds(
switch (slug) {
case "HELP": {
const help = payload as PortalHelpContent;
return {
...help,
sections: help.sections.map((section) => ({
...withId(section),
media: (section.media ?? []).map(withId),
})),
};
return { ...help, sections: help.sections.map(withId) };
}
case "FAQ": {
const faq = payload as PortalFaqContent;

View File

@@ -1,95 +0,0 @@
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,163 @@
import { ActionIcon, Box, Button, Group, Stack, Text, Tooltip } from "@mantine/core";
import { ChevronDown, ChevronUp, Plus } from "lucide-react";
export interface RailItem {
id: string;
label: string;
/** Renders as a small caps heading above the items that follow it. */
heading?: string;
/** Nested one level (an FAQ question under its group). */
indented?: boolean;
}
interface DocumentRailProps {
items: RailItem[];
selectedId: string | null;
onSelect: (id: string) => void;
onMove: (id: string, delta: number) => void;
/** Whether this row can move in the given direction. */
canMove: (id: string, delta: number) => boolean;
addLabel: string;
onAdd: () => void;
emptyLabel: string;
}
/**
* The list of pages that make up a document, and the only navigation on the
* tab. Replaces the accordion stack: a public officer editing the privacy
* policy sees fifteen page names, clicks one, and edits it — instead of
* scrolling a mile of open text boxes trying to find the right one.
*
* Reorder arrows appear only on the selected row, so the resting state is a
* clean list rather than fifteen rows of buttons.
*/
export function DocumentRail({
items,
selectedId,
onSelect,
onMove,
canMove,
addLabel,
onAdd,
emptyLabel,
}: DocumentRailProps) {
return (
<Stack
gap={4}
p="sm"
style={{
width: 280,
flexShrink: 0,
alignSelf: "flex-start",
background: "#FFFFFF",
border: "1px solid var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-lg)",
}}
>
{items.length === 0 && (
<Text size="sm" c="dimmed" p="sm">
{emptyLabel}
</Text>
)}
{items.map((item) => {
const selected = item.id === selectedId;
return (
<Box key={item.id}>
{item.heading && (
<Text
size="xs"
fw={700}
c="dimmed"
tt="uppercase"
mt="sm"
mb={4}
px="xs"
style={{ letterSpacing: "0.04em" }}
>
{item.heading}
</Text>
)}
<Group
gap={4}
wrap="nowrap"
px="xs"
py={6}
ml={item.indented ? "sm" : 0}
onClick={() => onSelect(item.id)}
style={{
borderRadius: "var(--mantine-radius-sm)",
cursor: "pointer",
background: selected
? "var(--mantine-primary-color-light)"
: "transparent",
}}
>
<Text
size="sm"
fw={selected ? 600 : 400}
truncate
style={{ flex: 1, minWidth: 0 }}
>
{item.label || (
<Text span c="dimmed" size="sm">
Untitled
</Text>
)}
</Text>
{selected && (
<Group gap={0} wrap="nowrap">
<Tooltip label="Move up">
<ActionIcon
size="sm"
variant="subtle"
color="gray"
disabled={!canMove(item.id, -1)}
onClick={(e) => {
e.stopPropagation();
onMove(item.id, -1);
}}
>
<ChevronUp size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label="Move down">
<ActionIcon
size="sm"
variant="subtle"
color="gray"
disabled={!canMove(item.id, 1)}
onClick={(e) => {
e.stopPropagation();
onMove(item.id, 1);
}}
>
<ChevronDown size={14} />
</ActionIcon>
</Tooltip>
</Group>
)}
</Group>
</Box>
);
})}
<Button
variant="subtle"
size="sm"
mt="xs"
leftSection={<Plus size={16} />}
onClick={onAdd}
styles={{ inner: { justifyContent: "flex-start" } }}
fullWidth
>
{addLabel}
</Button>
</Stack>
);
}
export default DocumentRail;

View File

@@ -0,0 +1,100 @@
import { Box, Button, Group, Stack, Text, TextInput } from "@mantine/core";
import { Trash2 } from "lucide-react";
import type { ReactNode } from "react";
interface EditorPaneProps {
/** Large borderless field at the top — the page's own name. */
title: string;
titlePlaceholder: string;
onTitleChange: (next: string) => void;
onRemove: () => void;
removeLabel: string;
children: ReactNode;
}
/**
* The open page. One title, one body, one Remove — everything else about the
* document lives in the rail, so this pane stays as close to a blank sheet as
* the feature set allows.
*/
export function EditorPane({
title,
titlePlaceholder,
onTitleChange,
onRemove,
removeLabel,
children,
}: EditorPaneProps) {
return (
<Stack
gap="md"
p="xl"
style={{
// Basis keeps the editor readable; it wraps below the rail rather than
// being crushed when the window cannot fit both side by side.
flex: "1 1 480px",
minWidth: 0,
background: "#FFFFFF",
border: "1px solid var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-lg)",
}}
>
<TextInput
value={title}
placeholder={titlePlaceholder}
onChange={(e) => onTitleChange(e.currentTarget.value)}
variant="unstyled"
styles={{
input: {
fontSize: "1.6rem",
fontWeight: 700,
lineHeight: 1.3,
height: "auto",
padding: 0,
},
}}
/>
<Box
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
pt="md"
>
{children}
</Box>
<Group justify="flex-end" pt="xs">
<Button
variant="subtle"
color="red"
size="compact-sm"
leftSection={<Trash2 size={14} />}
onClick={onRemove}
>
{removeLabel}
</Button>
</Group>
</Stack>
);
}
/** Shown when a document has no pages yet. */
export function EmptyPane({ message }: { message: string }) {
return (
<Stack
align="center"
justify="center"
p="xl"
style={{
flex: 1,
minHeight: 320,
background: "#FFFFFF",
border: "1px solid var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-lg)",
}}
>
<Text c="dimmed">{message}</Text>
</Stack>
);
}
export default EditorPane;

View File

@@ -1,242 +0,0 @@
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,231 @@
import type { PortalFaqContent, PortalFaqGroup } from "@edr/types";
import { Button, Group, Stack, Text, TextInput } from "@mantine/core";
import { Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { moveAt, newId, removeAt, replaceAt } from "./array-helpers";
import { DocumentRail, type RailItem } from "./DocumentRail";
import { EditorPane, EmptyPane } from "./EditorPane";
import { MarkdownEditor } from "./MarkdownEditor";
interface FaqWorkspaceProps {
value: PortalFaqContent;
onChange: (next: PortalFaqContent) => void;
/** Reseed counter — see the `key` on the editor below. */
seed: number;
}
/**
* The FAQ is two levels deep, so the rail shows each group as a heading with
* its questions beneath — the same outline a customer sees on the page. Picking
* a question opens it; picking the group's own row renames or removes it.
*/
export function FaqWorkspace({
value,
onChange,
seed,
}: FaqWorkspaceProps) {
const groups = value.groups ?? [];
const [selectedId, setSelectedId] = useState<string | null>(
groups[0]?.items[0]?.id ?? groups[0]?.id ?? null,
);
const setGroups = (next: PortalFaqGroup[]) =>
onChange({ ...value, groups: next });
// Every selectable row, in display order: the group's own row, then its
// questions. Flattening once keeps selection and reordering simple.
const rows: RailItem[] = groups.flatMap((group) => [
{ id: group.id, label: group.title || "Untitled group", heading: "Group" },
...group.items.map((item) => ({
id: item.id,
label: item.question,
indented: true,
})),
]);
useEffect(() => {
if (!rows.some((row) => row.id === selectedId)) {
setSelectedId(rows[0]?.id ?? null);
}
}, [rows, selectedId]);
const groupIndex = groups.findIndex((group) => group.id === selectedId);
const selectedGroup = groupIndex >= 0 ? groups[groupIndex] : null;
const ownerIndex = groups.findIndex((group) =>
group.items.some((item) => item.id === selectedId),
);
const owner = ownerIndex >= 0 ? groups[ownerIndex] : null;
const itemIndex =
owner?.items.findIndex((item) => item.id === selectedId) ?? -1;
const selectedItem = owner && itemIndex >= 0 ? owner.items[itemIndex] : null;
const move = (id: string, delta: number) => {
const asGroup = groups.findIndex((group) => group.id === id);
if (asGroup >= 0) {
setGroups(moveAt(groups, asGroup, delta));
return;
}
const at = groups.findIndex((group) =>
group.items.some((item) => item.id === id),
);
if (at < 0) return;
const within = groups[at].items.findIndex((item) => item.id === id);
setGroups(
replaceAt(groups, at, {
...groups[at],
items: moveAt(groups[at].items, within, delta),
}),
);
};
const canMove = (id: string, delta: number) => {
const asGroup = groups.findIndex((group) => group.id === id);
if (asGroup >= 0) {
const target = asGroup + delta;
return target >= 0 && target < groups.length;
}
const at = groups.findIndex((group) =>
group.items.some((item) => item.id === id),
);
if (at < 0) return false;
const within = groups[at].items.findIndex((item) => item.id === id);
const target = within + delta;
return target >= 0 && target < groups[at].items.length;
};
const addQuestion = () => {
// Add into the group the author is currently in, or the last one.
const target = owner ?? selectedGroup ?? groups[groups.length - 1];
if (!target) return;
const at = groups.findIndex((group) => group.id === target.id);
const question = { id: newId(), question: "", answer: "" };
setGroups(
replaceAt(groups, at, {
...target,
items: [...target.items, question],
}),
);
setSelectedId(question.id);
};
const addGroup = () => {
const group = { id: newId(), title: "New group", items: [] };
setGroups([...groups, group]);
setSelectedId(group.id);
};
return (
// Wraps rather than nowrap: with a fixed 280px rail, a narrow window
// squeezed the editor down to ~80px and every embedded picture rendered as
// an unreadable sliver. Below roughly 800px the editor now drops under the
// list instead.
<Group align="flex-start" gap="lg" wrap="wrap">
<Stack gap="xs" style={{ flexShrink: 0 }}>
<DocumentRail
items={rows}
selectedId={selectedId}
onSelect={setSelectedId}
onMove={move}
canMove={canMove}
addLabel="Add a question"
onAdd={addQuestion}
emptyLabel="No groups yet — add one to start."
/>
<Button
variant="subtle"
size="sm"
leftSection={<Plus size={16} />}
onClick={addGroup}
>
Add a group
</Button>
</Stack>
{selectedItem && owner ? (
<EditorPane
title={selectedItem.question}
titlePlaceholder="What is the customer asking?"
onTitleChange={(question) =>
setGroups(
replaceAt(groups, ownerIndex, {
...owner,
items: replaceAt(owner.items, itemIndex, {
...selectedItem,
question,
}),
}),
)
}
onRemove={() =>
setGroups(
replaceAt(groups, ownerIndex, {
...owner,
items: removeAt(owner.items, itemIndex),
}),
)
}
removeLabel="Delete this question"
>
<MarkdownEditor
// MDXEditor reads `markdown` only on mount — without a key,
// switching questions kept the previous answer on screen.
key={`${seed}:${selectedItem.id}`}
value={selectedItem.answer}
onChange={(answer) =>
setGroups(
replaceAt(groups, ownerIndex, {
...owner,
items: replaceAt(owner.items, itemIndex, {
...selectedItem,
answer,
}),
}),
)
}
/>
</EditorPane>
) : selectedGroup ? (
<EditorPane
title={selectedGroup.title}
titlePlaceholder="Group name"
onTitleChange={(title) =>
setGroups(replaceAt(groups, groupIndex, { ...selectedGroup, title }))
}
onRemove={() => setGroups(removeAt(groups, groupIndex))}
removeLabel="Delete this group and its questions"
>
<Stack gap="sm">
<Text size="sm" c="dimmed">
A group is just a heading on the FAQ page. It holds{" "}
{selectedGroup.items.length} question
{selectedGroup.items.length === 1 ? "" : "s"} pick one on the
left to edit it.
</Text>
<TextInput
label="Group name"
value={selectedGroup.title}
onChange={(e) =>
setGroups(
replaceAt(groups, groupIndex, {
...selectedGroup,
title: e.currentTarget.value,
}),
)
}
/>
</Stack>
</EditorPane>
) : (
<EmptyPane message="Add a group to get started." />
)}
</Group>
);
}
export default FaqWorkspace;

View File

@@ -1,125 +0,0 @@
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

@@ -1,110 +0,0 @@
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

@@ -1,22 +1,101 @@
import ReactMarkdown from "react-markdown";
import { isPortalVideoSrc, PORTAL_MEDIA_URI_SCHEME } from "@edr/types";
import { Text } from "@mantine/core";
import { useEffect, useState } from "react";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import { resolvePreview } from "./MarkdownEditor";
// 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.
* Renders one embedded picture or video. Stored copy holds `minio:<key>`, so
* the URL has to be signed before it can be shown; until it resolves the slot
* stays empty rather than flashing a broken image.
*/
function Embed({ src, alt }: { src: string; alt?: string }) {
const [resolved, setResolved] = useState<string | null>(
src.startsWith(PORTAL_MEDIA_URI_SCHEME) ? null : src,
);
useEffect(() => {
let active = true;
void resolvePreview(src).then((url) => {
if (active) setResolved(url);
});
return () => {
active = false;
};
}, [src]);
if (!resolved) return null;
// Spans, not <figure>: markdown wraps an image in a paragraph, and a
// <figure> inside a <p> is invalid HTML the browser silently re-parents —
// which dropped every embed after the first.
return (
<span style={{ display: "block", margin: "1rem 0" }}>
{isPortalVideoSrc(resolved) ? (
<video
src={resolved}
controls
preload="metadata"
style={{ width: "100%", borderRadius: 8, background: "#000" }}
/>
) : (
<img
src={resolved}
alt={alt ?? ""}
style={{ width: "100%", borderRadius: 8 }}
/>
)}
{alt && (
<Text component="span" display="block" size="sm" c="dimmed" mt={4}>
{alt}
</Text>
)}
</span>
);
}
/**
* Read-only markdown rendering, used by the Preview toggle and the version
* history. Editing goes through `MarkdownEditor` (MDXEditor).
*
* Same options as the portal's renderer — no `rehype-raw`, no custom
* `urlTransform` — so neither app grows an HTML-injection surface.
*/
/**
* Module scope on purpose. Declared inline, this object is rebuilt on every
* render, so React sees a brand-new component type for `img` and remounts
* `Embed` each time — which threw away the URL it had just resolved and left
* every uploaded picture and video permanently blank.
*/
const COMPONENTS = {
img: ({ src, alt }: { src?: string; alt?: string }) =>
typeof src === "string" ? <Embed src={src} alt={alt} /> : null,
};
/**
* Lets our own `minio:` refs through, and defers everything else to
* react-markdown's default vetting (which drops `javascript:` and friends).
*
* Needed because the default transform allows only http/https/mailto/tel, so
* an unresolved `minio:` ref was silently blanked and every uploaded picture
* rendered as an empty paragraph. Only the backoffice needs this: the public
* bundle has already had these refs replaced with signed https URLs, so the
* portal's renderer keeps the stock transform untouched.
*/
const urlTransform = (url: string) =>
url.startsWith(PORTAL_MEDIA_URI_SCHEME) ? url : defaultUrlTransform(url);
export function Markdown({ children }: { children: string }) {
return (
<div className="edr-md-content">
<ReactMarkdown>{children}</ReactMarkdown>
<ReactMarkdown components={COMPONENTS} urlTransform={urlTransform}>
{children}
</ReactMarkdown>
</div>
);
}

View File

@@ -1,13 +1,13 @@
import { PORTAL_MEDIA_URI_SCHEME } from "@edr/types";
import { Box, Stack, Text } from "@mantine/core";
import { isPortalVideoSrc, PORTAL_MEDIA_URI_SCHEME } from "@edr/types";
import { Box, Button, Group, SegmentedControl, Stack, Text } from "@mantine/core";
import {
BlockTypeSelect,
BoldItalicUnderlineToggles,
CreateLink,
InsertImage,
InsertThematicBreak,
ListsToggle,
MDXEditor,
type MDXEditorMethods,
UndoRedo,
headingsPlugin,
imagePlugin,
@@ -19,18 +19,21 @@ import {
thematicBreakPlugin,
toolbarPlugin,
} from "@mdxeditor/editor";
import { ImagePlus } from "lucide-react";
import { useRef, useState } from "react";
import "@mdxeditor/editor/style.css";
import { portalContentService } from "@/services/portal-content.service";
import { Markdown } from "./Markdown";
import { MediaDialog } from "./MediaDialog";
import { videoPosterDataUrl, VIDEO_PLACEHOLDER } from "./video-poster";
// 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;
}
/**
@@ -40,12 +43,12 @@ interface MarkdownEditorProps {
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.
* Inserted media is stored as `minio:<key>`, never as the signed URL the upload
* returns: a presigned URL expires, so persisting one would leave every
* embedded picture broken a few hours later. This resolves the ref back to a
* temporary URL purely for display.
*/
function resolvePreview(url: string): Promise<string> {
export 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);
@@ -53,88 +56,135 @@ function resolvePreview(url: string): Promise<string> {
if (!pending) {
pending = portalContentService
.mediaUrl(key)
.catch(() => url); // show a broken image rather than blowing up the editor
// The editor draws every embed as an <img>, so a video src rendered as a
// broken box. Swap in a frame grabbed from the video itself.
.then((signed) =>
isPortalVideoSrc(key) ? videoPosterDataUrl(signed) : signed,
)
.catch(() => (isPortalVideoSrc(key) ? VIDEO_PLACEHOLDER : url));
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>
)}
export function MarkdownEditor({ value, onChange }: MarkdownEditorProps) {
const editorRef = useRef<MDXEditorMethods>(null);
const [mediaOpen, setMediaOpen] = useState(false);
const [mode, setMode] = useState<"write" | "preview">("write");
<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 />
</>
),
}),
return (
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
<Group justify="space-between" align="center">
<Text size="sm" fw={600} c="dimmed">
Page text
</Text>
<SegmentedControl
size="xs"
value={mode}
onChange={(next) => setMode(next as "write" | "preview")}
data={[
{ label: "Write", value: "write" },
{ label: "Preview", value: "preview" },
]}
/>
</Box>
</Group>
{mode === "write" ? (
<Box
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)",
background: "#FFFFFF",
overflow: "hidden",
}}
>
<MDXEditor
ref={editorRef}
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(),
// Kept for rendering existing images; its own insert button is
// replaced by the one below, which also handles video.
imagePlugin({ imagePreviewHandler: resolvePreview }),
markdownShortcutPlugin(),
toolbarPlugin({
toolbarContents: () => (
<>
<UndoRedo />
{/* No underline: markdown has none, so MDXEditor emits a
raw <u> tag — and the portal's renderer drops raw HTML
by design, so the author's emphasis would silently
vanish for the customer. */}
<BoldItalicUnderlineToggles options={["Bold", "Italic"]} />
<BlockTypeSelect />
<ListsToggle />
<CreateLink />
<Button
size="compact-sm"
variant="subtle"
leftSection={<ImagePlus size={16} />}
onClick={() => setMediaOpen(true)}
>
Picture or video
</Button>
<InsertThematicBreak />
</>
),
}),
]}
/>
</Box>
) : (
<Box
p="lg"
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)",
background: "#FFFFFF",
minHeight: 240,
}}
>
{/* The same renderer the customer gets — the only place an author can
watch an embedded video actually play before publishing. */}
<Markdown>{value || "_This page is empty._"}</Markdown>
</Box>
)}
<MediaDialog
opened={mediaOpen}
onClose={() => setMediaOpen(false)}
onInsert={(markdown) => {
editorRef.current?.insertMarkdown(`\n\n${markdown}\n\n`);
// Inserting through the ref bypasses onChange, so push it ourselves.
const next = editorRef.current?.getMarkdown();
if (next !== undefined) onChange(next);
}}
/>
</Stack>
);
}
/** Reminder of the substitution tokens, rendered once per tab. */
/** Reminder of the substitution tokens, shown once per document. */
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).
Type <code>{"{{supportEmail}}"}</code>, <code>{"{{supportPhone}}"}</code>,{" "}
<code>{"{{supportOffice}}"}</code> or <code>{"{{supportHours}}"}</code>{" "}
anywhere and it fills in from the Contact tab change it once there and
every page updates.
</Text>
);
}

View File

@@ -0,0 +1,230 @@
import { PORTAL_MEDIA_URI_SCHEME, SUPPORT_MEDIA_MAX_BYTES } from "@edr/types";
import {
Box,
Button,
Group,
Loader,
Modal,
Progress,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { Film, Image as ImageIcon, UploadCloud } from "lucide-react";
import { useRef, useState } from "react";
import toast from "react-hot-toast";
import { portalContentService } from "@/services/portal-content.service";
interface MediaDialogProps {
opened: boolean;
onClose: () => void;
/** Receives the markdown to drop at the cursor. */
onInsert: (markdown: string) => void;
}
const MAX_MB = Math.round(SUPPORT_MEDIA_MAX_BYTES / (1024 * 1024));
/**
* Adds a picture or video to the text — drop a file, or browse for one.
*
* Replaces MDXEditor's built-in image dialog, which asks for a URL, only takes
* images, and leaves an author who just wants to show a screenshot with nothing
* to do. There is deliberately no "paste a link" field: everything lives in the
* platform, so nothing an editor inserts can rot because someone else's server
* moved a file.
*
* Uploads return an object key; the markdown stores `minio:<key>` and the API
* signs it fresh on every read.
*/
export function MediaDialog({ opened, onClose, onInsert }: MediaDialogProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState<number | null>(null);
const [dragging, setDragging] = useState(false);
const [uploaded, setUploaded] = useState<{
key: string;
kind: "image" | "video";
url: string;
} | null>(null);
const [caption, setCaption] = useState("");
const reset = () => {
setUploaded(null);
setCaption("");
setDragging(false);
if (inputRef.current) inputRef.current.value = "";
};
const close = () => {
reset();
onClose();
};
const upload = async (file: File) => {
if (file.size > SUPPORT_MEDIA_MAX_BYTES) {
toast.error(`That file is over ${MAX_MB} MB.`);
return;
}
setUploading(true);
setProgress(0);
try {
setUploaded(await portalContentService.uploadMedia(file, setProgress));
} catch (error) {
const err = error as {
code?: string;
response?: { data?: { message?: string } };
};
const message =
err.code === "ECONNABORTED"
? "That upload timed out. Check your connection and try again."
: (err.response?.data?.message ?? "Upload failed");
toast.error(Array.isArray(message) ? message.join(", ") : message);
} finally {
setUploading(false);
setProgress(null);
if (inputRef.current) inputRef.current.value = "";
}
};
const insert = () => {
if (!uploaded) return;
// The caption doubles as alt text, so it is worth prompting for.
onInsert(`![${caption}](${PORTAL_MEDIA_URI_SCHEME}${uploaded.key})`);
close();
};
return (
<Modal
opened={opened}
onClose={close}
title="Add a picture or video"
size="lg"
centered
>
<Stack gap="md">
{!uploaded ? (
<Box
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files?.[0];
if (file) void upload(file);
}}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed var(--mantine-color-${dragging ? "green" : "gray"}-4)`,
background: dragging
? "var(--mantine-color-green-0)"
: "var(--mantine-color-gray-0)",
borderRadius: "var(--mantine-radius-md)",
padding: "2.5rem 1.5rem",
textAlign: "center",
cursor: "pointer",
transition: "background 120ms, border-color 120ms",
}}
>
{uploading ? (
<Stack align="center" gap="xs">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{progress === null
? "Uploading…"
: `Uploading… ${progress}%`}
</Text>
{progress !== null && (
<Progress value={progress} w="60%" size="sm" />
)}
</Stack>
) : (
<Stack align="center" gap="xs">
<UploadCloud size={32} strokeWidth={1.5} />
<Text fw={500}>Drop a file here, or click to browse</Text>
<Text size="sm" c="dimmed">
Pictures (PNG, JPG, GIF) and videos (MP4, WebM) up to {MAX_MB} MB
</Text>
</Stack>
)}
</Box>
) : (
<Stack gap="md">
<Box
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)",
padding: "0.75rem",
background: "var(--mantine-color-gray-0)",
}}
>
{uploaded.kind === "video" ? (
<video
src={uploaded.url}
controls
style={{ width: "100%", maxHeight: 320, borderRadius: 8 }}
/>
) : (
<img
src={uploaded.url}
alt=""
style={{
width: "100%",
maxHeight: 320,
objectFit: "contain",
borderRadius: 8,
}}
/>
)}
</Box>
<Group gap="xs" c="dimmed">
{uploaded.kind === "video" ? (
<Film size={16} />
) : (
<ImageIcon size={16} />
)}
<Text size="sm">
{uploaded.kind === "video" ? "Video" : "Picture"} uploaded
</Text>
</Group>
<TextInput
label="Caption"
description="Shown under the picture, and read aloud by screen readers. Optional."
value={caption}
onChange={(e) => setCaption(e.currentTarget.value)}
autoFocus
/>
</Stack>
)}
<input
ref={inputRef}
type="file"
accept="image/*,video/*"
hidden
onChange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) void upload(file);
}}
/>
<Group justify="space-between">
<Button variant="subtle" onClick={uploaded ? reset : close}>
{uploaded ? "Choose a different file" : "Cancel"}
</Button>
<Button onClick={insert} disabled={!uploaded}>
Add to the page
</Button>
</Group>
</Stack>
</Modal>
);
}
export default MediaDialog;

View File

@@ -1,122 +0,0 @@
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

@@ -26,9 +26,9 @@ import {
useUpdatePortalDoc,
} from "@/hooks/portal-content/usePortalContentAdmin";
import { FaqEditor } from "./FaqEditor";
import { HelpEditor } from "./HelpEditor";
import { LegalDocEditor } from "./LegalDocEditor";
import { FaqWorkspace } from "./FaqWorkspace";
import { MarkdownHint } from "./MarkdownEditor";
import { SectionWorkspace } from "./SectionWorkspace";
import { VersionHistoryModal } from "./VersionHistoryModal";
const TABS: { slug: SupportDocSlug; label: string }[] = [
@@ -89,6 +89,14 @@ function DocumentTab({ slug }: { slug: SupportDocSlug }) {
const [note, setNote] = useState("");
const [historyOpen, setHistoryOpen] = useState(false);
/**
* Bumped every time the draft is replaced wholesale rather than edited —
* initial load, save, restore, Reset. The editors key off it to remount,
* because MDXEditor reads its markdown only on mount and would otherwise
* keep showing text the draft no longer holds.
*/
const [seed, setSeed] = useState(0);
// 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.
@@ -98,6 +106,7 @@ function DocumentTab({ slug }: { slug: SupportDocSlug }) {
seededVersion.current = data.version;
setDraft(data.payload);
setNote("");
setSeed((n) => n + 1);
}
}, [data]);
@@ -108,6 +117,7 @@ function DocumentTab({ slug }: { slug: SupportDocSlug }) {
const reset = () => {
setDraft(data.payload);
setNote("");
setSeed((n) => n + 1);
};
return (
@@ -174,7 +184,12 @@ function DocumentTab({ slug }: { slug: SupportDocSlug }) {
</Group>
</Card>
<DocumentEditor slug={slug} value={draft} onChange={setDraft} />
<DocumentEditor
slug={slug}
value={draft}
onChange={setDraft}
seed={seed}
/>
<VersionHistoryModal
slug={slug}
@@ -191,10 +206,13 @@ function DocumentEditor({
slug,
value,
onChange,
seed,
}: {
slug: SupportDocSlug;
value: SupportDocPayload;
onChange: (next: SupportDocPayload) => void;
/** Reseed counter; bumping it remounts the editors. */
seed: number;
}) {
switch (slug) {
case "CONTACT":
@@ -204,23 +222,98 @@ function DocumentEditor({
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}
<FaqWorkspace
value={value as PortalFaqContent}
onChange={onChange}
seed={seed}
/>
);
case "HELP":
case "PRIVACY":
case "TERMS": {
// All three are a title, a subtitle and a list of pages, so they share
// one workspace. `sections` is guarded because a row written before the
// free-form conversion has none.
const doc = value as PortalHelpContent | PortalLegalContent;
return (
<DocumentShell
value={doc}
onChange={onChange}
showLastUpdated={slug !== "HELP"}
seed={seed}
/>
);
}
}
}
/**
* Title, subtitle and (for the legal documents) the "last updated" line, above
* the page list. These three fields describe the whole document, so they sit
* apart from the page being edited.
*/
function DocumentShell({
value,
onChange,
showLastUpdated,
seed,
}: {
value: PortalHelpContent | PortalLegalContent;
onChange: (next: SupportDocPayload) => void;
showLastUpdated: boolean;
seed: number;
}) {
const legal = value as PortalLegalContent;
return (
<Stack gap="lg">
<Card withBorder padding="lg" radius="lg" bg="#FFFFFF">
<Stack gap="md">
<Group grow align="flex-start">
<TextInput
label="Page title"
description="The big heading customers see"
value={value.title}
onChange={(e) =>
onChange({ ...value, title: e.currentTarget.value })
}
/>
{showLastUpdated && (
<TextInput
label="Last updated"
description="Free text, e.g. 6 August 2026"
value={legal.lastUpdated}
onChange={(e) =>
onChange({ ...legal, lastUpdated: e.currentTarget.value })
}
/>
)}
</Group>
<TextInput
label="Intro line"
description="One sentence under the heading"
value={value.subtitle}
onChange={(e) =>
onChange({ ...value, subtitle: e.currentTarget.value })
}
/>
<MarkdownHint />
</Stack>
</Card>
<SectionWorkspace
sections={value.sections ?? []}
onChange={(sections) => onChange({ ...value, sections })}
seed={seed}
/>
</Stack>
);
}
/**
* Four fields, so no separate file. These values feed the help page's contact
* cards and resolve the `{{supportEmail}}`-style placeholders used throughout

View File

@@ -0,0 +1,111 @@
import type { PortalDocSection } from "@edr/types";
import { Group, Stack } from "@mantine/core";
import { useEffect, useState } from "react";
import { moveAt, newId, removeAt, replaceAt } from "./array-helpers";
import { DocumentRail } from "./DocumentRail";
import { EditorPane, EmptyPane } from "./EditorPane";
import { MarkdownEditor } from "./MarkdownEditor";
interface SectionWorkspaceProps {
sections: PortalDocSection[];
onChange: (next: PortalDocSection[]) => void;
/** Reseed counter — see the `key` on the editor below. */
seed: number;
}
/**
* Page-at-a-time editing for any document that is a list of sections — the help
* page and both legal documents. The list on the left is the document's table
* of contents; the pane on the right is the page you are working on.
*/
export function SectionWorkspace({
sections,
onChange,
seed,
}: SectionWorkspaceProps) {
const [selectedId, setSelectedId] = useState<string | null>(
sections[0]?.id ?? null,
);
// Keep a valid selection when the open page is deleted, or when the tab is
// reseeded after a save or a restore.
useEffect(() => {
if (!sections.some((section) => section.id === selectedId)) {
setSelectedId(sections[0]?.id ?? null);
}
}, [sections, selectedId]);
const index = sections.findIndex((section) => section.id === selectedId);
const selected = index >= 0 ? sections[index] : null;
const addSection = () => {
const section = { id: newId(), heading: "", body: "" };
onChange([...sections, section]);
setSelectedId(section.id);
};
return (
// Wraps rather than nowrap: with a fixed 280px rail, a narrow window
// squeezed the editor down to ~80px and every embedded picture rendered as
// an unreadable sliver. Below roughly 800px the editor now drops under the
// list instead.
<Group align="flex-start" gap="lg" wrap="wrap">
<DocumentRail
items={sections.map((section) => ({
id: section.id,
label: section.heading,
}))}
selectedId={selectedId}
onSelect={setSelectedId}
onMove={(id, delta) =>
onChange(
moveAt(
sections,
sections.findIndex((section) => section.id === id),
delta,
),
)
}
canMove={(id, delta) => {
const at = sections.findIndex((section) => section.id === id);
const target = at + delta;
return target >= 0 && target < sections.length;
}}
addLabel="Add a page"
onAdd={addSection}
emptyLabel="This document has no pages yet."
/>
{selected ? (
<EditorPane
title={selected.heading}
titlePlaceholder="Page heading"
onTitleChange={(heading) =>
onChange(replaceAt(sections, index, { ...selected, heading }))
}
onRemove={() => onChange(removeAt(sections, index))}
removeLabel="Delete this page"
>
<Stack gap="md">
<MarkdownEditor
// MDXEditor reads `markdown` only when it mounts, so without a
// key it keeps showing the previously-opened page's text. The
// counter is in the key too, so save, restore and Reset reseed it
// instead of leaving stale content on screen.
key={`${seed}:${selected.id}`}
value={selected.body}
onChange={(body) =>
onChange(replaceAt(sections, index, { ...selected, body }))
}
/>
</Stack>
</EditorPane>
) : (
<EmptyPane message="Add a page to get started." />
)}
</Group>
);
}
export default SectionWorkspace;

View File

@@ -102,8 +102,18 @@
border-radius: 3px;
}
/*
* MDXEditor renders its image node with `width="inherit" height="inherit"`
* attributes, which collapsed a 720px-wide picture into a ~56x124 sliver.
* The `!important` is aimed at those attributes rather than at another
* stylesheet — attribute-derived sizing otherwise wins here.
*/
.edr-md-content img {
display: block;
width: auto !important;
height: auto !important;
max-width: 100%;
height: auto;
max-height: 420px;
border-radius: 8px;
object-fit: contain;
}

View File

@@ -39,11 +39,11 @@ export function summarizeVersion(
return [
{ label: "Title", body: help.title },
{ label: "Subtitle", body: help.subtitle },
...help.sections.map((section) => ({
// Pictures and videos live in the body, so they render in the preview
// alongside the text they belong to — nothing to summarise separately.
...(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,
body: section.body,
})),
];
}

View File

@@ -0,0 +1,84 @@
/**
* A grey card with a play triangle, used when a real frame cannot be grabbed
* (the object store did not send CORS headers, the codec will not decode, or
* the seek timed out). Better than the broken-image icon the editor showed
* before, and it still says "this is a video".
*/
export const VIDEO_PLACEHOLDER =
"data:image/svg+xml;charset=utf-8," +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360">
<rect width="640" height="360" fill="#1f2933"/>
<circle cx="320" cy="180" r="46" fill="#ffffff" fill-opacity="0.9"/>
<path d="M305 156 l40 24 -40 24 z" fill="#1f2933"/>
<text x="320" y="272" font-family="sans-serif" font-size="20"
fill="#ffffff" fill-opacity="0.75" text-anchor="middle">Video</text>
</svg>`,
);
/** Give up rather than hang the editor on a file that will not decode. */
const POSTER_TIMEOUT_MS = 8000;
/**
* Grabs a single frame from a video URL and returns it as a data URL, so the
* editor can show a real thumbnail for an embedded video.
*
* Done in the browser at preview time on purpose: the alternative is
* generating posters server-side on upload, which means ffmpeg, a second
* stored object per video and a naming convention to tie them together — all
* to produce a picture only editors ever look at. Seeking with
* `preload="metadata"` makes the browser range-request just the bytes it needs
* rather than the whole file.
*
* `crossOrigin` is required or the canvas is tainted and `toDataURL` throws;
* if the object store does not allow it we fall back to the placeholder.
*/
export function videoPosterDataUrl(url: string): Promise<string> {
return new Promise((resolve) => {
const video = document.createElement("video");
let settled = false;
const finish = (result: string) => {
if (settled) return;
settled = true;
clearTimeout(timer);
video.removeAttribute("src");
video.load();
resolve(result);
};
const timer = setTimeout(() => finish(VIDEO_PLACEHOLDER), POSTER_TIMEOUT_MS);
video.crossOrigin = "anonymous";
video.preload = "metadata";
video.muted = true;
video.playsInline = true;
video.onloadedmetadata = () => {
// A frame just past the start: the very first frame of a screen
// recording is usually an empty desktop.
video.currentTime = Math.min(1, (video.duration || 2) / 2);
};
video.onseeked = () => {
try {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
if (!canvas.width || !canvas.height) return finish(VIDEO_PLACEHOLDER);
const context = canvas.getContext("2d");
if (!context) return finish(VIDEO_PLACEHOLDER);
context.drawImage(video, 0, 0, canvas.width, canvas.height);
finish(canvas.toDataURL("image/jpeg", 0.7));
} catch {
// Tainted canvas — the object store did not send CORS headers.
finish(VIDEO_PLACEHOLDER);
}
};
video.onerror = () => finish(VIDEO_PLACEHOLDER);
video.src = url;
});
}

View File

@@ -64,6 +64,7 @@ export const portalContentService = {
*/
async uploadMedia(
file: File,
onProgress?: (percent: number | null) => void,
): Promise<{ key: string; kind: PortalMediaKind; url: string }> {
const form = new FormData();
form.append("file", file);
@@ -72,7 +73,15 @@ export const portalContentService = {
key: string;
kind: PortalMediaKind;
url: string;
}>(`${ROOT}/media`, form);
}>(`${ROOT}/media`, form, {
// A video is big enough that a stalled connection would otherwise sit on
// a spinner forever with nothing to tell the author it had failed.
timeout: 2 * 60 * 1000,
onUploadProgress: (event) =>
onProgress?.(
event.total ? Math.round((event.loaded / event.total) * 100) : null,
),
});
return data;
},

View File

@@ -139,10 +139,16 @@ export function stepPayload(
};
}
case "personnel":
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null and
// undefined, so an empty string is validated and 400s with
// "generalManagerEmail must be an email". An Ethiopian company never types
// these — the GM comes from the Fayda verification (or the "same as owner"
// declaration), so the form fields are legitimately blank and would fail a
// step that has no input to fix.
return {
generalManagerName: d.generalManagerName,
generalManagerEmail: d.generalManagerEmail,
generalManagerPhone: d.generalManagerPhone,
generalManagerName: d.generalManagerName || undefined,
generalManagerEmail: d.generalManagerEmail || undefined,
generalManagerPhone: d.generalManagerPhone || undefined,
};
case "contact":
return {

View File

@@ -123,6 +123,40 @@ describe("stepPayload (company)", () => {
});
});
describe("stepPayload (personnel)", () => {
// An Ethiopian company never types the GM — Fayda (or "same as owner") owns
// those fields — so the form holds "". `@IsOptional()` on the DTO skips only
// null/undefined, so an empty string is validated and comes back as
// "generalManagerEmail must be an email", on a step that renders no input.
it("omits blank GM fields instead of sending empty strings", () => {
const payload = stepPayload(
"personnel",
values({
generalManagerName: "",
generalManagerEmail: "",
generalManagerPhone: "",
}),
);
expect(payload.generalManagerName).toBeUndefined();
expect(payload.generalManagerEmail).toBeUndefined();
expect(payload.generalManagerPhone).toBeUndefined();
});
it("still sends typed GM details (foreign company)", () => {
const payload = stepPayload(
"personnel",
values({
generalManagerName: "Abebe Bikila",
generalManagerEmail: "gm@example.com",
generalManagerPhone: "+251911223344",
}),
);
expect(payload.generalManagerName).toBe("Abebe Bikila");
expect(payload.generalManagerEmail).toBe("gm@example.com");
expect(payload.generalManagerPhone).toBe("+251911223344");
});
});
describe("firstPresent", () => {
it("skips empty strings rather than stopping at them", () => {
expect(firstPresent("", " ", "second@example.com")).toBe(

View File

@@ -41,6 +41,22 @@ export default function PoaStep({
formState: { errors },
} = form;
// Fayda's email/phone/address claims are optional and routinely come back
// empty, so a *verified* representative can still be missing the email and
// phone the API demands from a freight forwarder (`REQUIRED_POA_FIELDS`) —
// and the panel above renders no input for them, which dead-ends the step on
// "Add the poa email first". Offer an input for whatever the verification
// did not supply: the API keeps exactly those keys typeable, since a claim
// that returned nothing owns no value to protect (`faydaOwnedKeys`).
const poa = identity?.poa;
// Where Fayda is mandatory an unverified representative must verify rather
// than be typed, so nothing is offered until the verification lands.
const typedAllowed = !identity || poa!.verified || !identity.faydaRequired;
const missing = (value: string | null | undefined) =>
typedAllowed && !value?.trim();
const needsEmail = missing(poa?.email);
const needsPhone = missing(poa?.phone);
return (
<>
<Text size="sm" c="edr-muted">
@@ -60,24 +76,19 @@ export default function PoaStep({
required={requirePoa}
/>
)}
{/* A verified representative's details come from the Fayda claim
and are shown on the panel above. Where Fayda cannot be
required — a foreign company whose representative may hold no
Fayda ID — they are typed here instead. They have to be: the
API refuses to save a freight forwarder's PoA without a name,
email and phone (`REQUIRED_POA_FIELDS`), and before this the
step rendered no input for any of them, so the customer was
told to "add the poa name, poa email, poa phone" with nowhere
to add them. */}
{!identity?.poa.verified && !identity?.faydaRequired && (
<>
<TextInput
label="Representative's Name"
placeholder="Abebe Bikila"
error={errors.poaName?.message}
{...register("poaName")}
/>
<SimpleGrid cols={2} spacing="md">
{/* Whatever the Fayda claim did carry is shown on the panel above and
is never typed here — the verification owns it. */}
{missing(poa?.name) && (
<TextInput
label="Representative's Name"
placeholder="Abebe Bikila"
error={errors.poaName?.message}
{...register("poaName")}
/>
)}
{(needsEmail || needsPhone) && (
<SimpleGrid cols={needsEmail && needsPhone ? 2 : 1} spacing="md">
{needsEmail && (
<TextInput
label="Representative's Email"
type="email"
@@ -85,19 +96,23 @@ export default function PoaStep({
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
)}
{needsPhone && (
<ControlledPhoneField
control={control}
name="poaPhone"
label="Representative's Phone"
/>
</SimpleGrid>
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
</>
)}
</SimpleGrid>
)}
{missing(poa?.address) && (
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
)}
{/* The paper authorises the representative, so it shows once one

View File

@@ -64,6 +64,7 @@ import {
} from "@/pages/bookings/booking-display";
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
import { formatRateUnit } from "./new-contract-form/unit-rates";
import { formatAmount } from "./new-shipment-form/total";
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
import { getContractBookingAction } from "./contract-booking-action";
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
@@ -719,7 +720,7 @@ export default function ContractDetailPage() {
)}
</Box>
<Text fz={14} fw={700} style={{ color: GREEN }}>
{(item.unitPrice ?? 0).toLocaleString()} {pricing.currency}{" "}
{formatAmount(item.unitPrice)} {pricing.currency}{" "}
<Text span fz={12} fw={600} c="dimmed">
/ {formatRateUnit(item.unit)}
</Text>
@@ -1336,9 +1337,7 @@ export default function ContractDetailPage() {
whiteSpace: "nowrap",
}}
>
{amount > 0
? `ETB ${amount.toLocaleString()}`
: "—"}
{amount > 0 ? `ETB ${formatAmount(amount)}` : "—"}
</Text>
</Table.Td>
</Table.Tr>

View File

@@ -75,7 +75,7 @@ import {
createShipmentFormSchema,
initialShipmentFormValues,
} from "./new-shipment-form/schema";
import { computeShipmentTotal } from "./new-shipment-form/total";
import { computeShipmentTotal, formatAmount } from "./new-shipment-form/total";
import {
downloadContainerImportTemplate,
parseContainerExcel,
@@ -1014,7 +1014,7 @@ function PriceConfirmModal({
))}
<Text fz="xs" c="#9A5B00" mt={2}>
{overweightSurchargeAmount > 0
? `An overweight surcharge of ${overweightSurchargeAmount.toLocaleString()} ${
? `An overweight surcharge of ${formatAmount(overweightSurchargeAmount)} ${
validation?.currency ?? total?.currency ?? ""
} applies (included in the total below). You can still submit, or go back and adjust weights.`
: "An overweight surcharge applies. You can still submit, or go back and adjust weights."}
@@ -1038,7 +1038,7 @@ function PriceConfirmModal({
</Text>
<Text fz="xs" c="dimmed">
{line.quantity.toLocaleString()} ×{" "}
{line.unitPrice.toLocaleString()} {total.currency} ·{" "}
{formatAmount(line.unitPrice)} {total.currency} ·{" "}
{formatRateUnit(line.unit)}
</Text>
</Box>
@@ -1048,7 +1048,7 @@ function PriceConfirmModal({
c="#10202F"
style={{ whiteSpace: "nowrap" }}
>
{line.amount.toLocaleString()} {total.currency}
{formatAmount(line.amount)} {total.currency}
</Text>
</Group>
))}
@@ -1070,7 +1070,7 @@ function PriceConfirmModal({
Total
</Text>
<Text fw={800} fz={28} c="#10202F">
{total.total.toLocaleString()}{" "}
{formatAmount(total.total)}{" "}
<Text span fz={16} fw={700} c="edr-muted">
{total.currency}
</Text>

View File

@@ -15,6 +15,19 @@ export interface ShipmentTotal {
total: number;
}
/**
* Money always prints its cents. Bare toLocaleString() defaults to
* maximumFractionDigits: 0, which rounded the total away from the line items it
* sums (118,171.21 shown as 118,171) — and the customer is billed the exact
* amount, so the shown figure must match to the cent.
*/
export function formatAmount(amount: number | string | null | undefined) {
return Number(amount ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
/**
* Quantity a bulk rate bills, in ITS OWN unit. PER_ITEM cargo carries both
* figures — the item count prices the booking, the tonnage sizes the wagons —

View File

@@ -20,6 +20,8 @@ interface DocShellProps {
meta?: string;
/** Path of the current page, so it is not linked to itself. */
current: string;
/** Contents column, mirroring the page list editors see in the backoffice. */
sidebar?: ReactNode;
children: ReactNode;
}
@@ -33,12 +35,13 @@ export function DocShell({
subtitle,
meta,
current,
sidebar,
children,
}: DocShellProps) {
return (
<div className="min-h-screen bg-background text-foreground">
<header className="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-xl">
<div className="mx-auto flex max-w-4xl items-center justify-between gap-4 px-6 py-4">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-6 py-4">
<Link to="/" className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Train className="size-5" />
@@ -56,7 +59,7 @@ export function DocShell({
</div>
</header>
<main className="mx-auto max-w-4xl px-6 py-12">
<main className="mx-auto max-w-6xl px-6 py-12">
<h1 className="text-4xl font-black tracking-tight">{title}</h1>
<p className="mt-4 text-lg leading-8 text-muted-foreground">
{subtitle}
@@ -65,11 +68,18 @@ export function DocShell({
<p className="mt-2 text-sm text-muted-foreground">{meta}</p>
)}
<div className="mt-10">{children}</div>
{sidebar ? (
<div className="mt-10 flex flex-col gap-8 lg:flex-row lg:gap-10">
{sidebar}
<div className="min-w-0 flex-1">{children}</div>
</div>
) : (
<div className="mt-10">{children}</div>
)}
</main>
<footer className="border-t border-border py-8">
<div className="mx-auto flex max-w-4xl flex-col items-center justify-between gap-4 px-6 text-sm text-muted-foreground md:flex-row">
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 px-6 text-sm text-muted-foreground md:flex-row">
<span>© 2026 EDR Freight. All rights reserved.</span>
<nav className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
{DOC_LINKS.filter((l) => l.to !== current).map((link) => (
@@ -96,8 +106,11 @@ export function DocShell({
export function DocSections({ sections }: { sections: PortalDocSection[] }) {
return (
<div className="space-y-10">
{sections.map((section) => (
<section key={section.id}>
{/* Guarded: this is API-supplied, and these routes are public. */}
{(sections ?? []).map((section) => (
// `id` doubles as the anchor the contents list and #deep-links target;
// `scroll-mt` keeps the heading clear of the sticky header.
<section key={section.id} id={section.id} className="scroll-mt-24">
<h2 className="text-xl font-bold tracking-tight">
{section.heading}
</h2>

View File

@@ -0,0 +1,117 @@
import { useEffect, useState } from "react";
export interface DocNavItem {
id: string;
label: string;
}
/**
* Highlights whichever section is currently on screen, so a long policy tells
* the reader where they are. Watches the top band of the viewport, which is
* what makes the highlight change as a heading scrolls past rather than only
* when a whole section fits.
*/
export function useActiveSection(ids: string[]): string | null {
const [active, setActive] = useState<string | null>(ids[0] ?? null);
useEffect(() => {
if (ids.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible[0]) setActive(visible[0].target.id);
},
// Top ~quarter of the viewport, below the sticky header.
{ rootMargin: "-80px 0px -70% 0px", threshold: 0 },
);
for (const id of ids) {
const element = document.getElementById(id);
if (element) observer.observe(element);
}
return () => observer.disconnect();
}, [ids.join("|")]); // eslint-disable-line react-hooks/exhaustive-deps
return active;
}
interface DocSidebarProps {
items: DocNavItem[];
activeId: string | null;
/**
* Paged mode (the help page): selecting swaps the visible section. Omit for
* table-of-contents mode, where entries are anchors into one long document.
*/
onSelect?: (id: string) => void;
heading?: string;
}
/**
* The document's contents, mirroring the page list an editor sees in the
* backoffice. Sticky on wide screens; above the content on narrow ones, where
* a fixed column would eat the reading width.
*/
export function DocSidebar({
items,
activeId,
onSelect,
heading = "Contents",
}: DocSidebarProps) {
if (items.length === 0) return null;
return (
<nav
aria-label={heading}
// Divider: a right edge once the columns sit side by side, a bottom edge
// while they are stacked — so the contents always reads as its own
// column rather than as text that happens to be to the left.
className="border-b border-border pb-6 lg:sticky lg:top-24 lg:w-64 lg:shrink-0 lg:self-start lg:border-b-0 lg:border-r lg:pb-0 lg:pr-6"
>
<p className="px-3 text-xs font-bold uppercase tracking-wider text-foreground/60">
{heading}
</p>
<ul className="mt-3 max-h-[60vh] space-y-1 overflow-y-auto lg:max-h-[calc(100vh-10rem)]">
{items.map((item) => {
const active = item.id === activeId;
// Inactive entries were `muted-foreground`, which is the same tone as
// body copy and left the list looking disabled. These are navigation,
// so they sit closer to full foreground and darken on hover.
const className = `block w-full rounded-xl px-3 py-2 text-left text-sm transition ${
active
? "bg-accent font-semibold text-foreground"
: "text-foreground/75 hover:bg-accent/60 hover:text-foreground"
}`;
return (
<li key={item.id}>
{onSelect ? (
<button
type="button"
onClick={() => onSelect(item.id)}
aria-current={active ? "page" : undefined}
className={className}
>
{item.label}
</button>
) : (
<a
href={`#${item.id}`}
aria-current={active ? "true" : undefined}
className={className}
>
{item.label}
</a>
)}
</li>
);
})}
</ul>
</nav>
);
}
export default DocSidebar;

View File

@@ -4,22 +4,38 @@ import { Link } from "react-router-dom";
import { usePortalContent } from "@/hooks/usePortalContent";
import { DocShell } from "./DocShell";
import { DocSidebar, useActiveSection } from "./DocSidebar";
import { Markdown } from "./Markdown";
export default function FaqPage() {
// Never undefined — see TermsPage.
const { data } = usePortalContent();
const faq = data!.faq;
const groups = faq.groups ?? [];
// Contents lists the groups; individual questions stay as the disclosure
// rows below, which is already the fastest way to scan them.
const activeId = useActiveSection(groups.map((group) => group.id));
return (
<DocShell current="/faq" title={faq.title} subtitle={faq.subtitle}>
<DocShell
current="/faq"
title={faq.title}
subtitle={faq.subtitle}
sidebar={
<DocSidebar
items={groups.map((group) => ({ id: group.id, label: group.title }))}
activeId={activeId}
/>
}
>
<div className="space-y-10">
{faq.groups.map((group) => (
<section key={group.id}>
{groups.map((group) => (
<section key={group.id} id={group.id} className="scroll-mt-24">
<h2 className="text-xl font-bold tracking-tight">{group.title}</h2>
<div className="mt-4 space-y-3">
{group.items.map((item) => (
{(group.items ?? []).map((item) => (
// Native disclosure: keyboard- and screen-reader-accessible
// without any state of our own.
<details

View File

@@ -1,74 +1,75 @@
import type { PortalMedia } from "@edr/types";
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { usePortalContent } from "@/hooks/usePortalContent";
import { DocShell } from "./DocShell";
import { DocSidebar } from "./DocSidebar";
import { Markdown } from "./Markdown";
import { safeMediaSrc } from "./portal-content";
/**
* An attached image or video. The API has already swapped stored MinIO keys for
* freshly signed URLs, so `src` is ready to render — `safeMediaSrc` is a last
* check that an admin-entered value is a path or an https URL.
* Help is browsed a page at a time, mirroring how it is written: the sidebar is
* the list of pages, and picking one swaps the panel. Unlike the legal
* documents nobody reads help end to end — they arrive with one question — so
* showing a single answer beats a wall of every topic at once.
*/
function Media({ item }: { item: PortalMedia }) {
const src = safeMediaSrc(item.src);
if (!src) return null;
return (
<figure className="mt-6">
{item.kind === "video" ? (
// preload="metadata" so a large file is not pulled on every visit; the
// browser fetches it only once playback starts.
<video
controls
preload="metadata"
className="w-full rounded-[32px] border border-border bg-black"
>
<source src={src} />
Your browser cannot play this video.{" "}
<a href={src}>Download it instead</a>.
</video>
) : (
<img
src={src}
alt={item.caption ?? ""}
loading="lazy"
className="w-full rounded-[32px] border border-border"
/>
)}
{item.caption && (
<figcaption className="mt-2 text-sm text-muted-foreground">
{item.caption}
</figcaption>
)}
</figure>
);
}
export default function HelpPage() {
// Never undefined — see TermsPage.
const { data } = usePortalContent();
const help = data!.help;
const sections = help.sections ?? [];
const navigate = useNavigate();
const { hash } = useLocation();
// The open page lives in the URL, so a link to a specific topic works and
// Back steps between topics.
const fromHash = hash ? decodeURIComponent(hash.slice(1)) : "";
const [selectedId, setSelectedId] = useState(
() => fromHash || sections[0]?.id || "",
);
useEffect(() => {
const valid = sections.some((section) => section.id === selectedId);
if (!valid) setSelectedId(fromHash || sections[0]?.id || "");
}, [sections, selectedId, fromHash]);
useEffect(() => {
if (fromHash && fromHash !== selectedId) setSelectedId(fromHash);
}, [fromHash]); // eslint-disable-line react-hooks/exhaustive-deps
const selected =
sections.find((section) => section.id === selectedId) ?? sections[0];
return (
<DocShell current="/help" title={help.title} subtitle={help.subtitle}>
<div className="space-y-12">
{help.sections.map((section) => (
<section key={section.id}>
<h2 className="text-xl font-bold tracking-tight">
{section.heading}
</h2>
<Markdown>{section.body}</Markdown>
{section.media.map((item) => (
<Media key={item.id} item={item} />
))}
</section>
))}
</div>
<DocShell
current="/help"
title={help.title}
subtitle={help.subtitle}
sidebar={
<DocSidebar
heading="Topics"
items={sections.map((section) => ({
id: section.id,
label: section.heading,
}))}
activeId={selected?.id ?? null}
onSelect={(id) => {
setSelectedId(id);
navigate(`#${id}`);
window.scrollTo({ top: 0, behavior: "smooth" });
}}
/>
}
>
{selected && (
<section>
<h2 className="text-2xl font-bold tracking-tight">
{selected.heading}
</h2>
<Markdown>{selected.body}</Markdown>
</section>
)}
</DocShell>
);
}

View File

@@ -1,5 +1,8 @@
import { isPortalVideoSrc } from "@edr/types";
import ReactMarkdown from "react-markdown";
import { safeMediaSrc } from "./portal-content";
/**
* Renders admin-authored markdown from the support-content API.
*
@@ -10,7 +13,9 @@ import ReactMarkdown from "react-markdown";
* Two things must stay absent for that to hold:
* - `rehype-raw`, which would start rendering raw HTML embedded in the copy;
* - a custom `urlTransform`, which would override the built-in stripping of
* `javascript:` and `data:` hrefs.
* `javascript:` and `data:` hrefs. That transform is now the *only* thing
* vetting embedded media URLs, since they are authored inside the markdown
* rather than validated field-by-field on the API.
*
* `remark-gfm` is also left out: tables and strikethrough are not used in the
* legal or FAQ copy, and CommonMark already covers lists, emphasis and links.
@@ -51,17 +56,55 @@ export function Markdown({ children }: { children: string }) {
h3: ({ children: content }) => (
<h3 className="mt-6 font-bold tracking-tight">{content}</h3>
),
// Images embedded by the editor. The API has already resolved these to
// signed URLs; react-markdown's default urlTransform still guards the
// scheme.
img: ({ src, alt }) => (
<img
src={typeof src === "string" ? src : undefined}
alt={alt ?? ""}
loading="lazy"
className="mt-4 w-full rounded-2xl border border-border"
/>
),
/**
* Images *and* videos: both are written with markdown's image syntax,
* and the element is chosen from the file extension. `src` here has
* already been through react-markdown's url transform; `safeMediaSrc`
* narrows it further to same-origin paths and https.
*/
img: ({ src, alt }) => {
const resolved =
typeof src === "string" ? safeMediaSrc(src) : null;
if (!resolved) return null;
// Spans, not <figure>/<figcaption>: markdown wraps an image in a
// paragraph, and a <figure> inside a <p> is invalid HTML that the
// browser silently re-parents, which drops sibling content.
const caption = alt ? (
<span className="mt-2 block text-sm text-muted-foreground">
{alt}
</span>
) : null;
if (isPortalVideoSrc(resolved)) {
return (
<span className="mt-6 block">
{/* preload="metadata" so a large file is not pulled on every
visit; the browser fetches it once playback starts. */}
<video
controls
preload="metadata"
className="w-full rounded-[32px] border border-border bg-black"
>
<source src={resolved} />
</video>
{caption}
</span>
);
}
return (
<span className="mt-6 block">
<img
src={resolved}
alt={alt ?? ""}
loading="lazy"
className="w-full rounded-[32px] border border-border"
/>
{caption}
</span>
);
},
}}
>
{children}

View File

@@ -1,11 +1,15 @@
import { usePortalContent } from "@/hooks/usePortalContent";
import { DocSections, DocShell } from "./DocShell";
import { DocSidebar, useActiveSection } from "./DocSidebar";
export default function PrivacyPolicyPage() {
// Never undefined — see TermsPage.
const { data } = usePortalContent();
const privacy = data!.privacy;
const sections = privacy.sections ?? [];
const activeId = useActiveSection(sections.map((section) => section.id));
return (
<DocShell
@@ -13,8 +17,17 @@ export default function PrivacyPolicyPage() {
title={privacy.title}
subtitle={privacy.subtitle}
meta={`Last updated ${privacy.lastUpdated}`}
sidebar={
<DocSidebar
items={sections.map((section) => ({
id: section.id,
label: section.heading,
}))}
activeId={activeId}
/>
}
>
<DocSections sections={privacy.sections} />
<DocSections sections={sections} />
</DocShell>
);
}

View File

@@ -1,12 +1,18 @@
import { usePortalContent } from "@/hooks/usePortalContent";
import { DocSections, DocShell } from "./DocShell";
import { DocSidebar, useActiveSection } from "./DocSidebar";
export default function TermsPage() {
// Never undefined — the hook seeds it with the shipped copy, so this public
// page renders instantly and survives the API being unreachable.
const { data } = usePortalContent();
const terms = data!.terms;
const sections = terms.sections ?? [];
// A contents list, not a pager: the whole document stays on one page so it
// can be searched, printed and deep-linked to a clause.
const activeId = useActiveSection(sections.map((section) => section.id));
return (
<DocShell
@@ -14,8 +20,17 @@ export default function TermsPage() {
title={terms.title}
subtitle={terms.subtitle}
meta={`Last updated ${terms.lastUpdated}`}
sidebar={
<DocSidebar
items={sections.map((section) => ({
id: section.id,
label: section.heading,
}))}
activeId={activeId}
/>
}
>
<DocSections sections={terms.sections} />
<DocSections sections={sections} />
</DocShell>
);
}

View File

@@ -67,11 +67,16 @@ describe("withSupportVars", () => {
});
describe("safeMediaSrc", () => {
it("keeps same-origin paths and https sources", () => {
it("keeps same-origin paths and http(s) sources", () => {
expect(safeMediaSrc("/assets/guide.webm")).toBe("/assets/guide.webm");
expect(safeMediaSrc("https://minio.internal/support-content/a.png?sig=x")).toBe(
"https://minio.internal/support-content/a.png?sig=x",
);
// Signed object-store URLs are plain http in dev — rejecting them hid
// every upload.
expect(safeMediaSrc("http://localhost:9000/fhc/support-content/a.mp4")).toBe(
"http://localhost:9000/fhc/support-content/a.mp4",
);
});
it("drops javascript: and protocol-relative sources", () => {

View File

@@ -90,14 +90,17 @@ export function withSupportVars(
}
/**
* Accepts only a same-origin path or an https URL for an attached image or
* video, returning null for anything else so the caller renders nothing.
* Accepts a same-origin path or an http(s) URL for an attached image or video,
* returning null for anything else so the caller renders nothing.
*
* `(?!\/)` rejects protocol-relative `//host/...`, which would otherwise pass
* as a path. An `<img>`/`<source>` src is not a navigation, so a `javascript:`
* URL would not execute anyway — but the guard is cheaper than re-deriving
* that every time someone reads this file.
* The point is to exclude `javascript:` and `data:`, not to require TLS: the
* signed MinIO URLs the API hands back are plain http wherever the object
* store is (dev, and any deployment terminating TLS elsewhere), and rejecting
* those made every uploaded picture and video vanish from the page.
*
* `(?!\/)` still rejects protocol-relative `//host/...`, which would otherwise
* pass as a path.
*/
export function safeMediaSrc(src: string): string | null {
return /^(https:\/\/|\/(?!\/))/.test(src) ? src : null;
return /^(https?:\/\/|\/(?!\/))/.test(src) ? src : null;
}

View File

@@ -36,40 +36,29 @@ export const SUPPORT_CONTENT_DEFAULTS: SupportDocPayloadMap = {
{
id: "help-walkthrough",
heading: "Portal walkthrough",
body: "A guided tour of the portal — registering your company, raising a booking against a contract, and settling an invoice.",
media: [
{
id: "help-walkthrough-video",
kind: "video",
// Ships with the app rather than MinIO, so it is used verbatim.
src: "/assets/edr-portal-guide.webm",
caption: null,
},
],
// The video ships with the app rather than MinIO, so its path is used
// verbatim; the renderer picks <video> from the extension.
body: "A guided tour of the portal — registering your company, raising a booking against a contract, and settling an invoice.\n\n![](/assets/edr-portal-guide.webm)",
},
{
id: "help-chat",
heading: "Chat with our team",
body: "Signed-in customers can open a support conversation from the headset button at the bottom right of every portal page. You can send screenshots and documents in the chat, and replies appear there and as a notification.\n\n[Open the portal](/portal)",
media: [],
},
{
id: "help-contact",
heading: "Contact us",
body: "- **Email** — [{{supportEmail}}](mailto:{{supportEmail}}). Best for document issues and anything needing an attachment.\n- **Phone** — [{{supportPhone}}](tel:{{supportPhoneTel}}). Best for urgent problems with cargo already in transit.\n- **Head office** — {{supportOffice}}. Walk-in support during working hours.\n- **Support hours** — {{supportHours}}. Outside these hours, email us and we reply the next working day.",
media: [],
},
{
id: "help-topics",
heading: "Common topics",
body: "- **[Account & onboarding](/faq)** — registering your company, uploading your trade licence and TIN, and getting an operational profile approved.\n- **[Contracts](/faq)** — requesting a freight contract, reviewing its terms and signing it with your saved signature and stamp.\n- **[Bookings & tracking](/faq)** — raising a booking against a contract, adding last-mile transport and following the consignment along the corridor.\n- **[Invoices & payments](/faq)** — finding invoices, paying through the bank channels and confirming a payment that has not yet settled.",
media: [],
},
{
id: "help-checklist",
heading: "What to include when you contact us",
body: "- Your company name and the email you sign in with.\n- The reference of the contract, booking or invoice involved.\n- What you expected to happen and what happened instead.\n- A screenshot of any error message the portal showed.",
media: [],
},
],
},

View File

@@ -38,17 +38,27 @@ export const SUPPORT_MEDIA_MAX_BYTES = 50 * 1024 * 1024;
export type PortalMediaKind = "image" | "video";
/** An image or video attached to a help section. */
export interface PortalMedia {
id: string;
kind: PortalMediaKind;
/**
* Stored: a MinIO object key, a same-origin `/path`, or an `https://` URL.
* Served: the same value with MinIO keys replaced by a fresh presigned URL —
* the API rewrites this field in place, so the portal just renders it.
*/
src: string;
caption?: string | null;
/**
* Extensions the portal renders as a `<video>` rather than an `<img>`.
*
* Images and videos are both inserted with markdown's image syntax —
* `![caption](minio:support-content/clip.mp4)` — so an author drops media into
* the text in one flow and it stays where they put it. The renderer picks the
* element from the extension; nothing extra is stored.
*/
export const PORTAL_VIDEO_EXTENSIONS = [
".mp4",
".webm",
".ogg",
".ogv",
".mov",
".m4v",
] as const;
/** True when a media URL or key should render as a video. */
export function isPortalVideoSrc(src: string): boolean {
const path = src.split(/[?#]/)[0].toLowerCase();
return PORTAL_VIDEO_EXTENSIONS.some((ext) => path.endsWith(ext));
}
/**
@@ -126,31 +136,19 @@ export interface PortalFaqContent {
}
/**
* One free-form block of the help page: a heading, a full markdown body, and
* any number of attached images or videos.
* Slug `HELP`. Free-form: an ordered list of sections, each a heading plus a
* markdown body, with images and videos embedded in the body itself.
*
* Deliberately not a fixed set of typed blocks (video / channels / topics /
* checklist, as this once was). The help page is the one document whose shape
* genuinely changes with what support needs to explain that quarter, so it is
* built rather than filled in — add, reorder and delete sections freely.
* built rather than filled in — and it shares {@link PortalDocSection} with the
* legal documents, so every tab is edited the same way.
*/
export interface PortalHelpSection {
id: string;
heading: string;
/**
* Markdown. Embedded images reference uploads as
* `![alt](minio:support-content/<file>)` — see {@link PORTAL_MEDIA_URI_SCHEME}.
*/
body: string;
/** Rendered under the body, in order. */
media: PortalMedia[];
}
/** Slug `HELP`. */
export interface PortalHelpContent {
title: string;
subtitle: string;
sections: PortalHelpSection[];
sections: PortalDocSection[];
}
/** Payload shape per slug — the jsonb column's type, keyed by document. */