inside a is invalid HTML that the
+ // browser silently re-parents, which drops sibling content.
+ const caption = alt ? (
+
+ {alt}
+
+ ) : null;
+
+ if (isPortalVideoSrc(resolved)) {
+ return (
+
+ {/* preload="metadata" so a large file is not pulled on every
+ visit; the browser fetches it once playback starts. */}
+
+
+
+ {caption}
+
+ );
+ }
+
+ return (
+
+
+ {caption}
+
+ );
+ },
}}
>
{children}
diff --git a/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx b/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx
index 07e79d207..a924d6514 100644
--- a/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx
@@ -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 (
({
+ id: section.id,
+ label: section.heading,
+ }))}
+ activeId={activeId}
+ />
+ }
>
-
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx b/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx
index 6302e838d..671882ff1 100644
--- a/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx
@@ -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 (
({
+ id: section.id,
+ label: section.heading,
+ }))}
+ activeId={activeId}
+ />
+ }
>
-
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/support/portal-content.test.ts b/apps/edr-freight-web/portal/src/pages/support/portal-content.test.ts
index 2523ca3ae..07b2c09b6 100644
--- a/apps/edr-freight-web/portal/src/pages/support/portal-content.test.ts
+++ b/apps/edr-freight-web/portal/src/pages/support/portal-content.test.ts
@@ -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", () => {
diff --git a/apps/edr-freight-web/portal/src/pages/support/portal-content.ts b/apps/edr-freight-web/portal/src/pages/support/portal-content.ts
index 06bfdbce9..66c5910f8 100644
--- a/apps/edr-freight-web/portal/src/pages/support/portal-content.ts
+++ b/apps/edr-freight-web/portal/src/pages/support/portal-content.ts
@@ -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 ` `/`` 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;
}
diff --git a/packages/types/src/freight/portal-content.defaults.ts b/packages/types/src/freight/portal-content.defaults.ts
index 30ae3cf79..bd3ffe204 100644
--- a/packages/types/src/freight/portal-content.defaults.ts
+++ b/packages/types/src/freight/portal-content.defaults.ts
@@ -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 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: [],
},
],
},
diff --git a/packages/types/src/freight/portal-content.ts b/packages/types/src/freight/portal-content.ts
index 38af9e41e..104236133 100644
--- a/packages/types/src/freight/portal-content.ts
+++ b/packages/types/src/freight/portal-content.ts
@@ -38,17 +38,27 @@ export const SUPPORT_MEDIA_MAX_BYTES = 50 * 1024 * 1024;
export type PortalMediaKind = "image" | "video";
-/** An image or video attached to a help section. */
-export interface PortalMedia {
- id: string;
- kind: PortalMediaKind;
- /**
- * Stored: a MinIO object key, a same-origin `/path`, or an `https://` URL.
- * Served: the same value with MinIO keys replaced by a fresh presigned URL —
- * the API rewrites this field in place, so the portal just renders it.
- */
- src: string;
- caption?: string | null;
+/**
+ * Extensions the portal renders as a `` rather than an ` `.
+ *
+ * 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.
+ */
+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. */