mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -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 ``, 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 ``;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ------------------------------- */
|
||||
|
||||
@@ -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  below.",
|
||||
media: [
|
||||
{ id: "m1", kind: "image" as const, src: "support-content/a.png" },
|
||||
],
|
||||
body:
|
||||
"See  and " +
|
||||
".",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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 [
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
]) {
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,42 +56,56 @@ 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");
|
||||
|
||||
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" },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{mode === "write" ? (
|
||||
<Box
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
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.
|
||||
// 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);
|
||||
}}
|
||||
@@ -99,23 +116,30 @@ export function MarkdownEditor({
|
||||
linkPlugin(),
|
||||
linkDialogPlugin(),
|
||||
thematicBreakPlugin(),
|
||||
imagePlugin({
|
||||
imageUploadHandler: async (file) => {
|
||||
const { key } = await portalContentService.uploadMedia(file);
|
||||
return `${PORTAL_MEDIA_URI_SCHEME}${key}`;
|
||||
},
|
||||
imagePreviewHandler: resolvePreview,
|
||||
}),
|
||||
// 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 />
|
||||
<BoldItalicUnderlineToggles />
|
||||
{/* 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 />
|
||||
<InsertImage />
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
leftSection={<ImagePlus size={16} />}
|
||||
onClick={() => setMediaOpen(true)}
|
||||
>
|
||||
Picture or video
|
||||
</Button>
|
||||
<InsertThematicBreak />
|
||||
</>
|
||||
),
|
||||
@@ -123,18 +147,44 @@ export function MarkdownEditor({
|
||||
]}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(``);
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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,21 +222,96 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
{/* 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")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
)}
|
||||
{(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>
|
||||
)}
|
||||
{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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
|
||||
117
apps/edr-freight-web/portal/src/pages/support/DocSidebar.tsx
Normal file
117
apps/edr-freight-web/portal/src/pages/support/DocSidebar.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
<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>{section.body}</Markdown>
|
||||
|
||||
{section.media.map((item) => (
|
||||
<Media key={item.id} item={item} />
|
||||
))}
|
||||
<Markdown>{selected.body}</Markdown>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DocShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }) => (
|
||||
/**
|
||||
* 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={typeof src === "string" ? src : undefined}
|
||||
src={resolved}
|
||||
alt={alt ?? ""}
|
||||
loading="lazy"
|
||||
className="mt-4 w-full rounded-2xl border border-border"
|
||||
className="w-full rounded-[32px] border border-border"
|
||||
/>
|
||||
),
|
||||
{caption}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
/**
|
||||
* Extensions the portal renders as a `<video>` rather than an `<img>`.
|
||||
*
|
||||
* Images and videos are both inserted with markdown's image syntax —
|
||||
* `` — 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.
|
||||
*/
|
||||
src: string;
|
||||
caption?: string | null;
|
||||
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
|
||||
* `` — 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. */
|
||||
|
||||
Reference in New Issue
Block a user