Merge pull request #1181 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-08 12:38:32 +03:00
committed by GitHub
48 changed files with 6627 additions and 669 deletions

View File

@@ -50,6 +50,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
import { SupportContentModule } from "./modules/support-content/support-content.module";
import { OtpModule } from "./modules/otp/otp.module"; import { OtpModule } from "./modules/otp/otp.module";
import { HealthModule } from "./modules/health/health.module"; import { HealthModule } from "./modules/health/health.module";
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
@@ -67,6 +68,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
import { PaymentModule } from "./modules/payment/payment.module"; import { PaymentModule } from "./modules/payment/payment.module";
// import { PricingDataSeeder } from "./seed/pricing-data.seeder"; // import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { SupportContentSeeder } from "./seed/support-content.seeder";
// import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder"; // import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder";
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; // import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; // import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
@@ -220,6 +222,7 @@ if (!process.env.APPLICATION_NAME) {
DropdownSettingsModule, DropdownSettingsModule,
ExchangeSettingsModule, ExchangeSettingsModule,
ContractTemplatesModule, ContractTemplatesModule,
SupportContentModule,
OtpModule, OtpModule,
HealthModule, HealthModule,
RuleEngineModule, RuleEngineModule,
@@ -260,6 +263,7 @@ if (!process.env.APPLICATION_NAME) {
EdrOrgSeeder, EdrOrgSeeder,
FreightPositionsSeeder, FreightPositionsSeeder,
FileUploadSettingsSeeder, FileUploadSettingsSeeder,
SupportContentSeeder,
// YardFacilitiesSeeder, // YardFacilitiesSeeder,
FreightPermissionKeyMigrationSeeder, FreightPermissionKeyMigrationSeeder,
FreightNotificationPermissionsSeeder, FreightNotificationPermissionsSeeder,
@@ -291,6 +295,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly edrOrgSeeder: EdrOrgSeeder, private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly supportContentSeeder: SupportContentSeeder,
// private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, // private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly freightNotificationPermissionsSeeder: FreightNotificationPermissionsSeeder, private readonly freightNotificationPermissionsSeeder: FreightNotificationPermissionsSeeder,
@@ -349,6 +354,10 @@ export class AppModule implements OnApplicationBootstrap {
// File upload settings — keep enabled. // File upload settings — keep enabled.
await this.fileUploadSettingsSeeder.run(); await this.fileUploadSettingsSeeder.run();
// Portal help/FAQ/legal copy — keep enabled. Idempotent by emptiness, so
// it fills an empty table once and never touches admin edits afterwards.
await this.supportContentSeeder.run();
// Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama, // Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama,
// Dire Dawa). Idempotent; creates no yards. // Dire Dawa). Idempotent; creates no yards.
// await this.yardFacilitiesSeeder.run(); // await this.yardFacilitiesSeeder.run();

View File

@@ -0,0 +1,73 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Editable customer-facing copy for the portal's public pages (/help, /faq,
* /terms, /privacy) plus the shared support-contact block, with an append-only
* version log behind it.
*
* `payload` is opaque jsonb: the five documents have genuinely different shapes
* and the help page's blocks change with the copy, so typed columns would mean
* a migration per wording tweak. The shape is enforced by per-slug DTOs on
* write instead.
*
* No rows are inserted here — `SupportContentSeeder` fills the table on first
* boot and skips whenever it is non-empty, so a redeploy never overwrites
* admin edits the way a migration-embedded INSERT eventually would.
*/
export class SupportContent3350000000000 implements MigrationInterface {
name = "SupportContent3350000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.support_documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug varchar(32) NOT NULL,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
version integer NOT NULL DEFAULT 1,
updated_by_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_support_documents_slug
ON freight.support_documents (slug);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.support_document_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
document_id uuid NOT NULL
REFERENCES freight.support_documents(id) ON DELETE CASCADE,
version integer NOT NULL,
payload jsonb NOT NULL,
actor_id uuid,
note varchar(255),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
// Closes the concurrent-save race: two editors saving at once cannot both
// claim the same version number.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_support_doc_version
ON freight.support_document_versions (document_id, version);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_support_doc_versions_document
ON freight.support_document_versions (document_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.support_document_versions;`,
);
await queryRunner.query(`DROP TABLE IF EXISTS freight.support_documents;`);
}
}

View File

@@ -0,0 +1,112 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Converts the HELP document from its original fixed-block shape
* (`video` / `chat` / `channels` / `topics` / `checklist`) to the free-form
* `sections[]` builder, where every block is a heading plus markdown plus
* attached media.
*
* Only rows still in the old shape are touched — detected by the presence of a
* `channels` key — so this is a no-op on any environment seeded after the
* change, and re-running it does nothing.
*
* The payload literal is inlined rather than imported from
* `SUPPORT_CONTENT_DEFAULTS`: a migration must keep doing the same thing
* forever, and that constant will keep moving.
*
* The rewrite also bumps `version` and writes a matching history row. The live
* row's version always having a matching entry in
* `support_document_versions` is the invariant the history list and rollback
* both depend on, and a silent payload swap would break it.
*/
const HELP_SECTIONS = [
{
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",
src: "/assets/edr-portal-guide.webm",
caption: null,
},
],
},
{
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: [],
},
];
export class SupportHelpSections3360000000000 implements MigrationInterface {
name = "SupportHelpSections3360000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
const rows: { id: string; version: number; payload: Record<string, unknown> }[] =
await queryRunner.query(`
SELECT id, version, payload
FROM freight.support_documents
WHERE slug = 'HELP' AND payload ? 'channels'
`);
for (const row of rows) {
const payload = {
title: row.payload.title ?? "Help & Support",
subtitle:
row.payload.subtitle ??
"Get answers fast — watch the walkthrough, browse the common topics, check the FAQ, or reach our team directly.",
sections: HELP_SECTIONS,
};
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),
"Converted help page to free-form sections",
],
);
}
}
/**
* Not reversible: the old fixed blocks cannot be recovered from markdown
* sections an editor may since have rewritten. The version history holds the
* pre-conversion payload if it is ever genuinely needed.
*/
public async down(): Promise<void> {
// no-op
}
}

View File

@@ -0,0 +1,311 @@
import { SUPPORT_MEDIA_PREFIX, SupportDocSlug } from "@edr/types";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
ArrayMaxSize,
IsArray,
IsIn,
IsObject,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
ValidateNested,
} from "class-validator";
/**
* Markdown bodies are safe on read — the portal renders them with
* `react-markdown` and no `rehype-raw`, so any HTML in them is inert. The
* fields worth validating are these: they land in `href`/`src` attributes and
* bypass markdown entirely, which is where a `javascript:` URL would actually
* execute.
*
* Placeholders survive the check because they sit after the scheme
* (`mailto:{{supportEmail}}`, `tel:{{supportPhoneTel}}`).
*/
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 {
@ApiProperty()
@IsString()
@MinLength(3)
@MaxLength(200)
email!: string;
@ApiProperty()
@IsString()
@MinLength(3)
@MaxLength(60)
phone!: string;
@ApiProperty()
@IsString()
@MinLength(2)
@MaxLength(200)
office!: string;
@ApiProperty()
@IsString()
@MinLength(2)
@MaxLength(200)
hours!: string;
}
/* ---------------------------- PRIVACY / TERMS --------------------------- */
export class PortalDocSectionDto {
@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;
}
export class PortalLegalContentDto {
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
title!: string;
@ApiProperty()
@IsString()
@MaxLength(500)
subtitle!: string;
@ApiProperty({ description: 'Free text, e.g. "6 August 2026"' })
@IsString()
@MaxLength(60)
lastUpdated!: string;
@ApiProperty({ type: [PortalDocSectionDto] })
@IsArray()
@ArrayMaxSize(60)
@ValidateNested({ each: true })
@Type(() => PortalDocSectionDto)
sections!: PortalDocSectionDto[];
}
/* --------------------------------- FAQ --------------------------------- */
export class PortalCtaCardDto {
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
heading!: string;
@ApiProperty({ description: "Markdown" })
@IsString()
@MaxLength(2_000)
body!: string;
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(100)
ctaLabel!: string;
@ApiProperty()
@IsString()
@Matches(LINK_PATTERN, { message: LINK_MESSAGE })
@MaxLength(500)
ctaTo!: string;
}
export class PortalFaqItemDto {
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(300)
question!: string;
@ApiProperty({ description: "Markdown" })
@IsString()
@MaxLength(5_000)
answer!: string;
}
export class PortalFaqGroupDto {
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
title!: string;
@ApiProperty({ type: [PortalFaqItemDto] })
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => PortalFaqItemDto)
items!: PortalFaqItemDto[];
}
export class PortalFaqContentDto {
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
title!: string;
@ApiProperty()
@IsString()
@MaxLength(500)
subtitle!: string;
@ApiProperty({ type: [PortalFaqGroupDto] })
@IsArray()
@ArrayMaxSize(20)
@ValidateNested({ each: true })
@Type(() => PortalFaqGroupDto)
groups!: PortalFaqGroupDto[];
@ApiPropertyOptional({ type: PortalCtaCardDto, nullable: true })
@IsOptional()
@ValidateNested()
@Type(() => PortalCtaCardDto)
footer?: PortalCtaCardDto | null;
}
/* --------------------------------- 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()
@MinLength(1)
@MaxLength(200)
title!: string;
@ApiProperty()
@IsString()
@MaxLength(500)
subtitle!: string;
@ApiProperty({ type: [PortalHelpSectionDto] })
@IsArray()
@ArrayMaxSize(40)
@ValidateNested({ each: true })
@Type(() => PortalHelpSectionDto)
sections!: PortalHelpSectionDto[];
}
/* ------------------------------- request ------------------------------- */
export class UpdateSupportDocumentDto {
@ApiProperty({
description:
"The document's whole payload. Validated against the shape for its slug.",
type: Object,
})
@IsObject()
payload!: Record<string, unknown>;
@ApiPropertyOptional({ description: "Why this change was made" })
@IsOptional()
@IsString()
@MaxLength(255)
note?: string;
}
/** Which DTO class a slug's payload is validated against on write. */
export const PAYLOAD_DTO_BY_SLUG: Record<
SupportDocSlug,
new () => object
> = {
CONTACT: PortalSupportContactDto,
HELP: PortalHelpContentDto,
FAQ: PortalFaqContentDto,
PRIVACY: PortalLegalContentDto,
TERMS: PortalLegalContentDto,
};

View File

@@ -0,0 +1,69 @@
import { BaseEntity } from "@edr/api-common";
import type { SupportDocPayload, SupportDocSlug } from "@edr/types";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
/**
* One editable customer-facing document for the freight portal's public pages
* (/help, /faq, /terms, /privacy) plus the support contact block they all
* quote. Five fixed rows, keyed by slug and seeded on first boot — there is no
* create/delete route.
*
* The payload is opaque jsonb because the five documents have genuinely
* different shapes and the help page's blocks keep changing; typed columns
* would mean a migration per copy tweak. Shape safety lives in the per-slug
* DTOs the service validates against on write, the same arrangement
* `contract_templates.articles` already uses.
*
* `version` is the live row's version and always equals `max(version)` in
* {@link SupportDocumentVersion} — the seeder writes v1 and its history row
* together, so the log is never missing the current state.
*/
@Entity({ schema: "freight", name: "support_documents" })
@Index(["slug"], { unique: true })
export class SupportDocument extends BaseEntity {
@Column({ name: "slug", type: "varchar", length: 32, unique: true })
slug!: SupportDocSlug;
@Column({ name: "payload", type: "jsonb", default: () => "'{}'::jsonb" })
payload!: SupportDocPayload;
@Column({ name: "version", type: "int", default: 1 })
version!: number;
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
updatedById?: string | null;
}
/**
* Append-only history of every saved state of a document, written in the same
* transaction as the live row. Restoring is not a pointer reset: it re-saves an
* old payload through the normal write path, producing a *new* version, so a
* restore is itself undoable and the log only ever grows.
*
* Modelled on {@link CompanyRevision}, but snapshots the whole payload rather
* than a field diff — rollback needs the full state, not the delta.
*/
@Entity({ schema: "freight", name: "support_document_versions" })
@Index(["documentId"])
export class SupportDocumentVersion extends BaseEntity {
@Column({ name: "document_id", type: "uuid" })
documentId!: string;
@ManyToOne(() => SupportDocument, { onDelete: "CASCADE" })
@JoinColumn({ name: "document_id" })
document?: SupportDocument;
@Column({ name: "version", type: "int" })
version!: number;
@Column({ name: "payload", type: "jsonb" })
payload!: SupportDocPayload;
/** Who saved it. Null for the seeded initial version. */
@Column({ name: "actor_id", type: "uuid", nullable: true })
actorId?: string | null;
/** Optional "why" the editor typed, shown in the history list. */
@Column({ name: "note", type: "varchar", length: 255, nullable: true })
note?: string | null;
}

View File

@@ -0,0 +1,29 @@
import { Public } from "@edr/api-common";
import { Controller, Get, Header } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { SupportContentService } from "./support-content.service";
/**
* The portal's /help, /faq, /terms and /privacy routes are deliberately outside
* its auth guard — the sign-up screen links to them before a session exists —
* so this read must work with no token. `@Public()` is class-level because that
* is the idiom the other genuinely-anonymous controllers here use; without it
* the globally registered `JwtGuard` would 401 every anonymous visitor.
*/
@ApiTags("support-content")
@Public()
@Controller("support-content")
export class PublicSupportContentController {
constructor(private readonly service: SupportContentService) {}
@Get()
// Hit on every anonymous page view, and the copy changes a few times a year.
@Header("Cache-Control", "public, max-age=300")
@ApiOperation({
summary: "Public help, FAQ, legal and support-contact copy for the portal",
})
getBundle() {
return this.service.getBundle();
}
}

View File

@@ -0,0 +1,135 @@
import { CurrentUser } from "@edr/api-common";
import { SUPPORT_MEDIA_MAX_BYTES } from "@edr/types";
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UploadedFile,
UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import {
ApiBearerAuth,
ApiConsumes,
ApiOperation,
ApiQuery,
ApiTags,
} from "@nestjs/swagger";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { UpdateSupportDocumentDto } from "./dto/support-content.dto";
import { SupportContentService } from "./support-content.service";
const READ = [
FREIGHT_PERMS.settings.supportContent.view,
FREIGHT_PERMS.settings.supportContent.manage,
FREIGHT_PERMS.admin,
];
const WRITE = [
FREIGHT_PERMS.settings.supportContent.manage,
FREIGHT_PERMS.admin,
];
@ApiTags("support-content")
@ApiBearerAuth()
@Controller("support-content")
export class SupportContentController {
constructor(private readonly service: SupportContentService) {}
/**
* Stores a help-page image or video and returns its object key. The editor
* saves the key, not the returned URL — see `SupportContentService.uploadMedia`.
*
* The Multer limit duplicates the service-side type check on purpose: it
* stops reading the socket once the part is oversized instead of buffering
* the whole thing into memory first.
*/
@Post("media")
@BookingStaff(WRITE)
@UseInterceptors(
FileInterceptor("file", { limits: { fileSize: SUPPORT_MEDIA_MAX_BYTES } }),
)
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload an image or video for a help section" })
uploadMedia(@UploadedFile() file: Express.Multer.File) {
return this.service.uploadMedia(file);
}
/**
* Signs one stored key so the editor can preview an already-saved image.
* The editor stores `minio:<key>`, never a signed URL, so it needs somewhere
* to resolve those refs for display — this is it.
*/
@Get("media-url")
@BookingStaff(READ)
@ApiQuery({ name: "key", description: "MinIO object key" })
@ApiOperation({ summary: "Presigned URL for one stored media key" })
mediaUrl(@Query("key") key: string) {
return this.service.mediaUrl(key);
}
@Get("documents")
@BookingStaff(READ)
@ApiOperation({ summary: "List the five portal content documents (no payloads)" })
list() {
return this.service.list();
}
@Get("documents/:slug")
@BookingStaff(READ)
@ApiOperation({ summary: "Get one document with its payload" })
getBySlug(@Param("slug") slug: string) {
return this.service.getBySlug(slug);
}
@Patch("documents/:slug")
@BookingStaff(WRITE)
@ApiOperation({
summary: "Replace a document's payload, recording a new version",
})
update(
@Param("slug") slug: string,
@Body() dto: UpdateSupportDocumentDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.update(slug, dto, user?.id ?? null);
}
@Get("documents/:slug/versions")
@BookingStaff(READ)
@ApiOperation({ summary: "Version history, newest first (no payloads)" })
listVersions(@Param("slug") slug: string) {
return this.service.listVersions(slug);
}
@Get("documents/:slug/versions/:version")
@BookingStaff(READ)
@ApiOperation({ summary: "One historical version, with its payload" })
getVersion(
@Param("slug") slug: string,
@Param("version", ParseIntPipe) version: number,
) {
return this.service.getVersion(slug, version);
}
@Post("documents/:slug/versions/:version/restore")
@BookingStaff(WRITE)
@ApiOperation({
summary: "Restore a version — re-saves it as a new version, never destructive",
})
restore(
@Param("slug") slug: string,
@Param("version", ParseIntPipe) version: number,
@CurrentUser() user: TCurrentUser,
) {
return this.service.restore(slug, version, user?.id ?? null);
}
}

View File

@@ -0,0 +1,23 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { MinioModule } from "../minio/minio.module";
import {
SupportDocument,
SupportDocumentVersion,
} from "./entities/support-document.entity";
import { PublicSupportContentController } from "./public-support-content.controller";
import { SupportContentController } from "./support-content.controller";
import { SupportContentRepository } from "./support-content.repository";
import { SupportContentService } from "./support-content.service";
@Module({
imports: [
TypeOrmModule.forFeature([SupportDocument, SupportDocumentVersion]),
MinioModule,
],
controllers: [PublicSupportContentController, SupportContentController],
providers: [SupportContentRepository, SupportContentService],
exports: [SupportContentService],
})
export class SupportContentModule {}

View File

@@ -0,0 +1,71 @@
import { BaseRepository } from "@edr/api-common";
import type { SupportDocSlug } from "@edr/types";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import {
SupportDocument,
SupportDocumentVersion,
} from "./entities/support-document.entity";
@Injectable()
export class SupportContentRepository extends BaseRepository<SupportDocument> {
constructor(
@InjectRepository(SupportDocument)
repository: Repository<SupportDocument>,
@InjectRepository(SupportDocumentVersion)
private readonly versions: Repository<SupportDocumentVersion>,
) {
super(repository);
}
findBySlug(slug: SupportDocSlug): Promise<SupportDocument | null> {
return this.repository.findOne({ where: { slug } });
}
override findAll(): Promise<SupportDocument[]> {
return this.repository.find({ order: { slug: "ASC" } });
}
findVersions(documentId: string): Promise<SupportDocumentVersion[]> {
return this.versions.find({
where: { documentId },
order: { version: "DESC" },
});
}
findVersion(
documentId: string,
version: number,
): Promise<SupportDocumentVersion | null> {
return this.versions.findOne({ where: { documentId, version } });
}
/**
* Bump the live row and append its history entry atomically. Doing both in
* one transaction is what guarantees `document.version` always has a matching
* version row — the invariant the history list and rollback both rely on.
*/
async saveWithVersion(
document: SupportDocument,
actorId: string | null,
note: string | null,
): Promise<SupportDocument> {
return this.repository.manager.transaction(async (manager) => {
const saved = await manager.save(SupportDocument, document);
await manager.save(
manager.create(SupportDocumentVersion, {
documentId: saved.id,
version: saved.version,
payload: saved.payload,
actorId,
note,
}),
);
return saved;
});
}
}

View File

@@ -0,0 +1,237 @@
import {
SUPPORT_CONTENT_DEFAULTS,
SUPPORT_DOC_SLUGS,
SupportDocPayload,
SupportDocSlug,
} from "@edr/types";
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { MinioService } from "../minio/minio.service";
import {
SupportDocument,
SupportDocumentVersion,
} from "./entities/support-document.entity";
import { SupportContentRepository } from "./support-content.repository";
import { SupportContentService, validatePayload } from "./support-content.service";
const ACTOR = "11111111-1111-4111-8111-111111111111";
/**
* In-memory stand-in for the repository: one seeded document plus its version
* log, with `saveWithVersion` doing what the real transaction does. Enough to
* assert the rollback contract without a database.
*/
function makeRepository(slug: SupportDocSlug) {
const document = {
id: "00000000-0000-0000-0000-000000000001",
slug,
payload: SUPPORT_CONTENT_DEFAULTS[slug],
version: 1,
updatedById: null,
} as SupportDocument;
const versions: SupportDocumentVersion[] = [
{
id: "v1",
documentId: document.id,
version: 1,
payload: document.payload,
actorId: null,
note: "Initial content",
} as SupportDocumentVersion,
];
const repository = {
findAll: jest.fn(async () => [document]),
findBySlug: jest.fn(async (s: SupportDocSlug) =>
s === slug ? document : null,
),
findVersions: jest.fn(async () => [...versions].reverse()),
findVersion: jest.fn(
async (_id: string, version: number) =>
versions.find((v) => v.version === version) ?? null,
),
saveWithVersion: jest.fn(
async (doc: SupportDocument, actorId: string | null, note: string | null) => {
versions.push({
id: `v${doc.version}`,
documentId: doc.id,
version: doc.version,
payload: doc.payload,
actorId,
note,
} as SupportDocumentVersion);
return doc;
},
),
};
// Signing is exercised only through getBundle; the write path never touches it.
const minio = {
getSignedUrl: jest.fn(async (key: string) => `https://minio.test/${key}?sig=x`),
uploadFile: jest.fn(),
};
return {
document,
versions,
minio,
service: new SupportContentService(
repository as unknown as SupportContentRepository,
minio as unknown as MinioService,
),
};
}
describe("SupportContentService.restore", () => {
it("rolls back to an older payload as a NEW version, leaving history intact", async () => {
const { document, versions, service } = makeRepository("CONTACT");
const original = SUPPORT_CONTENT_DEFAULTS.CONTACT;
await service.update(
"CONTACT",
{ payload: { ...original, phone: "+251 99 999 9999" } },
ACTOR,
);
expect(document.version).toBe(2);
expect(versions.map((v) => v.version)).toEqual([1, 2]);
const restored = await service.restore("CONTACT", 1, ACTOR);
expect(restored.payload).toEqual(original);
// The assertion that matters: restore is additive. If someone "optimises"
// it into a destructive pointer reset this drops back to 1 and rollback
// silently stops being undoable.
expect(restored.version).toBe(3);
expect(versions.map((v) => v.version)).toEqual([1, 2, 3]);
expect(versions[2].note).toBe("Restored version 1");
});
it("404s on a version that was never written", async () => {
const { service } = makeRepository("CONTACT");
await expect(service.restore("CONTACT", 99, ACTOR)).rejects.toBeInstanceOf(
NotFoundException,
);
});
it("rejects an unknown slug", async () => {
const { service } = makeRepository("CONTACT");
await expect(service.getBySlug("NOPE")).rejects.toBeInstanceOf(
BadRequestException,
);
});
});
describe("SupportContentService.getBundle", () => {
it("signs stored keys on read and leaves paths and URLs alone", async () => {
const { service, minio } = makeRepository("CONTACT");
const help = SUPPORT_CONTENT_DEFAULTS.HELP;
// 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(
"/assets/edr-portal-guide.webm",
);
expect(minio.getSignedUrl).not.toHaveBeenCalled();
const withUpload = {
...help,
sections: [
{
...help.sections[0],
body: "See ![diagram](minio:support-content/d.png) below.",
media: [
{ id: "m1", kind: "image" as const, src: "support-content/a.png" },
],
},
],
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const signed = await (service as any).signMedia({
...bundle,
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",
);
// 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");
});
});
describe("validatePayload", () => {
const help = SUPPORT_CONTENT_DEFAULTS.HELP;
it("accepts every shipped default", () => {
for (const slug of SUPPORT_DOC_SLUGS) {
expect(() =>
validatePayload(slug, SUPPORT_CONTENT_DEFAULTS[slug]),
).not.toThrow();
}
});
const sectionWithMedia = (src: string) => ({
...help,
sections: [{ ...help.sections[0], media: [{ kind: "video", src }] }],
});
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",
]) {
expect(() => validatePayload("HELP", sectionWithMedia(src))).not.toThrow();
}
});
it("rejects a media kind that is neither image nor video", () => {
expect(() =>
validatePayload("HELP", {
...help,
sections: [
{
...help.sections[0],
media: [{ kind: "pdf", src: "support-content/a.pdf" }],
},
],
}),
).toThrow(BadRequestException);
});
it("keeps placeholder links, which sit after the scheme", () => {
expect(() =>
validatePayload("FAQ", {
...SUPPORT_CONTENT_DEFAULTS.FAQ,
footer: {
...SUPPORT_CONTENT_DEFAULTS.FAQ.footer!,
ctaTo: "mailto:{{supportEmail}}",
},
}),
).not.toThrow();
});
it("generates ids for items saved without one", () => {
const legal = validatePayload("TERMS", {
...SUPPORT_CONTENT_DEFAULTS.TERMS,
sections: [{ heading: "1. New", body: "Body." }],
}) as Extract<SupportDocPayload, { sections: unknown }>;
expect(legal.sections[0].id).toEqual(expect.any(String));
expect(legal.sections[0].id.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,368 @@
import {
PORTAL_MEDIA_URI_SCHEME,
PortalContentBundle,
PortalFaqContent,
PortalHelpContent,
PortalLegalContent,
PortalMediaKind,
SUPPORT_CONTENT_DEFAULTS,
SUPPORT_DOC_SLUGS,
SUPPORT_MEDIA_PREFIX,
SupportDocPayload,
SupportDocSlug,
} from "@edr/types";
import {
BadRequestException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { extname } from "path";
import { MinioService } from "../minio/minio.service";
import { plainToInstance } from "class-transformer";
import { validateSync, ValidationError } from "class-validator";
import { randomUUID } from "crypto";
import {
PAYLOAD_DTO_BY_SLUG,
UpdateSupportDocumentDto,
} from "./dto/support-content.dto";
import {
SupportDocument,
SupportDocumentVersion,
} from "./entities/support-document.entity";
import { SupportContentRepository } from "./support-content.repository";
/**
* A paste-bomb in one section would bloat the public bundle every anonymous
* visitor downloads, so the whole payload is capped as well as its fields.
*/
const MAX_PAYLOAD_BYTES = 200_000;
/**
* Comfortably longer than the 5-minute `Cache-Control` on the public bundle, so
* a cached response never outlives the URLs inside it.
*/
const MEDIA_URL_TTL_SECONDS = 6 * 60 * 60;
/** `minio:support-content/<file>` inside markdown. */
const MEDIA_REF = new RegExp(
`${PORTAL_MEDIA_URI_SCHEME}([A-Za-z0-9._\\-/]+)`,
"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(
private readonly repository: SupportContentRepository,
private readonly minio: MinioService,
) {}
/**
* Stores an image or video for the help page and returns its object key.
*
* The key is what gets saved in the document — never the signed URL. A
* presigned URL expires, so persisting one would leave every embedded image
* broken a few hours later; signing happens per read instead.
*/
async uploadMedia(
file?: Express.Multer.File,
): Promise<{ key: string; kind: PortalMediaKind; url: string }> {
if (!file) throw new BadRequestException("No file uploaded");
const kind: PortalMediaKind | null = file.mimetype.startsWith("image/")
? "image"
: file.mimetype.startsWith("video/")
? "video"
: null;
if (!kind) {
throw new BadRequestException(
`Unsupported file type ${file.mimetype} — images and videos only`,
);
}
const key = `${SUPPORT_MEDIA_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`;
await this.minio.uploadFile(key, file.buffer, file.mimetype);
return {
key,
kind,
url: await this.minio.getSignedUrl(key, MEDIA_URL_TTL_SECONDS),
};
}
/** Presigned URL for one stored key, for the backoffice editor's previews. */
async mediaUrl(key: string): Promise<{ url: string }> {
if (!key?.startsWith(SUPPORT_MEDIA_PREFIX)) {
throw new BadRequestException(
`key must be an uploaded ${SUPPORT_MEDIA_PREFIX} object`,
);
}
return { url: await this.minio.getSignedUrl(key, MEDIA_URL_TTL_SECONDS) };
}
/**
* The public bundle. Missing rows fall back to the shipped defaults so an
* unseeded or half-migrated environment still serves the legal pages rather
* than 404-ing the first thing an anonymous visitor sees.
*/
async getBundle(): Promise<PortalContentBundle> {
const documents = await this.repository.findAll();
const bySlug = new Map(documents.map((d) => [d.slug, d.payload]));
const payload = <S extends SupportDocSlug>(slug: S) =>
(bySlug.get(slug) ?? SUPPORT_CONTENT_DEFAULTS[slug]) as never;
return this.signMedia({
contact: payload("CONTACT"),
help: payload("HELP"),
faq: payload("FAQ"),
privacy: payload("PRIVACY"),
terms: payload("TERMS"),
});
}
/**
* 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.
*
* 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
* `MinioService.getSignedUrl`).
*/
private async signMedia(
bundle: PortalContentBundle,
): 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]);
} else if (Array.isArray(value)) {
value.forEach(collect);
} else if (value && typeof value === "object") {
Object.values(value).forEach(collect);
}
};
collect(bundle);
if (keys.size === 0) return bundle;
const signed = new Map(
await Promise.all(
[...keys].map(
async (key) =>
[key, await this.minio.getSignedUrl(key, MEDIA_URL_TTL_SECONDS)] as const,
),
),
);
const rewrite = (value: unknown): unknown => {
if (typeof value === "string") {
return value.replace(MEDIA_REF, (whole, key: string) =>
signed.get(key) ?? whole,
);
}
if (Array.isArray(value)) return value.map(rewrite);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, rewrite(v)]),
);
}
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;
}
/** Admin list — metadata only, no payloads. */
async list(): Promise<Omit<SupportDocument, "payload">[]> {
const documents = await this.repository.findAll();
return documents.map(({ payload: _payload, ...rest }) => rest);
}
async getBySlug(rawSlug: string): Promise<SupportDocument> {
const slug = assertSlug(rawSlug);
const document = await this.repository.findBySlug(slug);
if (!document) {
throw new NotFoundException(`Support document ${slug} not found`);
}
return document;
}
/**
* Replace a document's whole payload. Writing the whole slice rather than
* patching fields is deliberate: one editorial change becomes exactly one
* version, which is what keeps the history readable.
*/
async update(
rawSlug: string,
dto: UpdateSupportDocumentDto,
actorId: string | null,
): Promise<SupportDocument> {
const document = await this.getBySlug(rawSlug);
const payload = validatePayload(document.slug, dto.payload);
document.payload = payload;
document.version += 1;
document.updatedById = actorId;
return this.repository.saveWithVersion(document, actorId, dto.note ?? null);
}
async listVersions(rawSlug: string): Promise<SupportDocumentVersion[]> {
const document = await this.getBySlug(rawSlug);
return this.repository.findVersions(document.id);
}
async getVersion(
rawSlug: string,
version: number,
): Promise<SupportDocumentVersion> {
const document = await this.getBySlug(rawSlug);
const found = await this.repository.findVersion(document.id, version);
if (!found) {
throw new NotFoundException(
`Version ${version} of ${document.slug} not found`,
);
}
return found;
}
/**
* Roll back to an earlier version by re-saving its payload through the normal
* write path. The result is a NEW version whose content equals the old one —
* never a destructive pointer reset — so the history only grows and a restore
* is itself undoable.
*/
async restore(
rawSlug: string,
version: number,
actorId: string | null,
): Promise<SupportDocument> {
const target = await this.getVersion(rawSlug, version);
return this.update(
rawSlug,
{
payload: target.payload as unknown as Record<string, unknown>,
note: `Restored version ${version}`,
},
actorId,
);
}
}
/* ------------------------------ helpers ------------------------------- */
export function assertSlug(raw: string): SupportDocSlug {
const slug = raw?.toUpperCase() as SupportDocSlug;
if (!SUPPORT_DOC_SLUGS.includes(slug)) {
throw new BadRequestException(
`Unknown support document "${raw}". Valid slugs: ${SUPPORT_DOC_SLUGS.join(", ")}`,
);
}
return slug;
}
/**
* Validate an incoming payload against the DTO registered for its slug, then
* fill in any missing item ids. Exported so the seeder's spec can assert the
* shipped defaults are themselves valid.
*/
export function validatePayload(
slug: SupportDocSlug,
raw: unknown,
): SupportDocPayload {
if (JSON.stringify(raw ?? null).length > MAX_PAYLOAD_BYTES) {
throw new BadRequestException(
`Payload for ${slug} exceeds ${MAX_PAYLOAD_BYTES} bytes`,
);
}
const instance = plainToInstance(PAYLOAD_DTO_BY_SLUG[slug], raw ?? {});
const errors = validateSync(instance as object, {
whitelist: true,
forbidNonWhitelisted: true,
});
if (errors.length) {
throw new BadRequestException(flattenErrors(errors));
}
return withGeneratedIds(slug, instance as SupportDocPayload);
}
function flattenErrors(errors: ValidationError[], path = ""): string[] {
return errors.flatMap((error) => {
const here = path ? `${path}.${error.property}` : error.property;
const own = Object.values(error.constraints ?? {}).map(
(message) => `${here}: ${message}`,
);
return [...own, ...flattenErrors(error.children ?? [], here)];
});
}
const withId = <T extends { id?: string }>(item: T): T => ({
...item,
id: item.id || randomUUID(),
});
/**
* Item ids are the React keys in the portal, and admin-authored headings and
* questions collide too easily to use as keys. Editors may omit them; the
* server mints one rather than making every client remember to.
*/
function withGeneratedIds(
slug: SupportDocSlug,
payload: SupportDocPayload,
): SupportDocPayload {
switch (slug) {
case "HELP": {
const help = payload as PortalHelpContent;
return {
...help,
sections: help.sections.map((section) => ({
...withId(section),
media: (section.media ?? []).map(withId),
})),
};
}
case "FAQ": {
const faq = payload as PortalFaqContent;
return {
...faq,
groups: faq.groups.map((group) => ({
...withId(group),
items: group.items.map(withId),
})),
};
}
case "PRIVACY":
case "TERMS": {
const legal = payload as PortalLegalContent;
return { ...legal, sections: legal.sections.map(withId) };
}
default:
return payload;
}
}

View File

@@ -1295,6 +1295,16 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:settings:contract_templates:read", "edr_freight_app:settings:contract_templates:read",
"Read contract template data (API only)", "Read contract template data (API only)",
), ),
perm(
"b4f00001-0001-4000-8000-000000000001",
"edr_freight_app:settings:support_content:view",
"View portal help & legal content",
),
perm(
"b4f00001-0001-4000-8000-000000000002",
"edr_freight_app:settings:support_content:manage",
"Edit portal help, FAQ & legal content",
),
]; ];
// N. Previously-ungated staff surfaces (support inbox, procurement, compliance, // N. Previously-ungated staff surfaces (support inbox, procurement, compliance,
@@ -1843,6 +1853,11 @@ export const FREIGHT_PERMS = {
delete: "edr_freight_app:settings:contract_templates:delete", delete: "edr_freight_app:settings:contract_templates:delete",
read: "edr_freight_app:settings:contract_templates:read", read: "edr_freight_app:settings:contract_templates:read",
}, },
// Portal-facing help/FAQ/legal copy, edited from Portal content.
supportContent: {
view: "edr_freight_app:settings:support_content:view",
manage: "edr_freight_app:settings:support_content:manage",
},
}, },
audit: { audit: {
view: "edr_freight_app:audit:view", view: "edr_freight_app:audit:view",

View File

@@ -0,0 +1,62 @@
import { SUPPORT_CONTENT_DEFAULTS, SUPPORT_DOC_SLUGS } from "@edr/types";
import { Injectable, Logger } from "@nestjs/common";
import { DataSource } from "typeorm";
import {
SupportDocument,
SupportDocumentVersion,
} from "../modules/support-content/entities/support-document.entity";
/**
* Puts the portal's shipped help/FAQ/legal copy into the database on first
* boot. Idempotent by emptiness, like the other reference-data seeders: once a
* row exists the content is admin-managed, so a redeploy must never clobber it.
*
* Each document is written together with its `version = 1` history row, which
* is what makes `max(version)` in the log always equal the live row — the
* invariant the version list and rollback both assume.
*/
@Injectable()
export class SupportContentSeeder {
private readonly logger = new Logger(SupportContentSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run() {
const documents = this.dataSource.getRepository(SupportDocument);
const versions = this.dataSource.getRepository(SupportDocumentVersion);
const existing = await documents.count({ withDeleted: true });
if (existing > 0) {
this.logger.log(
`support_documents already has ${existing} rows — skipping seed`,
);
return;
}
for (const slug of SUPPORT_DOC_SLUGS) {
const document = await documents.save(
documents.create({
slug,
payload: SUPPORT_CONTENT_DEFAULTS[slug],
version: 1,
updatedById: null,
}),
);
await versions.save(
versions.create({
documentId: document.id,
version: 1,
payload: document.payload,
actorId: null,
note: "Initial content",
}),
);
}
this.logger.log(
`Seeded ${SUPPORT_DOC_SLUGS.length} portal content documents`,
);
}
}

View File

@@ -20,6 +20,7 @@
"@mantine/core": "^9.3.0", "@mantine/core": "^9.3.0",
"@mantine/dates": "^9.3.0", "@mantine/dates": "^9.3.0",
"@mantine/hooks": "^9.3.0", "@mantine/hooks": "^9.3.0",
"@mdxeditor/editor": "^4.2.0",
"@posthog/react": "^1.10.3", "@posthog/react": "^1.10.3",
"@radix-ui/react-accordion": "^1.2.13", "@radix-ui/react-accordion": "^1.2.13",
"@radix-ui/react-alert-dialog": "^1.1.16", "@radix-ui/react-alert-dialog": "^1.1.16",
@@ -93,6 +94,7 @@
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
"react-image-crop": "^11.0.10", "react-image-crop": "^11.0.10",
"react-intersection-observer": "^9.16.0", "react-intersection-observer": "^9.16.0",
"react-markdown": "^9.1.0",
"react-pdf": "^10.4.1", "react-pdf": "^10.4.1",
"react-pdf-html": "^2.1.5", "react-pdf-html": "^2.1.5",
"react-quill-new": "^3.8.3", "react-quill-new": "^3.8.3",

View File

@@ -56,6 +56,7 @@ import NoAccessPage from "./pages/NoAccessPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
import PortalContentPage from "./pages/portal_content/PortalContentPage";
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
import FleetResourcePage from "./pages/fleet/FleetResourcePage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import WagonTransfersPage from "./pages/wagons/WagonTransfersPage"; import WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
@@ -764,7 +765,9 @@ const App = () => {
<Route <Route
path="compliance" path="compliance"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission
permission={[FREIGHT_PERMS.compliance.view, FREIGHT_PERMS.fleet.view]}
>
<CompliancePage /> <CompliancePage />
</RequirePermission> </RequirePermission>
} }
@@ -788,7 +791,9 @@ const App = () => {
<Route <Route
path="procurement" path="procurement"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission
permission={[FREIGHT_PERMS.procurement.view, FREIGHT_PERMS.fleet.view]}
>
<ProcurementPage /> <ProcurementPage />
</RequirePermission> </RequirePermission>
} }
@@ -811,7 +816,9 @@ const App = () => {
<Route <Route
path="file-settings" path="file-settings"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.admin}> <RequirePermission
permission={[FREIGHT_PERMS.settings.fileUpload.view, FREIGHT_PERMS.admin]}
>
<FileUploadSettingsPage /> <FileUploadSettingsPage />
</RequirePermission> </RequirePermission>
} }
@@ -819,7 +826,9 @@ const App = () => {
<Route <Route
path="dropdown-settings" path="dropdown-settings"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.admin}> <RequirePermission
permission={[FREIGHT_PERMS.settings.dropdown.view, FREIGHT_PERMS.admin]}
>
<DropdownSettingsPage /> <DropdownSettingsPage />
</RequirePermission> </RequirePermission>
} }
@@ -858,6 +867,20 @@ const App = () => {
</RequirePermission> </RequirePermission>
} }
/> />
<Route
path="portal-content"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.settings.supportContent.view,
FREIGHT_PERMS.settings.supportContent.manage,
FREIGHT_PERMS.admin,
]}
>
<PortalContentPage />
</RequirePermission>
}
/>
<Route <Route
path="configuration" path="configuration"
@@ -876,7 +899,9 @@ const App = () => {
<Route <Route
path="configuration/trade-access" path="configuration/trade-access"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.admin}> <RequirePermission
permission={[FREIGHT_PERMS.tradeAccess.view, FREIGHT_PERMS.admin]}
>
<TradeAccessPage /> <TradeAccessPage />
</RequirePermission> </RequirePermission>
} }
@@ -884,7 +909,9 @@ const App = () => {
<Route <Route
path="configuration/exchange-rate" path="configuration/exchange-rate"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.admin}> <RequirePermission
permission={[FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin]}
>
<div className="p-4"> <div className="p-4">
<ExchangeRateSettingsCard /> <ExchangeRateSettingsCard />
</div> </div>

View File

@@ -1,5 +1,6 @@
import { import {
ArrowLeftRight, ArrowLeftRight,
BookOpen,
Boxes, Boxes,
Building2, Building2,
BarChart3, BarChart3,
@@ -279,19 +280,21 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
label: "Compliance & Alerts", label: "Compliance & Alerts",
href: "/dashboard/compliance", href: "/dashboard/compliance",
icon: <ShieldCheck />, icon: <ShieldCheck />,
permission: FREIGHT_PERMS.fleet.view, permission: [FREIGHT_PERMS.compliance.view, FREIGHT_PERMS.fleet.view],
}, },
{ {
label: "Incidents", label: "Incidents",
href: "/dashboard/incidents", href: "/dashboard/incidents",
icon: <FileText />, icon: <FileText />,
// No dedicated backend key exists for incidents yet — stuck on the
// blanket fleet:view fallback until one is added.
permission: FREIGHT_PERMS.fleet.view, permission: FREIGHT_PERMS.fleet.view,
}, },
{ {
label: "Procurement", label: "Procurement",
href: "/dashboard/procurement", href: "/dashboard/procurement",
icon: <Package />, icon: <Package />,
permission: FREIGHT_PERMS.fleet.view, permission: [FREIGHT_PERMS.procurement.view, FREIGHT_PERMS.fleet.view],
}, },
{ {
label: "Financial Reports", label: "Financial Reports",
@@ -476,13 +479,13 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
label: "File settings", label: "File settings",
href: "/dashboard/file-settings", href: "/dashboard/file-settings",
icon: <Paperclip />, icon: <Paperclip />,
permission: FREIGHT_PERMS.admin, permission: [FREIGHT_PERMS.settings.fileUpload.view, FREIGHT_PERMS.admin],
}, },
{ {
label: "Dropdown settings", label: "Dropdown settings",
href: "/dashboard/dropdown-settings", href: "/dashboard/dropdown-settings",
icon: <Settings />, icon: <Settings />,
permission: FREIGHT_PERMS.admin, permission: [FREIGHT_PERMS.settings.dropdown.view, FREIGHT_PERMS.admin],
}, },
{ {
label: "Contract templates", label: "Contract templates",
@@ -494,6 +497,16 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
FREIGHT_PERMS.admin, FREIGHT_PERMS.admin,
], ],
}, },
{
label: "Portal content",
href: "/dashboard/portal-content",
icon: <BookOpen />,
permission: [
FREIGHT_PERMS.settings.supportContent.view,
FREIGHT_PERMS.settings.supportContent.manage,
FREIGHT_PERMS.admin,
],
},
{ {
label: "Audit logs", label: "Audit logs",
href: "/dashboard/audit-logs", href: "/dashboard/audit-logs",
@@ -514,12 +527,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
{ {
label: "Trade access", label: "Trade access",
href: "/dashboard/configuration/trade-access", href: "/dashboard/configuration/trade-access",
permission: FREIGHT_PERMS.admin, permission: [FREIGHT_PERMS.tradeAccess.view, FREIGHT_PERMS.admin],
}, },
{ {
label: "Exchange rate", label: "Exchange rate",
href: "/dashboard/configuration/exchange-rate", href: "/dashboard/configuration/exchange-rate",
permission: FREIGHT_PERMS.admin, permission: [FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin],
}, },
], ],
}, },

View File

@@ -0,0 +1,78 @@
import type { SupportDocPayload, SupportDocSlug } from "@edr/types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { portalContentService } from "@/services/portal-content.service";
/**
* Every key shares the `portal-content` prefix so one invalidate after a save
* or a restore sweeps the document and its version list together.
*/
const KEYS = {
ROOT: ["portal-content"] as const,
bySlug: (slug: string) => ["portal-content", "detail", slug] as const,
versions: (slug: string) => ["portal-content", "versions", slug] as const,
};
export function usePortalDoc(slug: SupportDocSlug) {
return useQuery({
queryKey: KEYS.bySlug(slug),
queryFn: () => portalContentService.getBySlug(slug),
});
}
/** Version history. Stays idle until the history modal is opened. */
export function usePortalDocVersions(slug: SupportDocSlug, enabled: boolean) {
return useQuery({
queryKey: KEYS.versions(slug),
queryFn: () => portalContentService.listVersions(slug),
enabled,
});
}
/** One historical payload, fetched only when a version is previewed. */
export function usePortalDocVersion(
slug: SupportDocSlug,
version: number | null,
) {
return useQuery({
queryKey: [...KEYS.versions(slug), version],
queryFn: () => portalContentService.getVersion(slug, version as number),
enabled: version !== null,
});
}
function usePortalContentMutation<TVariables>(
mutationFn: (vars: TVariables) => Promise<unknown>,
successMessage: string,
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn,
onSuccess: () => {
toast.success(successMessage);
void queryClient.invalidateQueries({ queryKey: KEYS.ROOT });
},
onError: (error: unknown) => {
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Something went wrong";
toast.error(Array.isArray(message) ? message.join(", ") : message);
},
});
}
export function useUpdatePortalDoc(slug: SupportDocSlug) {
return usePortalContentMutation(
(vars: { payload: SupportDocPayload; note?: string }) =>
portalContentService.update(slug, vars.payload, vars.note),
"Portal content saved",
);
}
export function useRestorePortalVersion(slug: SupportDocSlug) {
return usePortalContentMutation(
(version: number) => portalContentService.restore(slug, version),
"Version restored",
);
}

View File

@@ -326,6 +326,11 @@ export const FREIGHT_PERMS = {
delete: "edr_freight_app:settings:contract_templates:delete", delete: "edr_freight_app:settings:contract_templates:delete",
read: "edr_freight_app:settings:contract_templates:read", read: "edr_freight_app:settings:contract_templates:read",
}, },
// Portal-facing help/FAQ/legal copy, edited from Portal content.
supportContent: {
view: "edr_freight_app:settings:support_content:view",
manage: "edr_freight_app:settings:support_content:manage",
},
}, },
audit: { audit: {
view: "edr_freight_app:audit:view", view: "edr_freight_app:audit:view",

View File

@@ -0,0 +1,95 @@
import { Accordion, ActionIcon, Center, Group, Text, Tooltip } from "@mantine/core";
import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
import type { ReactNode } from "react";
interface AccordionRowProps {
value: string;
/** Collapsed summary — the heading, question or card title. */
title: string;
/** Small dimmed line under the title, e.g. a body excerpt. */
subtitle?: string;
index: number;
length: number;
onMove: (delta: number) => void;
onRemove: () => void;
children: ReactNode;
}
/**
* One collapsible item with reorder and delete controls in its header.
*
* Collapsing is the point: a legal document has fifteen sections and the FAQ
* seventeen answers, and rendering every textarea expanded turned each tab into
* an unnavigable mile of boxes. Collapsed, the tab reads as the list of
* headings the customer actually sees.
*
* The buttons sit outside `Accordion.Control` so clicking one does not also
* toggle the panel.
*/
export function AccordionRow({
value,
title,
subtitle,
index,
length,
onMove,
onRemove,
children,
}: AccordionRowProps) {
return (
<Accordion.Item value={value}>
<Center>
<Accordion.Control>
<div style={{ minWidth: 0 }}>
<Text fw={500} truncate>
{title || <Text span c="dimmed">(untitled)</Text>}
</Text>
{subtitle && (
<Text size="xs" c="dimmed" truncate>
{subtitle}
</Text>
)}
</div>
</Accordion.Control>
<Group gap={2} wrap="nowrap" pr="sm">
<Tooltip label="Move up">
<ActionIcon
variant="subtle"
color="gray"
disabled={index === 0}
onClick={() => onMove(-1)}
>
<ChevronUp size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Move down">
<ActionIcon
variant="subtle"
color="gray"
disabled={index === length - 1}
onClick={() => onMove(1)}
>
<ChevronDown size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Remove">
<ActionIcon variant="subtle" color="red" onClick={onRemove}>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Center>
<Accordion.Panel>{children}</Accordion.Panel>
</Accordion.Item>
);
}
/** First line of a markdown body, for an accordion subtitle. */
export function excerpt(markdown: string, max = 90): string {
const line = markdown.replace(/[#*`>-]/g, "").trim().split("\n")[0] ?? "";
return line.length > max ? `${line.slice(0, max)}` : line;
}
export default AccordionRow;

View File

@@ -0,0 +1,242 @@
import type { PortalFaqContent, PortalFaqGroup } from "@edr/types";
import {
Accordion,
Badge,
Button,
Card,
Group,
Stack,
Switch,
TextInput,
} from "@mantine/core";
import { Plus } from "lucide-react";
import { AccordionRow, excerpt } from "./AccordionRow";
import { moveAt, newId, removeAt, replaceAt } from "./array-helpers";
import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor";
interface FaqEditorProps {
value: PortalFaqContent;
onChange: (next: PortalFaqContent) => void;
}
const EMPTY_FOOTER = {
heading: "Still need a hand?",
body: "",
ctaLabel: "Go to Help & Support",
ctaTo: "/help",
};
export function FaqEditor({ value, onChange }: FaqEditorProps) {
const setGroups = (groups: PortalFaqGroup[]) => onChange({ ...value, groups });
const setGroup = (index: number, next: PortalFaqGroup) =>
setGroups(replaceAt(value.groups, index, next));
return (
<Stack gap="lg">
<Card withBorder padding="md" radius="md">
<Stack gap="md">
<TextInput
label="Page title"
value={value.title}
onChange={(e) =>
onChange({ ...value, title: e.currentTarget.value })
}
/>
<TextInput
label="Subtitle"
value={value.subtitle}
onChange={(e) =>
onChange({ ...value, subtitle: e.currentTarget.value })
}
/>
</Stack>
</Card>
<MarkdownHint />
<Accordion variant="separated" radius="md" chevronPosition="left">
{value.groups.map((group, groupIndex) => (
<AccordionRow
key={group.id}
value={group.id}
title={group.title}
subtitle={`${group.items.length} question${group.items.length === 1 ? "" : "s"}`}
index={groupIndex}
length={value.groups.length}
onMove={(delta) => setGroups(moveAt(value.groups, groupIndex, delta))}
onRemove={() => setGroups(removeAt(value.groups, groupIndex))}
>
<Stack gap="md">
<TextInput
label="Group title"
value={group.title}
onChange={(e) =>
setGroup(groupIndex, {
...group,
title: e.currentTarget.value,
})
}
/>
<Accordion variant="contained" radius="sm" chevronPosition="left">
{group.items.map((item, itemIndex) => (
<AccordionRow
key={item.id}
value={item.id}
title={item.question}
subtitle={excerpt(item.answer, 70)}
index={itemIndex}
length={group.items.length}
onMove={(delta) =>
setGroup(groupIndex, {
...group,
items: moveAt(group.items, itemIndex, delta),
})
}
onRemove={() =>
setGroup(groupIndex, {
...group,
items: removeAt(group.items, itemIndex),
})
}
>
<Stack gap="md">
<TextInput
label="Question"
value={item.question}
onChange={(e) =>
setGroup(groupIndex, {
...group,
items: replaceAt(group.items, itemIndex, {
...item,
question: e.currentTarget.value,
}),
})
}
/>
<MarkdownEditor
label="Answer"
value={item.answer}
onChange={(answer) =>
setGroup(groupIndex, {
...group,
items: replaceAt(group.items, itemIndex, {
...item,
answer,
}),
})
}
/>
</Stack>
</AccordionRow>
))}
</Accordion>
<Button
variant="subtle"
size="xs"
leftSection={<Plus size={14} />}
style={{ alignSelf: "flex-start" }}
onClick={() =>
setGroup(groupIndex, {
...group,
items: [
...group.items,
{ id: newId(), question: "New question", answer: "" },
],
})
}
>
Add question
</Button>
</Stack>
</AccordionRow>
))}
</Accordion>
<Button
variant="light"
leftSection={<Plus size={16} />}
style={{ alignSelf: "flex-start" }}
onClick={() =>
setGroups([
...value.groups,
{ id: newId(), title: "New group", items: [] },
])
}
>
Add group
</Button>
<Card withBorder padding="md" radius="md">
<Stack gap="md">
<Group justify="space-between">
<Switch
label="Closing card"
checked={Boolean(value.footer)}
onChange={(e) =>
onChange({
...value,
footer: e.currentTarget.checked ? EMPTY_FOOTER : null,
})
}
/>
{!value.footer && <Badge variant="light" color="gray">Hidden</Badge>}
</Group>
{value.footer && (
<>
<TextInput
label="Heading"
value={value.footer.heading}
onChange={(e) =>
onChange({
...value,
footer: { ...value.footer!, heading: e.currentTarget.value },
})
}
/>
<MarkdownEditor
label="Body"
value={value.footer.body}
onChange={(body) =>
onChange({ ...value, footer: { ...value.footer!, body } })
}
/>
<Group grow>
<TextInput
label="Button label"
value={value.footer.ctaLabel}
onChange={(e) =>
onChange({
...value,
footer: {
...value.footer!,
ctaLabel: e.currentTarget.value,
},
})
}
/>
<TextInput
label="Button link"
description="A portal route (/help) or an https:// URL"
value={value.footer.ctaTo}
onChange={(e) =>
onChange({
...value,
footer: { ...value.footer!, ctaTo: e.currentTarget.value },
})
}
/>
</Group>
</>
)}
</Stack>
</Card>
</Stack>
);
}
export default FaqEditor;

View File

@@ -0,0 +1,125 @@
import type { PortalHelpContent, PortalHelpSection } from "@edr/types";
import { Accordion, Button, Card, Divider, Stack, TextInput } from "@mantine/core";
import { Plus } from "lucide-react";
import { AccordionRow, excerpt } from "./AccordionRow";
import { moveAt, newId, removeAt, replaceAt } from "./array-helpers";
import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor";
import { MediaManager } from "./MediaManager";
interface HelpEditorProps {
value: PortalHelpContent;
onChange: (next: PortalHelpContent) => void;
}
/**
* The help page is built, not filled in: an ordered list of sections, each a
* heading plus free markdown plus any images or videos. Nothing about the page
* is fixed except its title, so support can add, reorder or drop a section
* without a code change.
*/
export function HelpEditor({ value, onChange }: HelpEditorProps) {
// A row written before the free-form conversion has no `sections` at all.
// Tolerate it rather than crashing the tab: the migration rewrites it, but
// an environment can be mid-deploy.
const sections = value.sections ?? [];
const setSections = (next: PortalHelpSection[]) =>
onChange({ ...value, sections: next });
return (
<Stack gap="lg">
<Card withBorder padding="md" radius="md">
<Stack gap="md">
<TextInput
label="Page title"
value={value.title}
onChange={(e) =>
onChange({ ...value, title: e.currentTarget.value })
}
/>
<TextInput
label="Subtitle"
value={value.subtitle}
onChange={(e) =>
onChange({ ...value, subtitle: e.currentTarget.value })
}
/>
</Stack>
</Card>
<MarkdownHint />
<Accordion variant="separated" radius="md" chevronPosition="left">
{sections.map((section, index) => (
<AccordionRow
key={section.id}
value={section.id}
title={section.heading}
subtitle={
section.media.length
? `${excerpt(section.body, 60)} · ${section.media.length} attachment${section.media.length === 1 ? "" : "s"}`
: excerpt(section.body)
}
index={index}
length={sections.length}
onMove={(delta) => setSections(moveAt(sections, index, delta))}
onRemove={() => setSections(removeAt(sections, index))}
>
<Stack gap="md">
<TextInput
label="Heading"
value={section.heading}
onChange={(e) =>
setSections(
replaceAt(sections, index, {
...section,
heading: e.currentTarget.value,
}),
)
}
/>
<MarkdownEditor
label="Body"
value={section.body}
onChange={(body) =>
setSections(
replaceAt(sections, index, { ...section, body }),
)
}
/>
<Divider />
<MediaManager
value={section.media}
onChange={(media) =>
setSections(
replaceAt(sections, index, { ...section, media }),
)
}
/>
</Stack>
</AccordionRow>
))}
</Accordion>
<Button
variant="light"
leftSection={<Plus size={16} />}
style={{ alignSelf: "flex-start" }}
onClick={() =>
setSections([
...sections,
{ id: newId(), heading: "New section", body: "", media: [] },
])
}
>
Add section
</Button>
</Stack>
);
}
export default HelpEditor;

View File

@@ -0,0 +1,110 @@
import type { PortalLegalContent } from "@edr/types";
import { Accordion, Button, Card, Group, Stack, TextInput } from "@mantine/core";
import { Plus } from "lucide-react";
import { AccordionRow, excerpt } from "./AccordionRow";
import { moveAt, newId, removeAt, replaceAt } from "./array-helpers";
import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor";
interface LegalDocEditorProps {
value: PortalLegalContent;
onChange: (next: PortalLegalContent) => void;
}
/** Shared by the Privacy and Terms tabs — the two documents have one shape. */
export function LegalDocEditor({ value, onChange }: LegalDocEditorProps) {
const setSections = (sections: PortalLegalContent["sections"]) =>
onChange({ ...value, sections });
return (
<Stack gap="lg">
<Card withBorder padding="md" radius="md">
<Stack gap="md">
<Group grow align="flex-start">
<TextInput
label="Page title"
value={value.title}
onChange={(e) =>
onChange({ ...value, title: e.currentTarget.value })
}
/>
<TextInput
label="Last updated"
description="Free text, e.g. 6 August 2026"
value={value.lastUpdated}
onChange={(e) =>
onChange({ ...value, lastUpdated: e.currentTarget.value })
}
/>
</Group>
<TextInput
label="Subtitle"
value={value.subtitle}
onChange={(e) =>
onChange({ ...value, subtitle: e.currentTarget.value })
}
/>
</Stack>
</Card>
<MarkdownHint />
<Accordion variant="separated" radius="md" chevronPosition="left">
{value.sections.map((section, index) => (
<AccordionRow
key={section.id}
value={section.id}
title={section.heading}
subtitle={excerpt(section.body)}
index={index}
length={value.sections.length}
onMove={(delta) => setSections(moveAt(value.sections, index, delta))}
onRemove={() => setSections(removeAt(value.sections, index))}
>
<Stack gap="md">
<TextInput
label="Heading"
value={section.heading}
onChange={(e) =>
setSections(
replaceAt(value.sections, index, {
...section,
heading: e.currentTarget.value,
}),
)
}
/>
<MarkdownEditor
label="Body"
value={section.body}
onChange={(body) =>
setSections(
replaceAt(value.sections, index, { ...section, body }),
)
}
/>
</Stack>
</AccordionRow>
))}
</Accordion>
<Button
variant="light"
leftSection={<Plus size={16} />}
style={{ alignSelf: "flex-start" }}
onClick={() =>
setSections([
...value.sections,
{ id: newId(), heading: "New section", body: "" },
])
}
>
Add section
</Button>
</Stack>
);
}
export default LegalDocEditor;

View File

@@ -0,0 +1,24 @@
import ReactMarkdown from "react-markdown";
// Same preflight fix the editor needs — Mantine's `Typography` defines its list
// and margin rules with `:where()`, which Tailwind's preflight outranks, so
// bullets rendered without markers here too.
import "./markdown-editor.css";
/**
* Read-only markdown rendering for the version-history preview. Editing goes
* through `MarkdownEditor` (MDXEditor); this is only for showing what an old
* version said.
*
* Same options as the portal's renderer — no `rehype-raw`, no custom
* `urlTransform` — so neither app grows an HTML-injection surface.
*/
export function Markdown({ children }: { children: string }) {
return (
<div className="edr-md-content">
<ReactMarkdown>{children}</ReactMarkdown>
</div>
);
}
export default Markdown;

View File

@@ -0,0 +1,142 @@
import { PORTAL_MEDIA_URI_SCHEME } from "@edr/types";
import { Box, Stack, Text } from "@mantine/core";
import {
BlockTypeSelect,
BoldItalicUnderlineToggles,
CreateLink,
InsertImage,
InsertThematicBreak,
ListsToggle,
MDXEditor,
UndoRedo,
headingsPlugin,
imagePlugin,
linkDialogPlugin,
linkPlugin,
listsPlugin,
markdownShortcutPlugin,
quotePlugin,
thematicBreakPlugin,
toolbarPlugin,
} from "@mdxeditor/editor";
import "@mdxeditor/editor/style.css";
import { portalContentService } from "@/services/portal-content.service";
// Undoes Tailwind's preflight inside the editor's content area — see the file.
import "./markdown-editor.css";
interface MarkdownEditorProps {
label: string;
value: string;
onChange: (next: string) => void;
description?: string;
}
/**
* Signed URLs are per-request and short-lived, so previews are memoised for the
* life of the page rather than re-signed on every keystroke re-render.
*/
const previewCache = new Map<string, Promise<string>>();
/**
* Inserted images are stored as `minio:<key>`, never as the signed URL the
* upload returns: a presigned URL expires, so persisting one would leave every
* embedded image broken a few hours later. `imagePreviewHandler` resolves the
* ref back to a temporary URL purely for display, on both sides of the wire.
*/
function resolvePreview(url: string): Promise<string> {
if (!url.startsWith(PORTAL_MEDIA_URI_SCHEME)) return Promise.resolve(url);
const key = url.slice(PORTAL_MEDIA_URI_SCHEME.length);
let pending = previewCache.get(key);
if (!pending) {
pending = portalContentService
.mediaUrl(key)
.catch(() => url); // show a broken image rather than blowing up the editor
previewCache.set(key, pending);
}
return pending;
}
export function MarkdownEditor({
label,
value,
onChange,
description,
}: MarkdownEditorProps) {
return (
<Stack gap={6}>
<Text size="sm" fw={500}>
{label}
</Text>
{description && (
<Text size="xs" c="dimmed">
{description}
</Text>
)}
<Box
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-sm)",
}}
>
<MDXEditor
markdown={value}
contentEditableClassName="edr-md-content"
// MDXEditor re-serialises the markdown once on mount, which differs
// harmlessly from what was stored (spacing, escaping). Reporting that
// as an edit made every tab open "unsaved" and let a Save write a
// no-op version, so the normalisation pass is ignored.
onChange={(markdown, initialMarkdownNormalize) => {
if (!initialMarkdownNormalize) onChange(markdown);
}}
plugins={[
headingsPlugin(),
listsPlugin(),
quotePlugin(),
linkPlugin(),
linkDialogPlugin(),
thematicBreakPlugin(),
imagePlugin({
imageUploadHandler: async (file) => {
const { key } = await portalContentService.uploadMedia(file);
return `${PORTAL_MEDIA_URI_SCHEME}${key}`;
},
imagePreviewHandler: resolvePreview,
}),
markdownShortcutPlugin(),
toolbarPlugin({
toolbarContents: () => (
<>
<UndoRedo />
<BoldItalicUnderlineToggles />
<BlockTypeSelect />
<ListsToggle />
<CreateLink />
<InsertImage />
<InsertThematicBreak />
</>
),
}),
]}
/>
</Box>
</Stack>
);
}
/** Reminder of the substitution tokens, rendered once per tab. */
export function MarkdownHint() {
return (
<Text size="xs" c="dimmed">
Placeholders resolve from the Contact tab, so one edit there updates every
page: <code>{"{{supportEmail}}"}</code> · <code>{"{{supportPhone}}"}</code>{" "}
· <code>{"{{supportOffice}}"}</code> · <code>{"{{supportHours}}"}</code> ·{" "}
<code>{"{{supportPhoneTel}}"}</code> (inside a tel: link).
</Text>
);
}
export default MarkdownEditor;

View File

@@ -0,0 +1,122 @@
import type { PortalMedia } from "@edr/types";
import {
ActionIcon,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { Film, Image as ImageIcon, Trash2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import toast from "react-hot-toast";
import { portalContentService } from "@/services/portal-content.service";
import { newId, removeAt, replaceAt } from "./array-helpers";
interface MediaManagerProps {
value: PortalMedia[];
onChange: (next: PortalMedia[]) => void;
}
/**
* Attachments for one help section. Uploads store the MinIO object *key*; the
* signed URL the upload returns is short-lived and is never persisted, so the
* list shows the key rather than pretending to be a gallery.
*/
export function MediaManager({ value, onChange }: MediaManagerProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const upload = async (file: File) => {
setUploading(true);
try {
const { key, kind } = await portalContentService.uploadMedia(file);
onChange([...value, { id: newId(), kind, src: key, caption: null }]);
} catch (error) {
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Upload failed";
toast.error(Array.isArray(message) ? message.join(", ") : message);
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
};
return (
<Stack gap="xs">
<Text size="sm" fw={500}>
Attachments
</Text>
{value.map((item, index) => (
<Paper key={item.id} withBorder p="xs" radius="sm">
<Group wrap="nowrap" align="center" gap="sm">
{item.kind === "video" ? (
<Film size={18} />
) : (
<ImageIcon size={18} />
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" c="dimmed" truncate>
{item.src}
</Text>
<TextInput
size="xs"
placeholder="Caption (optional)"
value={item.caption ?? ""}
onChange={(e) =>
onChange(
replaceAt(value, index, {
...item,
caption: e.currentTarget.value || null,
}),
)
}
/>
</Stack>
<Tooltip label="Remove attachment">
<ActionIcon
variant="subtle"
color="red"
onClick={() => onChange(removeAt(value, index))}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Paper>
))}
<input
ref={inputRef}
type="file"
accept="image/*,video/*"
hidden
onChange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) void upload(file);
}}
/>
<Button
variant="light"
size="xs"
loading={uploading}
leftSection={<Upload size={14} />}
style={{ alignSelf: "flex-start" }}
onClick={() => inputRef.current?.click()}
>
Upload image or video
</Button>
</Stack>
);
}
export default MediaManager;

View File

@@ -0,0 +1,261 @@
import type {
PortalFaqContent,
PortalHelpContent,
PortalLegalContent,
PortalSupportContact,
SupportDocPayload,
SupportDocSlug,
} from "@edr/types";
import {
Badge,
Button,
Card,
Group,
Loader,
Stack,
Tabs,
Text,
TextInput,
} from "@mantine/core";
import { History, RotateCcw, Save } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { PageContainer, PageHeader } from "@/components/page";
import {
usePortalDoc,
useUpdatePortalDoc,
} from "@/hooks/portal-content/usePortalContentAdmin";
import { FaqEditor } from "./FaqEditor";
import { HelpEditor } from "./HelpEditor";
import { LegalDocEditor } from "./LegalDocEditor";
import { VersionHistoryModal } from "./VersionHistoryModal";
const TABS: { slug: SupportDocSlug; label: string }[] = [
{ slug: "CONTACT", label: "Contact" },
{ slug: "HELP", label: "Help" },
{ slug: "FAQ", label: "FAQ" },
{ slug: "PRIVACY", label: "Privacy" },
{ slug: "TERMS", label: "Terms" },
];
/**
* Edits the copy on the freight portal's public pages — /help, /faq, /terms,
* /privacy — and the support contact block all four quote.
*
* Each tab is a local draft saved in one PATCH of the whole document, rather
* than a mutation per field. That is what makes one editorial change equal one
* version, which is the difference between a history you can read and a history
* of keystrokes.
*/
export default function PortalContentPage() {
const [active, setActive] = useState<SupportDocSlug>("CONTACT");
return (
<PageContainer>
<PageHeader
title="Portal content"
subtitle="Help, FAQ and legal copy shown to customers on the public portal pages. Body text is markdown, every save is versioned, and any version can be restored."
/>
<Tabs
value={active}
onChange={(value) => setActive(value as SupportDocSlug)}
keepMounted={false}
>
<Tabs.List mb="lg">
{TABS.map((tab) => (
<Tabs.Tab key={tab.slug} value={tab.slug}>
{tab.label}
</Tabs.Tab>
))}
</Tabs.List>
{TABS.map((tab) => (
<Tabs.Panel key={tab.slug} value={tab.slug}>
<DocumentTab slug={tab.slug} />
</Tabs.Panel>
))}
</Tabs>
</PageContainer>
);
}
function DocumentTab({ slug }: { slug: SupportDocSlug }) {
const { data, isLoading } = usePortalDoc(slug);
const update = useUpdatePortalDoc(slug);
const [draft, setDraft] = useState<SupportDocPayload | null>(null);
const [note, setNote] = useState("");
const [historyOpen, setHistoryOpen] = useState(false);
// Reseed only when the server's version number moves (load, save, restore).
// Keying off `data` itself would let a background refetch wipe edits that are
// still in progress.
const seededVersion = useRef<number | null>(null);
useEffect(() => {
if (data && seededVersion.current !== data.version) {
seededVersion.current = data.version;
setDraft(data.payload);
setNote("");
}
}, [data]);
if (isLoading || !data || !draft) return <Loader size="sm" />;
const dirty = JSON.stringify(draft) !== JSON.stringify(data.payload);
const reset = () => {
setDraft(data.payload);
setNote("");
};
return (
<Stack gap="lg">
{/* Sticky: these tabs are long lists, and a Save button that scrolls out
of reach is the fastest way to lose an edit. */}
<Card
withBorder
padding="sm"
radius="md"
style={{
position: "sticky",
top: 0,
zIndex: 2,
backgroundColor: "var(--mantine-color-body)",
}}
>
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
<Group gap="xs">
<Badge variant="light" color={dirty ? "orange" : "gray"}>
v{data.version}
</Badge>
<Text size="sm" c={dirty ? "orange" : "dimmed"}>
{dirty ? "Unsaved changes" : "Saved"}
</Text>
</Group>
<Group gap="xs" align="center">
{dirty && (
<TextInput
size="sm"
placeholder="Change note (optional)"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
w={240}
/>
)}
<Button
variant="default"
leftSection={<History size={16} />}
onClick={() => setHistoryOpen(true)}
>
History
</Button>
<Button
variant="subtle"
leftSection={<RotateCcw size={16} />}
disabled={!dirty}
onClick={reset}
>
Reset
</Button>
<Button
leftSection={<Save size={16} />}
disabled={!dirty}
loading={update.isPending}
onClick={() =>
update.mutate({ payload: draft, note: note || undefined })
}
>
Save
</Button>
</Group>
</Group>
</Card>
<DocumentEditor slug={slug} value={draft} onChange={setDraft} />
<VersionHistoryModal
slug={slug}
opened={historyOpen}
onClose={() => setHistoryOpen(false)}
hasUnsavedChanges={dirty}
onRestored={reset}
/>
</Stack>
);
}
function DocumentEditor({
slug,
value,
onChange,
}: {
slug: SupportDocSlug;
value: SupportDocPayload;
onChange: (next: SupportDocPayload) => void;
}) {
switch (slug) {
case "CONTACT":
return (
<ContactEditor
value={value as PortalSupportContact}
onChange={onChange}
/>
);
case "HELP":
return (
<HelpEditor value={value as PortalHelpContent} onChange={onChange} />
);
case "FAQ":
return <FaqEditor value={value as PortalFaqContent} onChange={onChange} />;
case "PRIVACY":
case "TERMS":
return (
<LegalDocEditor
value={value as PortalLegalContent}
onChange={onChange}
/>
);
}
}
/**
* Four fields, so no separate file. These values feed the help page's contact
* cards and resolve the `{{supportEmail}}`-style placeholders used throughout
* the FAQ and legal copy — editing them here updates every page at once.
*/
function ContactEditor({
value,
onChange,
}: {
value: PortalSupportContact;
onChange: (next: PortalSupportContact) => void;
}) {
return (
<Stack gap="md" maw={640}>
<TextInput
label="Support email"
value={value.email}
onChange={(e) => onChange({ ...value, email: e.currentTarget.value })}
/>
<TextInput
label="Support phone"
description="Displayed as typed; tel: links strip the spacing automatically."
value={value.phone}
onChange={(e) => onChange({ ...value, phone: e.currentTarget.value })}
/>
<TextInput
label="Head office"
value={value.office}
onChange={(e) => onChange({ ...value, office: e.currentTarget.value })}
/>
<TextInput
label="Support hours"
value={value.hours}
onChange={(e) => onChange({ ...value, hours: e.currentTarget.value })}
/>
</Stack>
);
}

View File

@@ -0,0 +1,198 @@
import type { SupportDocSlug } from "@edr/types";
import {
Alert,
Badge,
Button,
Card,
Group,
Loader,
Modal,
Stack,
Text,
} from "@mantine/core";
import { AlertTriangle } from "lucide-react";
import { useState } from "react";
import {
usePortalDocVersion,
usePortalDocVersions,
useRestorePortalVersion,
} from "@/hooks/portal-content/usePortalContentAdmin";
import { Markdown } from "./Markdown";
import { summarizeVersion } from "./version-preview";
interface VersionHistoryModalProps {
slug: SupportDocSlug;
opened: boolean;
onClose: () => void;
/** True when the tab holds unsaved edits a restore would discard. */
hasUnsavedChanges: boolean;
onRestored: () => void;
}
function formatSavedAt(value: string): string {
return new Date(value).toLocaleString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* Version history for one document. Restoring re-saves the old payload as a new
* version server-side, so the list only ever grows and a restore is itself
* undoable — there is nothing here that can destroy history.
*/
export function VersionHistoryModal({
slug,
opened,
onClose,
hasUnsavedChanges,
onRestored,
}: VersionHistoryModalProps) {
const { data: versions, isLoading } = usePortalDocVersions(slug, opened);
const [previewing, setPreviewing] = useState<number | null>(null);
const [confirming, setConfirming] = useState<number | null>(null);
const { data: preview } = usePortalDocVersion(slug, previewing);
const restore = useRestorePortalVersion(slug);
const close = () => {
setPreviewing(null);
setConfirming(null);
onClose();
};
const latest = versions?.[0]?.version;
return (
<Modal
opened={opened}
onClose={close}
size="xl"
title={`Version history — ${slug}`}
>
<Stack gap="md">
{hasUnsavedChanges && (
<Alert
color="orange"
icon={<AlertTriangle size={16} />}
title="Unsaved changes"
>
This tab has edits that have not been saved. Restoring a version
discards them.
</Alert>
)}
{isLoading && <Loader size="sm" />}
{versions?.map((version) => (
<Card key={version.id} withBorder padding="md" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={2}>
<Group gap="xs">
<Text fw={600}>v{version.version}</Text>
{version.version === latest && (
<Badge size="sm" variant="light">
Current
</Badge>
)}
<Text size="sm" c="dimmed">
{formatSavedAt(version.createdAt)}
</Text>
</Group>
{version.note && (
<Text size="sm" c="dimmed">
{version.note}
</Text>
)}
</Stack>
{confirming === version.version ? (
<Group gap="xs" wrap="nowrap">
<Text size="sm">Restore v{version.version}?</Text>
<Button
size="xs"
color="red"
loading={restore.isPending}
onClick={() =>
restore.mutate(version.version, {
onSuccess: () => {
onRestored();
close();
},
})
}
>
Confirm
</Button>
<Button
size="xs"
variant="subtle"
onClick={() => setConfirming(null)}
>
Cancel
</Button>
</Group>
) : (
<Group gap="xs" wrap="nowrap">
<Button
size="xs"
variant="light"
onClick={() =>
setPreviewing(
previewing === version.version ? null : version.version,
)
}
>
{previewing === version.version ? "Hide" : "Preview"}
</Button>
<Button
size="xs"
variant="subtle"
disabled={version.version === latest}
onClick={() => {
setConfirming(version.version);
setPreviewing(null);
}}
>
Restore
</Button>
</Group>
)}
</Group>
{previewing === version.version && (
<Card mt="sm" withBorder padding="sm" radius="sm" bg="gray.0">
{preview ? (
<Stack gap="sm">
{summarizeVersion(slug, preview.payload).map((entry, i) => (
<Stack key={`${entry.label}-${i}`} gap={2}>
<Text size="sm" fw={600}>
{entry.label}
</Text>
<Markdown>{entry.body}</Markdown>
</Stack>
))}
</Stack>
) : (
<Loader size="xs" />
)}
</Card>
)}
</Card>
))}
{versions?.length === 0 && (
<Text size="sm" c="dimmed">
No history yet.
</Text>
)}
</Stack>
</Modal>
);
}
export default VersionHistoryModal;

View File

@@ -0,0 +1,24 @@
/** Immutable list edits shared by the three payload editors. */
export function replaceAt<T>(items: T[], index: number, next: T): T[] {
return items.map((item, i) => (i === index ? next : item));
}
export function removeAt<T>(items: T[], index: number): T[] {
return items.filter((_, i) => i !== index);
}
/**
* Swaps an item with its neighbour. Out-of-range moves return the list
* unchanged, so the ▲/▼ buttons need no disabled-state bookkeeping of their own.
*/
export function moveAt<T>(items: T[], index: number, delta: number): T[] {
const target = index + delta;
if (target < 0 || target >= items.length) return items;
const next = [...items];
[next[index], next[target]] = [next[target], next[index]];
return next;
}
export const newId = () => crypto.randomUUID();

View File

@@ -0,0 +1,109 @@
/*
* Tailwind's preflight zeroes margins on `p`, strips `list-style` from `ul`/`ol`
* and flattens heading sizes. MDXEditor's own stylesheet assumes browser
* defaults, so inside this app its content area renders as one undifferentiated
* block — paragraphs run together and bullets lose their markers.
*
* This restores the handful of element styles the editor needs, scoped to its
* content area so nothing leaks back into the rest of the backoffice. It is a
* deliberate alternative to pulling in @tailwindcss/typography for one widget.
*/
.edr-md-content p {
margin: 0 0 0.75rem;
line-height: 1.6;
}
.edr-md-content p:last-child {
margin-bottom: 0;
}
.edr-md-content ul,
.edr-md-content ol {
margin: 0 0 0.75rem;
padding-left: 1.5rem;
}
.edr-md-content ul {
list-style: disc;
}
.edr-md-content ol {
list-style: decimal;
}
.edr-md-content li {
margin: 0.25rem 0;
line-height: 1.6;
}
/* Nested lists — the editor's indent button produces these. */
.edr-md-content li > ul,
.edr-md-content li > ol {
margin: 0.25rem 0 0;
}
.edr-md-content h1,
.edr-md-content h2,
.edr-md-content h3,
.edr-md-content h4 {
font-weight: 700;
line-height: 1.3;
margin: 1rem 0 0.5rem;
}
.edr-md-content h1 {
font-size: 1.5rem;
}
.edr-md-content h2 {
font-size: 1.25rem;
}
.edr-md-content h3 {
font-size: 1.1rem;
}
.edr-md-content h4 {
font-size: 1rem;
}
.edr-md-content strong {
font-weight: 600;
}
.edr-md-content em {
font-style: italic;
}
.edr-md-content a {
color: var(--mantine-color-blue-6);
text-decoration: underline;
}
.edr-md-content blockquote {
margin: 0 0 0.75rem;
padding-left: 0.75rem;
border-left: 3px solid var(--mantine-color-gray-3);
color: var(--mantine-color-dimmed);
}
.edr-md-content hr {
border: 0;
border-top: 1px solid var(--mantine-color-gray-3);
margin: 1rem 0;
}
.edr-md-content code {
font-family: var(--mantine-font-family-monospace);
font-size: 0.875em;
background: var(--mantine-color-gray-1);
padding: 0.05rem 0.25rem;
border-radius: 3px;
}
.edr-md-content img {
max-width: 100%;
height: auto;
border-radius: 8px;
}

View File

@@ -0,0 +1,79 @@
import type {
PortalFaqContent,
PortalHelpContent,
PortalLegalContent,
PortalSupportContact,
SupportDocPayload,
SupportDocSlug,
} from "@edr/types";
export interface PreviewEntry {
label: string;
/** Markdown, rendered read-only. */
body: string;
}
/**
* Flattens a stored payload into labelled markdown blocks for the history
* modal. An editor deciding whether to roll back needs to read the wording of
* that version — a raw JSON dump technically shows it, but not in a form
* anyone can compare legal prose in.
*/
export function summarizeVersion(
slug: SupportDocSlug,
payload: SupportDocPayload,
): PreviewEntry[] {
switch (slug) {
case "CONTACT": {
const contact = payload as PortalSupportContact;
return [
{ label: "Email", body: contact.email },
{ label: "Phone", body: contact.phone },
{ label: "Head office", body: contact.office },
{ label: "Support hours", body: contact.hours },
];
}
case "HELP": {
const help = payload as PortalHelpContent;
return [
{ label: "Title", body: help.title },
{ label: "Subtitle", body: help.subtitle },
...help.sections.map((section) => ({
label: section.heading,
body: section.media.length
? `${section.body}\n\n_${section.media.length} attachment${section.media.length === 1 ? "" : "s"}: ${section.media.map((m) => m.src).join(", ")}_`
: section.body,
})),
];
}
case "FAQ": {
const faq = payload as PortalFaqContent;
return [
{ label: "Title", body: faq.title },
...faq.groups.flatMap((group) =>
group.items.map((item) => ({
label: `${group.title}${item.question}`,
body: item.answer,
})),
),
...(faq.footer
? [{ label: faq.footer.heading, body: faq.footer.body }]
: []),
];
}
case "PRIVACY":
case "TERMS": {
const legal = payload as PortalLegalContent;
return [
{ label: "Last updated", body: legal.lastUpdated },
...legal.sections.map((section) => ({
label: section.heading,
body: section.body,
})),
];
}
}
}

View File

@@ -0,0 +1,98 @@
import type {
PortalMediaKind,
SupportDocPayload,
SupportDocSlug,
SupportDocumentDetail,
SupportDocVersionDetail,
SupportDocVersionSummary,
} from "@edr/types";
import { api as client } from "../auth/http";
const ROOT = "/support-content";
const BASE = `${ROOT}/documents`;
/**
* Customer-facing help/FAQ/legal copy for the freight portal. The client's
* response interceptor already unwraps the `{ success, data }` envelope, so
* every method is a one-liner.
*/
export const portalContentService = {
async getBySlug(slug: SupportDocSlug): Promise<SupportDocumentDetail> {
const { data } = await client.get<SupportDocumentDetail>(`${BASE}/${slug}`);
return data;
},
/**
* Replaces the document's whole payload. Whole-payload rather than per-field
* on purpose: one Save becomes exactly one version, which is what keeps the
* history list readable.
*/
async update(
slug: SupportDocSlug,
payload: SupportDocPayload,
note?: string,
): Promise<SupportDocumentDetail> {
const { data } = await client.patch<SupportDocumentDetail>(
`${BASE}/${slug}`,
{ payload, note },
);
return data;
},
async listVersions(slug: SupportDocSlug): Promise<SupportDocVersionSummary[]> {
const { data } = await client.get<SupportDocVersionSummary[]>(
`${BASE}/${slug}/versions`,
);
return data;
},
async getVersion(
slug: SupportDocSlug,
version: number,
): Promise<SupportDocVersionDetail> {
const { data } = await client.get<SupportDocVersionDetail>(
`${BASE}/${slug}/versions/${version}`,
);
return data;
},
/**
* Uploads an image or video and returns its object *key*. The key is what
* gets saved in the document; `url` is only for showing the editor a preview
* right now, and expires.
*/
async uploadMedia(
file: File,
): Promise<{ key: string; kind: PortalMediaKind; url: string }> {
const form = new FormData();
form.append("file", file);
const { data } = await client.post<{
key: string;
kind: PortalMediaKind;
url: string;
}>(`${ROOT}/media`, form);
return data;
},
/** Resolves one stored key to a temporary URL, for editor previews. */
async mediaUrl(key: string): Promise<string> {
const { data } = await client.get<{ url: string }>(`${ROOT}/media-url`, {
params: { key },
});
return data.url;
},
/** Re-saves an old payload as a new version — never destructive. */
async restore(
slug: SupportDocSlug,
version: number,
): Promise<SupportDocumentDetail> {
const { data } = await client.post<SupportDocumentDetail>(
`${BASE}/${slug}/versions/${version}/restore`,
{},
);
return data;
},
};

View File

@@ -33,6 +33,7 @@
"react-dom": "19.2.6", "react-dom": "19.2.6",
"react-hook-form": "^7.76.0", "react-hook-form": "^7.76.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-markdown": "^9.1.0",
"react-phone-number-input": "^3.4.17", "react-phone-number-input": "^3.4.17",
"react-router-dom": "^6.27.0", "react-router-dom": "^6.27.0",
"recharts": "^3.8.1", "recharts": "^3.8.1",

View File

@@ -218,6 +218,11 @@ export const URL_CONSTANTS = {
PAY_ONLINE: (id: string) => `/api/warehouse-fee-invoices/${id}/pay-online`, PAY_ONLINE: (id: string) => `/api/warehouse-fee-invoices/${id}/pay-online`,
}, },
// Public — no session required; the sign-up screen links to these pages.
PORTAL_CONTENT: {
PUBLIC: "/api/support-content",
},
LAST_MILE_REQUESTS: { LAST_MILE_REQUESTS: {
BY_ID: (id: string) => `/last-mile-requests/${id}`, BY_ID: (id: string) => `/last-mile-requests/${id}`,
SUBMIT: (id: string) => `/last-mile-requests/${id}/submit`, SUBMIT: (id: string) => `/last-mile-requests/${id}/submit`,

View File

@@ -0,0 +1,39 @@
import type { PortalContentBundle } from "@edr/types";
import { useQuery } from "@tanstack/react-query";
import { URL_CONSTANTS } from "@/constants/URLS";
import {
FALLBACK_PORTAL_CONTENT,
withSupportVars,
} from "@/pages/support/portal-content";
import type { ApiResponse } from "@/types/apiResponse";
import { client } from "@/utils/api";
import { unwrap } from "@/utils/endpoint";
/**
* The whole public help/FAQ/legal bundle in one request, shared by the four
* public pages (react-query dedupes it across the routes).
*
* The endpoint is unauthenticated and the shared axios client attaches a token
* only when the cookie exists, so this works for anonymous visitors as-is.
*
* `placeholderData` means `data` is never undefined: the pages render the
* shipped copy immediately and swap in the live copy when the fetch resolves.
* That is deliberate — it is what lets the four public pages skip loading and
* error states entirely. If the fallback is ever removed, those states are
* owed back.
*/
export function usePortalContent() {
return useQuery({
queryKey: ["portal-content"],
queryFn: async (): Promise<PortalContentBundle> => {
const response = await client.get<ApiResponse<PortalContentBundle>>(
URL_CONSTANTS.PORTAL_CONTENT.PUBLIC,
);
return unwrap(response.data);
},
placeholderData: FALLBACK_PORTAL_CONTENT,
select: withSupportVars,
staleTime: 5 * 60_000,
});
}

View File

@@ -1,8 +1,9 @@
import type { PortalDocSection } from "@edr/types";
import { ArrowLeft, Train } from "lucide-react"; import { ArrowLeft, Train } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { Section } from "./content"; import { Markdown } from "./Markdown";
/** Public pages reachable from every doc page's header and footer. */ /** Public pages reachable from every doc page's header and footer. */
const DOC_LINKS = [ const DOC_LINKS = [
@@ -87,32 +88,21 @@ export function DocShell({
); );
} }
/** Renders a legal document's numbered sections. */ /**
export function DocSections({ sections }: { sections: Section[] }) { * Renders a legal document's numbered sections. Bodies are markdown, so the
* paragraph and bullet arrays this used to walk are one string now — keyed by
* `id` rather than by heading, which admin-authored text can duplicate.
*/
export function DocSections({ sections }: { sections: PortalDocSection[] }) {
return ( return (
<div className="space-y-10"> <div className="space-y-10">
{sections.map((section) => ( {sections.map((section) => (
<section key={section.heading}> <section key={section.id}>
<h2 className="text-xl font-bold tracking-tight"> <h2 className="text-xl font-bold tracking-tight">
{section.heading} {section.heading}
</h2> </h2>
{section.body?.map((paragraph) => ( <Markdown>{section.body}</Markdown>
<p
key={paragraph}
className="mt-4 leading-7 text-muted-foreground"
>
{paragraph}
</p>
))}
{section.bullets && (
<ul className="mt-4 list-disc space-y-2 pl-5 leading-7 text-muted-foreground">
{section.bullets.map((bullet) => (
<li key={bullet}>{bullet}</li>
))}
</ul>
)}
</section> </section>
))} ))}
</div> </div>

View File

@@ -1,19 +1,21 @@
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { usePortalContent } from "@/hooks/usePortalContent";
import { DocShell } from "./DocShell"; import { DocShell } from "./DocShell";
import { FAQ_GROUPS, SUPPORT_CONTACT } from "./content"; import { Markdown } from "./Markdown";
export default function FaqPage() { export default function FaqPage() {
// Never undefined — see TermsPage.
const { data } = usePortalContent();
const faq = data!.faq;
return ( return (
<DocShell <DocShell current="/faq" title={faq.title} subtitle={faq.subtitle}>
current="/faq"
title="Frequently Asked Questions"
subtitle="Answers to the questions customers ask most about registering, booking cargo and settling invoices on EDR Freight."
>
<div className="space-y-10"> <div className="space-y-10">
{FAQ_GROUPS.map((group) => ( {faq.groups.map((group) => (
<section key={group.title}> <section key={group.id}>
<h2 className="text-xl font-bold tracking-tight">{group.title}</h2> <h2 className="text-xl font-bold tracking-tight">{group.title}</h2>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
@@ -21,7 +23,7 @@ export default function FaqPage() {
// Native disclosure: keyboard- and screen-reader-accessible // Native disclosure: keyboard- and screen-reader-accessible
// without any state of our own. // without any state of our own.
<details <details
key={item.question} key={item.id}
className="group rounded-2xl border border-border bg-card px-5 py-4 transition hover:border-primary/40" className="group rounded-2xl border border-border bg-card px-5 py-4 transition hover:border-primary/40"
> >
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 font-semibold"> <summary className="flex cursor-pointer list-none items-center justify-between gap-4 font-semibold">
@@ -29,9 +31,7 @@ export default function FaqPage() {
<ChevronDown className="size-5 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" /> <ChevronDown className="size-5 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
</summary> </summary>
<p className="mt-3 leading-7 text-muted-foreground"> <Markdown>{item.answer}</Markdown>
{item.answer}
</p>
</details> </details>
))} ))}
</div> </div>
@@ -39,21 +39,20 @@ export default function FaqPage() {
))} ))}
</div> </div>
<div className="mt-12 rounded-[32px] border border-border bg-card p-8"> {faq.footer && (
<h2 className="text-xl font-bold tracking-tight"> <div className="mt-12 rounded-[32px] border border-border bg-card p-8">
Still need a hand? <h2 className="text-xl font-bold tracking-tight">
</h2> {faq.footer.heading}
<p className="mt-2 leading-7 text-muted-foreground"> </h2>
Our team is on {SUPPORT_CONTACT.email} and {SUPPORT_CONTACT.phone}, or <Markdown>{faq.footer.body}</Markdown>
you can start a chat from the support button inside the portal. <Link
</p> to={faq.footer.ctaTo}
<Link className="mt-6 inline-flex rounded-2xl bg-primary px-6 py-3 font-semibold text-primary-foreground transition hover:opacity-90"
to="/help" >
className="mt-6 inline-flex rounded-2xl bg-primary px-6 py-3 font-semibold text-primary-foreground transition hover:opacity-90" {faq.footer.ctaLabel}
> </Link>
Go to Help &amp; Support </div>
</Link> )}
</div>
</DocShell> </DocShell>
); );
} }

View File

@@ -1,208 +1,74 @@
import { import type { PortalMedia } from "@edr/types";
Clock3,
FileText, import { usePortalContent } from "@/hooks/usePortalContent";
HelpCircle,
Mail,
MapPin,
MessageSquare,
Package,
Phone,
Receipt,
ShieldCheck,
} from "lucide-react";
import { Link } from "react-router-dom";
import { DocShell } from "./DocShell"; import { DocShell } from "./DocShell";
import { SUPPORT_CONTACT } from "./content"; import { Markdown } from "./Markdown";
import { safeMediaSrc } from "./portal-content";
const channels = [ /**
{ * An attached image or video. The API has already swapped stored MinIO keys for
icon: Mail, * freshly signed URLs, so `src` is ready to render — `safeMediaSrc` is a last
title: "Email", * check that an admin-entered value is a path or an https URL.
value: SUPPORT_CONTACT.email, */
href: `mailto:${SUPPORT_CONTACT.email}`, function Media({ item }: { item: PortalMedia }) {
note: "Best for document issues and anything needing an attachment.", const src = safeMediaSrc(item.src);
}, if (!src) return null;
{
icon: Phone,
title: "Phone",
value: SUPPORT_CONTACT.phone,
href: `tel:${SUPPORT_CONTACT.phone.replace(/\s/g, "")}`,
note: "Best for urgent problems with cargo already in transit.",
},
{
icon: MapPin,
title: "Head office",
value: SUPPORT_CONTACT.office,
note: "Walk-in support during working hours.",
},
{
icon: Clock3,
title: "Support hours",
value: SUPPORT_CONTACT.hours,
note: "Outside these hours, email us and we reply the next working day.",
},
];
const topics = [
{
icon: ShieldCheck,
title: "Account & onboarding",
body: "Registering your company, uploading your trade licence and TIN, and getting an operational profile approved.",
},
{
icon: FileText,
title: "Contracts",
body: "Requesting a freight contract, reviewing its terms and signing it with your saved signature and stamp.",
},
{
icon: Package,
title: "Bookings & tracking",
body: "Raising a booking against a contract, adding last-mile transport and following the consignment along the corridor.",
},
{
icon: Receipt,
title: "Invoices & payments",
body: "Finding invoices, paying through the bank channels and confirming a payment that has not yet settled.",
},
];
export default function HelpPage() {
return ( return (
<DocShell <figure className="mt-6">
current="/help" {item.kind === "video" ? (
title="Help & Support" // preload="metadata" so a large file is not pulled on every visit; the
subtitle="Get answers fast — watch the walkthrough, browse the common topics, check the FAQ, or reach our team directly." // browser fetches it only once playback starts.
>
<section className="mb-12">
<h2 className="text-xl font-bold tracking-tight">
Portal walkthrough
</h2>
<p className="mt-2 leading-7 text-muted-foreground">
A guided tour of the portal registering your company, raising a
booking against a contract, and settling an invoice.
</p>
{/* preload="metadata" so the 28 MB file is not pulled on every visit;
the browser fetches it only once playback starts. */}
<video <video
controls controls
preload="metadata" preload="metadata"
className="mt-6 w-full rounded-[32px] border border-border bg-black" className="w-full rounded-[32px] border border-border bg-black"
> >
<source src="/assets/edr-portal-guide.webm" type="video/webm" /> <source src={src} />
Your browser cannot play this video. Download it at{" "} Your browser cannot play this video.{" "}
<a href="/assets/edr-portal-guide.webm"> <a href={src}>Download it instead</a>.
/assets/edr-portal-guide.webm
</a>
.
</video> </video>
</section> ) : (
<img
src={src}
alt={item.caption ?? ""}
loading="lazy"
className="w-full rounded-[32px] border border-border"
/>
)}
{/* Live chat is the fastest route, so lead with it. */} {item.caption && (
<div className="rounded-[32px] border border-border bg-card p-8"> <figcaption className="mt-2 text-sm text-muted-foreground">
<div className="flex items-start gap-4"> {item.caption}
<div className="rounded-2xl bg-accent p-3 text-primary"> </figcaption>
<MessageSquare className="size-5" /> )}
</div> </figure>
);
}
<div> export default function HelpPage() {
// Never undefined — see TermsPage.
const { data } = usePortalContent();
const help = data!.help;
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"> <h2 className="text-xl font-bold tracking-tight">
Chat with our team {section.heading}
</h2> </h2>
<p className="mt-2 leading-7 text-muted-foreground">
Signed-in customers can open a support conversation from the <Markdown>{section.body}</Markdown>
headset button at the bottom right of every portal page. You can
send screenshots and documents in the chat, and replies appear {section.media.map((item) => (
there and as a notification. <Media key={item.id} item={item} />
</p> ))}
<Link </section>
to="/portal" ))}
className="mt-6 inline-flex rounded-2xl bg-primary px-6 py-3 font-semibold text-primary-foreground transition hover:opacity-90"
>
Open the portal
</Link>
</div>
</div>
</div> </div>
<section className="mt-12">
<h2 className="text-xl font-bold tracking-tight">Contact us</h2>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{channels.map((channel) => (
<div
key={channel.title}
className="flex items-start gap-4 rounded-2xl border border-border bg-background p-5"
>
<div className="rounded-2xl bg-accent p-3 text-primary">
<channel.icon className="size-5" />
</div>
<div>
<p className="font-semibold">{channel.title}</p>
{channel.href ? (
<a
href={channel.href}
className="text-muted-foreground transition-colors hover:text-primary"
>
{channel.value}
</a>
) : (
<p className="text-muted-foreground">{channel.value}</p>
)}
<p className="mt-1 text-sm text-muted-foreground">
{channel.note}
</p>
</div>
</div>
))}
</div>
</section>
<section className="mt-12">
<h2 className="text-xl font-bold tracking-tight">Common topics</h2>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{topics.map((topic) => (
<Link
key={topic.title}
to="/faq"
className="rounded-2xl border border-border bg-background p-5 transition hover:border-primary/40"
>
<div className="inline-flex rounded-2xl bg-accent p-3 text-primary">
<topic.icon className="size-5" />
</div>
<p className="mt-4 font-semibold">{topic.title}</p>
<p className="mt-1 leading-7 text-muted-foreground">
{topic.body}
</p>
</Link>
))}
</div>
</section>
<section className="mt-12 rounded-[32px] border border-border bg-card p-8">
<div className="flex items-start gap-4">
<div className="rounded-2xl bg-accent p-3 text-primary">
<HelpCircle className="size-5" />
</div>
<div>
<h2 className="text-xl font-bold tracking-tight">
What to include when you contact us
</h2>
<ul className="mt-3 list-disc space-y-2 pl-5 leading-7 text-muted-foreground">
<li>Your company name and the email you sign in with.</li>
<li>
The reference of the contract, booking or invoice involved.
</li>
<li>What you expected to happen and what happened instead.</li>
<li>A screenshot of any error message the portal showed.</li>
</ul>
</div>
</div>
</section>
</DocShell> </DocShell>
); );
} }

View File

@@ -0,0 +1,72 @@
import ReactMarkdown from "react-markdown";
/**
* Renders admin-authored markdown from the support-content API.
*
* Deliberately plain `react-markdown`: it builds React elements directly, so
* unlike a markdown→HTML-string library it needs no `dangerouslySetInnerHTML`
* and no sanitizer, and the portal keeps its zero HTML-injection surface.
*
* 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.
*
* `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.
*
* The component map reproduces the Tailwind classes the pages used when this
* copy was hardcoded, so switching to markdown changed nothing visually.
*/
export function Markdown({ children }: { children: string }) {
return (
<ReactMarkdown
components={{
p: ({ children: content }) => (
<p className="mt-4 leading-7 text-muted-foreground">{content}</p>
),
ul: ({ children: content }) => (
<ul className="mt-4 list-disc space-y-2 pl-5 leading-7 text-muted-foreground">
{content}
</ul>
),
ol: ({ children: content }) => (
<ol className="mt-4 list-decimal space-y-2 pl-5 leading-7 text-muted-foreground">
{content}
</ol>
),
li: ({ children: content }) => <li>{content}</li>,
a: ({ href, children: content }) => (
<a
href={href}
className="font-semibold text-foreground transition-colors hover:text-primary"
>
{content}
</a>
),
strong: ({ children: content }) => (
<strong className="font-semibold text-foreground">{content}</strong>
),
em: ({ children: content }) => <em className="italic">{content}</em>,
h3: ({ children: content }) => (
<h3 className="mt-6 font-bold tracking-tight">{content}</h3>
),
// Images embedded by the editor. The API has already resolved these to
// signed URLs; react-markdown's default urlTransform still guards the
// scheme.
img: ({ src, alt }) => (
<img
src={typeof src === "string" ? src : undefined}
alt={alt ?? ""}
loading="lazy"
className="mt-4 w-full rounded-2xl border border-border"
/>
),
}}
>
{children}
</ReactMarkdown>
);
}
export default Markdown;

View File

@@ -1,15 +1,20 @@
import { usePortalContent } from "@/hooks/usePortalContent";
import { DocSections, DocShell } from "./DocShell"; import { DocSections, DocShell } from "./DocShell";
import { LEGAL_LAST_UPDATED, PRIVACY_SECTIONS } from "./content";
export default function PrivacyPolicyPage() { export default function PrivacyPolicyPage() {
// Never undefined — see TermsPage.
const { data } = usePortalContent();
const privacy = data!.privacy;
return ( return (
<DocShell <DocShell
current="/privacy" current="/privacy"
title="Privacy Policy" title={privacy.title}
subtitle="How EDR Freight collects, uses, shares and protects the information you provide when you use the platform." subtitle={privacy.subtitle}
meta={`Last updated ${LEGAL_LAST_UPDATED}`} meta={`Last updated ${privacy.lastUpdated}`}
> >
<DocSections sections={PRIVACY_SECTIONS} /> <DocSections sections={privacy.sections} />
</DocShell> </DocShell>
); );
} }

View File

@@ -1,15 +1,21 @@
import { usePortalContent } from "@/hooks/usePortalContent";
import { DocSections, DocShell } from "./DocShell"; import { DocSections, DocShell } from "./DocShell";
import { LEGAL_LAST_UPDATED, TERMS_SECTIONS } from "./content";
export default function TermsPage() { 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;
return ( return (
<DocShell <DocShell
current="/terms" current="/terms"
title="Terms of Service" title={terms.title}
subtitle="The terms on which EDR provides the EDR Freight platform and the freight services you request through it." subtitle={terms.subtitle}
meta={`Last updated ${LEGAL_LAST_UPDATED}`} meta={`Last updated ${terms.lastUpdated}`}
> >
<DocSections sections={TERMS_SECTIONS} /> <DocSections sections={terms.sections} />
</DocShell> </DocShell>
); );
} }

View File

@@ -1,358 +0,0 @@
/**
* Copy for the public help/FAQ/legal pages. Kept as data so the pages stay
* thin — the shell in `DocShell.tsx` renders any `Section[]` the same way.
*
* The privacy and terms text is the platform's working draft; legal counsel
* signs off on the final wording, and `LEGAL_LAST_UPDATED` is bumped with it.
*/
export const SUPPORT_CONTACT = {
email: "support@edrfreight.com",
phone: "+251 11 000 0000",
office: "Addis Ababa, Ethiopia",
hours: "Monday Saturday, 8:30 AM 5:30 PM (EAT)",
};
export const LEGAL_LAST_UPDATED = "6 August 2026";
export interface Section {
heading: string;
/** Paragraphs, rendered in order. */
body?: string[];
/** Optional bullet list, rendered after the paragraphs. */
bullets?: string[];
}
export interface FaqItem {
question: string;
answer: string;
}
export interface FaqGroup {
title: string;
items: FaqItem[];
}
export const FAQ_GROUPS: FaqGroup[] = [
{
title: "Getting started",
items: [
{
question: "How do I open an account on EDR Freight?",
answer:
"Sign up with your work email and verify the one-time code we send you. After you set a password, the onboarding wizard collects your company details, trade licence, TIN certificate and the operational services you need (importer, exporter, freight forwarder or transporter). Submit the wizard and our team reviews the application.",
},
{
question: "How long does account approval take?",
answer:
"Most complete applications are reviewed within two working days. You will see the status on your dashboard, and we email you when a profile is approved or when a document needs to be re-uploaded.",
},
{
question: "My profile was rejected. What now?",
answer:
"The rejection notice states the reason. Open Settings, correct the details or replace the document that was flagged, and re-apply — you do not need to create a new account.",
},
{
question: "Can one company hold several operational services?",
answer:
"Yes. A company can hold importer, exporter, freight forwarder and transporter profiles at the same time. Each is approved separately, and the header lets you switch between the ones you hold.",
},
],
},
{
title: "Contracts and bookings",
items: [
{
question: "What is the difference between a contract and a booking?",
answer:
"A contract is the commercial agreement covering a cargo movement — route, commodity, volume and rates. A booking is a single shipment executed under that contract. You create the contract once, then raise bookings against it for each consignment.",
},
{
question: "How do I create a booking?",
answer:
"Open the contract from the Contracts list and choose New Booking. Provide the consignment details, containers or tonnage, and the last-mile requirement if you need one. Bookings can also be started from the Bookings page, which routes you through contract selection first.",
},
{
question: "Why do I have to sign a contract before shipping?",
answer:
"The contract document is the binding agreement for the movement. You must scroll to the end, accept the terms, and sign it with your saved signature and stamp before EDR schedules any wagon against it.",
},
{
question: "Where do I set up my signature and stamp?",
answer:
"Under Signature & Stamp in the portal. It is saved once and reused for every contract you sign, so you do not have to upload it per document.",
},
{
question: "Can I change a booking after submitting it?",
answer:
"You can edit a booking while it is still pending review. Once EDR has confirmed it and allocated capacity, changes go through our operations team — contact support with the booking reference.",
},
{
question: "How do I track a consignment?",
answer:
"Open the booking and use the tracking panel, which shows the current milestone, the wagon or container assigned, and the timestamps recorded at each corridor point.",
},
],
},
{
title: "Invoices and payments",
items: [
{
question: "Where do I find my invoices?",
answer:
"The Invoices page lists every invoice raised against your company, with its status, due date and outstanding balance. Open any invoice to see its line items and download a PDF copy.",
},
{
question: "Which payment methods are supported?",
answer:
"Payments are made through the integrated bank channels shown at checkout. After you complete the payment on the bank's page you are returned to the portal, and the invoice status updates once the bank confirms the transaction.",
},
{
question: "My payment was deducted but the invoice still shows unpaid.",
answer:
"Bank confirmations can lag by a few minutes. Use the Check Payment Status page linked from your receipt; if it still has not settled after an hour, email support with the invoice number and the bank reference and we will reconcile it.",
},
{
question: "Why is my invoice amount rounded?",
answer:
"Some bank channels only accept whole-birr amounts, so invoices routed through them are rounded up to the nearest birr. The rounding is shown on the invoice detail page.",
},
],
},
{
title: "Account and security",
items: [
{
question: "How do I reset my password?",
answer:
"Use Forgot Password on the sign-in page. We email you a reset link that is valid for a limited time. If a member of our staff issued the link, it works the same way even if you are already signed in.",
},
{
question: "Can I add colleagues to my company account?",
answer:
"Yes. Company administrators can invite additional users from Settings. Each user signs in with their own credentials, and actions are recorded against the individual who performed them.",
},
{
question: "How do I update company details after approval?",
answer:
"Edit them in Settings. Changes to regulated fields — trade licence, TIN, legal name — are re-verified by our team before they take effect.",
},
],
},
];
export const PRIVACY_SECTIONS: Section[] = [
{
heading: "1. Introduction",
body: [
"The Ethio-Djibouti Standard Gauge Rail Share Company (\"EDR\", \"we\", \"us\") operates the EDR Freight platform, which lets customers register their business, agree freight contracts, raise bookings, track consignments and settle invoices online.",
"This policy explains what personal and business information we collect through the platform, why we collect it, how long we keep it and what rights you have over it. It applies to the EDR Freight customer portal and the services reached through it.",
],
},
{
heading: "2. Information we collect",
body: [
"We collect information you give us, information generated by your use of the platform, and information we receive from the regulators and financial institutions we work with.",
],
bullets: [
"Account details — name, work email address, phone number and the credentials used to sign in.",
"Company and compliance records — legal name, trade licence, TIN certificate, VAT registration, ownership and manager details, and the operational services you apply for.",
"Identity verification data — where you verify through a national identity service, the verification result and the attributes that service returns to us.",
"Operational data — contracts, bookings, consignment and cargo details, container and wagon assignments, tracking events and delivery confirmations.",
"Financial data — invoices, payment references, transaction status and settlement confirmations received from banks. We do not store your card numbers or online banking credentials.",
"Support data — the messages and files you send us through the in-app support chat or by email.",
"Technical data — IP address, device and browser information, and event logs generated when you use the platform.",
],
},
{
heading: "3. How we use your information",
bullets: [
"To create and administer your account and verify that your company is entitled to the services it applies for.",
"To perform the freight contracts and bookings you place, including allocating capacity and coordinating rail and last-mile movements.",
"To issue invoices, process payments and keep the accounting records the law requires us to keep.",
"To provide customer support and respond to the questions and complaints you raise.",
"To keep the platform secure, detect misuse and investigate incidents.",
"To meet our legal, tax, customs and regulatory obligations in Ethiopia and Djibouti.",
"To improve the platform — measuring which features are used and where users encounter errors, using aggregated and pseudonymised data wherever that is sufficient.",
],
},
{
heading: "4. Legal basis for processing",
body: [
"We process your information because it is necessary to perform the contract between you and EDR, because we have a legal obligation to do so (customs, tax and transport regulation), or because we have a legitimate interest in operating and securing the platform. Where we rely on your consent — for example, optional marketing messages — you can withdraw it at any time.",
],
},
{
heading: "5. Sharing your information",
body: [
"We do not sell your information. We share it only where it is necessary to deliver the service or where the law requires it.",
],
bullets: [
"Government and regulatory bodies — customs, revenue and transport authorities in Ethiopia and Djibouti, to the extent required for the movement of your cargo.",
"Ports, terminals and last-mile transporters involved in executing your bookings.",
"Banks and payment providers, to initiate and reconcile the payments you make.",
"Technology suppliers who host and maintain the platform on our behalf, under contracts that restrict them to processing data on our instructions.",
"Courts, law enforcement and other authorities where we are legally compelled to disclose.",
],
},
{
heading: "6. International transfers",
body: [
"Cross-border freight inherently involves parties in more than one country, so consignment and clearance information is shared with counterparties and authorities in Djibouti as well as Ethiopia. Where we transfer information outside Ethiopia, we do so only as far as the movement requires or the law permits, and we require recipients to protect it to a comparable standard.",
],
},
{
heading: "7. Data retention",
body: [
"We keep account and company records for as long as your account is active. Contract, booking, customs and financial records are kept for the period required by Ethiopian commercial, tax and customs law after the relevant transaction, because we are obliged to be able to produce them. Support conversations and technical logs are kept for a shorter period, sufficient to resolve disputes and investigate security incidents.",
],
},
{
heading: "8. Security",
body: [
"Access to the platform requires authentication, and staff access to customer records is limited to what each role needs. Data is transmitted over encrypted connections and stored on systems protected by access controls and logging. No system is perfectly secure, so please keep your credentials confidential and tell us immediately if you believe your account has been compromised.",
],
},
{
heading: "9. Your rights",
body: [
"Subject to Ethiopian law, you may ask us to give you a copy of the personal information we hold about you, correct it if it is inaccurate, restrict or object to certain processing, or delete it where we are not required to keep it. Requests are handled through the contact details below; we may need to verify your identity before acting.",
],
},
{
heading: "10. Cookies and similar technologies",
body: [
"The platform uses cookies and browser storage to keep you signed in, remember your interface preferences and measure how the product is used so we can fix problems. Essential cookies cannot be turned off without breaking sign-in. You can clear or block the rest through your browser settings.",
],
},
{
heading: "11. Children",
body: [
"The platform is a business service and is not directed at children. We do not knowingly collect information from anyone under 18.",
],
},
{
heading: "12. Changes to this policy",
body: [
"We may update this policy as the platform and the law change. Material changes are announced in the portal before they take effect, and the date at the top of this page always reflects the current version.",
],
},
{
heading: "13. Contact us",
body: [
`Questions about this policy or about how we handle your information can be sent to ${SUPPORT_CONTACT.email}, called in on ${SUPPORT_CONTACT.phone}, or addressed to our head office in ${SUPPORT_CONTACT.office}.`,
],
},
];
export const TERMS_SECTIONS: Section[] = [
{
heading: "1. These terms",
body: [
"These terms govern your use of the EDR Freight platform operated by the Ethio-Djibouti Standard Gauge Rail Share Company (\"EDR\"). By creating an account or using the platform, the company you represent agrees to them.",
"The platform is the channel through which you register, request and manage freight services. The commercial terms of each movement — routes, rates, volumes and payment terms — are set out in the freight contract you sign in the platform. Where a signed contract and these terms conflict, the signed contract governs that movement.",
],
},
{
heading: "2. Eligibility and accounts",
bullets: [
"The platform is for registered businesses. You confirm that you are authorised to act for the company you register and to bind it to these terms.",
"The information and documents you submit — trade licence, TIN, VAT registration, ownership details — must be accurate, current and genuine.",
"Accounts and operational profiles are activated only after EDR has reviewed and approved them, and approval may be refused or withdrawn.",
"You are responsible for keeping credentials confidential and for everything done under your account. Tell us at once if you suspect unauthorised use.",
],
},
{
heading: "3. Contracts and bookings",
bullets: [
"A freight contract takes effect when it is signed in the platform by you and countersigned by EDR.",
"A booking is a request for a specific movement under a contract. It becomes binding when EDR confirms it and allocates capacity — submission alone does not reserve a wagon or container.",
"You are responsible for the accuracy of consignment data: commodity description, weight, dimensions, container numbers, hazardous classification and consignee details.",
"Capacity is finite. EDR may decline, defer or reschedule a booking where capacity, safety, operating conditions or regulatory direction require it.",
],
},
{
heading: "4. Cargo, documents and compliance",
bullets: [
"You must obtain and provide every permit, customs declaration and clearance document the movement requires, and you warrant that the cargo may lawfully be carried.",
"Prohibited and restricted goods may not be tendered without EDR's prior written agreement and any licence the law requires.",
"Cargo must be packed, secured and, where applicable, labelled to the standard the mode of carriage requires. EDR may inspect, refuse or offload cargo that is misdeclared or unsafe.",
"You are liable for fines, demurrage, storage charges and losses arising from misdeclared cargo, missing documents or delays attributable to you.",
],
},
{
heading: "5. Rates, invoicing and payment",
bullets: [
"Charges are calculated from the rates in your contract, the tariffs published in the platform, and any accessorial services actually rendered.",
"Invoices are issued in the platform and are payable by the due date shown on them, through the payment channels the platform offers.",
"Payment is confirmed when the funds are confirmed by the bank, not when payment is initiated.",
"Overdue amounts may attract interest and may result in suspension of new bookings or of the account until the balance is cleared.",
"Taxes and statutory duties are your responsibility unless the contract expressly says otherwise.",
],
},
{
heading: "6. Delivery, delay and liability",
body: [
"Transit times shown in the platform are estimates based on planned schedules. They are not guarantees, and EDR is not liable for indirect or consequential loss, loss of profit or loss of market arising from delay.",
"EDR's liability for loss of or damage to cargo is limited to the extent set out in the applicable freight contract and in the transport law governing the carriage. Claims must be notified in writing within the period the contract specifies; late claims may be rejected.",
"Neither party is liable for failure to perform caused by events beyond its reasonable control, including natural disasters, industrial action, civil unrest, infrastructure failure, or acts of government and regulatory authorities.",
],
},
{
heading: "7. Acceptable use of the platform",
bullets: [
"Use the platform only for its intended purpose and in accordance with applicable law.",
"Do not attempt to gain unauthorised access, probe or disrupt the service, or interfere with other customers' data.",
"Do not scrape, resell or redistribute platform content, rates or data without written permission.",
"Do not upload malware or content that infringes the rights of others.",
],
},
{
heading: "8. Electronic signatures and records",
body: [
"You agree that contracts signed in the platform using your stored signature and stamp are validly executed, that the records the platform keeps of those signatures are admissible evidence of them, and that they carry the same effect as signatures on paper.",
],
},
{
heading: "9. Availability and changes to the service",
body: [
"We aim to keep the platform available, but it may be interrupted for maintenance, upgrades or reasons outside our control. We may add, change or withdraw features. Where a change materially affects how you use the platform, we will give reasonable notice in the portal.",
],
},
{
heading: "10. Suspension and termination",
body: [
"We may suspend or terminate access where these terms are breached, where documents prove to be false, where amounts remain unpaid, or where the law or a regulator requires it. You may stop using the platform at any time. Termination does not affect obligations already incurred — cargo in transit, invoices outstanding, or records we are required to retain.",
],
},
{
heading: "11. Intellectual property",
body: [
"The platform, its software, design and content belong to EDR or its licensors. You are granted a non-exclusive, non-transferable right to use it for your own freight operations. Your commercial and consignment data remains yours; you grant us the right to process it as needed to deliver the service and as described in the Privacy Policy.",
],
},
{
heading: "12. Confidentiality and data protection",
body: [
"Each party will keep the other's non-public commercial information confidential and use it only for the purposes of the services. Our handling of personal information is described in the Privacy Policy, which forms part of these terms.",
],
},
{
heading: "13. Governing law and disputes",
body: [
"These terms are governed by the laws of the Federal Democratic Republic of Ethiopia. The parties will first attempt to resolve any dispute amicably; failing that, the dispute is subject to the jurisdiction of the competent courts of Ethiopia, without prejudice to any arbitration clause agreed in a specific freight contract.",
],
},
{
heading: "14. Changes to these terms",
body: [
"We may update these terms as the service and the law change. Updates are published here and announced in the portal. Continuing to use the platform after an update takes effect means you accept the revised terms.",
],
},
{
heading: "15. Contact",
body: [
`For questions about these terms, write to ${SUPPORT_CONTACT.email} or call ${SUPPORT_CONTACT.phone}.`,
],
},
];

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import {
applyPortalVars,
FALLBACK_PORTAL_CONTENT,
safeMediaSrc,
withSupportVars,
} from "./portal-content";
const contact = {
email: "support@edrfreight.com",
phone: "+251 11 000 0000",
office: "Addis Ababa, Ethiopia",
hours: "Monday Saturday, 8:30 AM 5:30 PM (EAT)",
};
describe("applyPortalVars", () => {
it("substitutes every known token", () => {
expect(
applyPortalVars(
"Mail {{supportEmail}}, call {{supportPhone}}, visit {{supportOffice}}, open {{supportHours}}.",
contact,
),
).toBe(
"Mail support@edrfreight.com, call +251 11 000 0000, visit Addis Ababa, Ethiopia, open Monday Saturday, 8:30 AM 5:30 PM (EAT).",
);
});
it("strips spacing for the tel: variant", () => {
expect(applyPortalVars("tel:{{supportPhoneTel}}", contact)).toBe(
"tel:+251110000000",
);
});
it("leaves an unknown token verbatim so the typo is visible", () => {
expect(applyPortalVars("Mail {{supportEmial}}.", contact)).toBe(
"Mail {{supportEmial}}.",
);
});
it("does not let $& in a contact value corrupt the output", () => {
// Guards the replace-callback choice: with a replacement *string*, `$&`
// would expand to the matched token and the address would come out wrong.
expect(
applyPortalVars("Write to {{supportOffice}}.", {
...contact,
office: "Bole $& Road",
}),
).toBe("Write to Bole $& Road.");
});
});
describe("withSupportVars", () => {
it("resolves placeholders buried in the shipped legal copy", () => {
const resolved = withSupportVars(FALLBACK_PORTAL_CONTENT);
const contactSection = resolved.privacy.sections.at(-1)!;
expect(contactSection.body).toContain(contact.email);
expect(contactSection.body).not.toContain("{{");
});
it("leaves the contact block itself alone — it is the substitution source", () => {
expect(withSupportVars(FALLBACK_PORTAL_CONTENT).contact).toEqual(
FALLBACK_PORTAL_CONTENT.contact,
);
});
});
describe("safeMediaSrc", () => {
it("keeps same-origin paths and https 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",
);
});
it("drops javascript: and protocol-relative sources", () => {
expect(safeMediaSrc("javascript:alert(1)")).toBeNull();
expect(safeMediaSrc("//evil.example.com/g.webm")).toBeNull();
// A bare object key means the API failed to sign it — render nothing
// rather than a broken relative URL.
expect(safeMediaSrc("support-content/a.png")).toBeNull();
});
});

View File

@@ -0,0 +1,103 @@
import {
SUPPORT_CONTENT_DEFAULTS,
type PortalContentBundle,
type PortalContentVar,
type PortalSupportContact,
} from "@edr/types";
/**
* Copy for the public help/FAQ/legal pages now lives in the database and is
* edited from the backoffice. This module holds what is left in the app: the
* shipped copy as a fallback, and the two pure helpers the pages need.
*
* The fallback matters because these four routes are public and linked from
* the sign-up screen — they are often the first thing an anonymous visitor
* sees. Rendering the shipped text while the request is in flight (or if the
* API is down) beats showing them a spinner or an error card, and it is why
* none of the four pages carry loading or error branches.
*/
export const FALLBACK_PORTAL_CONTENT: PortalContentBundle = {
contact: SUPPORT_CONTENT_DEFAULTS.CONTACT,
help: SUPPORT_CONTENT_DEFAULTS.HELP,
faq: SUPPORT_CONTENT_DEFAULTS.FAQ,
privacy: SUPPORT_CONTENT_DEFAULTS.PRIVACY,
terms: SUPPORT_CONTENT_DEFAULTS.TERMS,
};
const VAR_PATTERN =
/\{\{(supportEmail|supportPhone|supportPhoneTel|supportOffice|supportHours)\}\}/g;
function resolveVar(name: PortalContentVar, contact: PortalSupportContact) {
switch (name) {
case "supportEmail":
return contact.email;
case "supportPhone":
return contact.phone;
case "supportPhoneTel":
// tel: hrefs must not carry the display spacing.
return contact.phone.replace(/\s/g, "");
case "supportOffice":
return contact.office;
case "supportHours":
return contact.hours;
}
}
/**
* Substitutes `{{supportEmail}}`-style placeholders against the editable
* contact block. The support address used to be interpolated into the privacy
* and terms prose at build time, which meant editing it would have left those
* paragraphs quoting a stale one.
*
* Uses a replacement *callback* on purpose: with a replacement string, a `$&`
* or `$1` inside an admin-typed office address would be treated as a
* backreference and corrupt the output.
*
* Unknown tokens are left verbatim — the pattern only matches the four known
* names — so a typo shows up as `{{supportEmial}}` rather than a blank.
*/
export function applyPortalVars(
text: string,
contact: PortalSupportContact,
): string {
return text.replace(VAR_PATTERN, (_match, name: PortalContentVar) =>
resolveVar(name, contact),
);
}
/**
* Applies {@link applyPortalVars} to every string in the bundle except the
* contact block itself, which is the substitution source. Walking the whole
* object means a placeholder works in any field, including ones added later.
*/
export function withSupportVars(
bundle: PortalContentBundle,
): PortalContentBundle {
const { contact, ...rest } = bundle;
const walk = (value: unknown): unknown => {
if (typeof value === "string") return applyPortalVars(value, contact);
if (Array.isArray(value)) return value.map(walk);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, inner]) => [key, walk(inner)]),
);
}
return value;
};
return { contact, ...(walk(rest) as Omit<PortalContentBundle, "contact">) };
}
/**
* 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.
*
* `(?!\/)` 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.
*/
export function safeMediaSrc(src: string): string | null {
return /^(https:\/\/|\/(?!\/))/.test(src) ? src : null;
}

View File

@@ -11,6 +11,8 @@ export * from "./ethiopian-regions.catalog";
export * from "./notifications"; export * from "./notifications";
export * from "./booking-window-ws"; export * from "./booking-window-ws";
export * from "./support-chat"; export * from "./support-chat";
export * from "./portal-content";
export * from "./portal-content.defaults";
export enum TradeDirection { export enum TradeDirection {
IMPORT = "IMPORT", IMPORT = "IMPORT",

View File

@@ -0,0 +1,375 @@
import type { SupportDocPayloadMap } from "./portal-content";
/**
* The copy the portal shipped with, transcribed from what used to be
* `edr-freight-web/portal/src/pages/support/content.ts` and the inline blocks
* of `HelpPage.tsx`.
*
* Two mechanical changes from the original:
*
* 1. `Section { body: string[]; bullets: string[] }` collapses to one markdown
* string — paragraphs separated by a blank line, bullets as `- ` lines.
* 2. The support email/phone/office, previously string-interpolated into the
* privacy and terms prose at build time, are now `{{supportEmail}}`-style
* placeholders resolved against the CONTACT document at read time. That is
* what stops the legal text keeping a stale phone number after an edit.
*
* It lives in the shared package because three consumers need the same bytes
* and any drift between them would only surface during an outage: the API
* seeds the database from it and serves it for any row still missing, and the
* portal renders it while the request is in flight or if the API is down.
* Once seeded, the database is authoritative and this is only a floor.
*/
export const SUPPORT_CONTENT_DEFAULTS: SupportDocPayloadMap = {
CONTACT: {
email: "support@edrfreight.com",
phone: "+251 11 000 0000",
office: "Addis Ababa, Ethiopia",
hours: "Monday Saturday, 8:30 AM 5:30 PM (EAT)",
},
HELP: {
title: "Help & Support",
subtitle:
"Get answers fast — watch the walkthrough, browse the common topics, check the FAQ, or reach our team directly.",
sections: [
{
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,
},
],
},
{
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: [],
},
],
},
FAQ: {
title: "Frequently Asked Questions",
subtitle:
"Answers to the questions customers ask most about registering, booking cargo and settling invoices on EDR Freight.",
groups: [
{
id: "faq-getting-started",
title: "Getting started",
items: [
{
id: "faq-open-account",
question: "How do I open an account on EDR Freight?",
answer:
"Sign up with your work email and verify the one-time code we send you. After you set a password, the onboarding wizard collects your company details, trade licence, TIN certificate and the operational services you need (importer, exporter, freight forwarder or transporter). Submit the wizard and our team reviews the application.",
},
{
id: "faq-approval-time",
question: "How long does account approval take?",
answer:
"Most complete applications are reviewed within two working days. You will see the status on your dashboard, and we email you when a profile is approved or when a document needs to be re-uploaded.",
},
{
id: "faq-rejected",
question: "My profile was rejected. What now?",
answer:
"The rejection notice states the reason. Open Settings, correct the details or replace the document that was flagged, and re-apply — you do not need to create a new account.",
},
{
id: "faq-multiple-services",
question: "Can one company hold several operational services?",
answer:
"Yes. A company can hold importer, exporter, freight forwarder and transporter profiles at the same time. Each is approved separately, and the header lets you switch between the ones you hold.",
},
],
},
{
id: "faq-contracts-bookings",
title: "Contracts and bookings",
items: [
{
id: "faq-contract-vs-booking",
question: "What is the difference between a contract and a booking?",
answer:
"A contract is the commercial agreement covering a cargo movement — route, commodity, volume and rates. A booking is a single shipment executed under that contract. You create the contract once, then raise bookings against it for each consignment.",
},
{
id: "faq-create-booking",
question: "How do I create a booking?",
answer:
"Open the contract from the Contracts list and choose New Booking. Provide the consignment details, containers or tonnage, and the last-mile requirement if you need one. Bookings can also be started from the Bookings page, which routes you through contract selection first.",
},
{
id: "faq-sign-contract",
question: "Why do I have to sign a contract before shipping?",
answer:
"The contract document is the binding agreement for the movement. You must scroll to the end, accept the terms, and sign it with your saved signature and stamp before EDR schedules any wagon against it.",
},
{
id: "faq-signature-setup",
question: "Where do I set up my signature and stamp?",
answer:
"Under Signature & Stamp in the portal. It is saved once and reused for every contract you sign, so you do not have to upload it per document.",
},
{
id: "faq-change-booking",
question: "Can I change a booking after submitting it?",
answer:
"You can edit a booking while it is still pending review. Once EDR has confirmed it and allocated capacity, changes go through our operations team — contact support with the booking reference.",
},
{
id: "faq-track-consignment",
question: "How do I track a consignment?",
answer:
"Open the booking and use the tracking panel, which shows the current milestone, the wagon or container assigned, and the timestamps recorded at each corridor point.",
},
],
},
{
id: "faq-invoices-payments",
title: "Invoices and payments",
items: [
{
id: "faq-find-invoices",
question: "Where do I find my invoices?",
answer:
"The Invoices page lists every invoice raised against your company, with its status, due date and outstanding balance. Open any invoice to see its line items and download a PDF copy.",
},
{
id: "faq-payment-methods",
question: "Which payment methods are supported?",
answer:
"Payments are made through the integrated bank channels shown at checkout. After you complete the payment on the bank's page you are returned to the portal, and the invoice status updates once the bank confirms the transaction.",
},
{
id: "faq-payment-not-settled",
question:
"My payment was deducted but the invoice still shows unpaid.",
answer:
"Bank confirmations can lag by a few minutes. Use the Check Payment Status page linked from your receipt; if it still has not settled after an hour, email support with the invoice number and the bank reference and we will reconcile it.",
},
{
id: "faq-rounding",
question: "Why is my invoice amount rounded?",
answer:
"Some bank channels only accept whole-birr amounts, so invoices routed through them are rounded up to the nearest birr. The rounding is shown on the invoice detail page.",
},
],
},
{
id: "faq-account-security",
title: "Account and security",
items: [
{
id: "faq-reset-password",
question: "How do I reset my password?",
answer:
"Use Forgot Password on the sign-in page. We email you a reset link that is valid for a limited time. If a member of our staff issued the link, it works the same way even if you are already signed in.",
},
{
id: "faq-add-colleagues",
question: "Can I add colleagues to my company account?",
answer:
"Yes. Company administrators can invite additional users from Settings. Each user signs in with their own credentials, and actions are recorded against the individual who performed them.",
},
{
id: "faq-update-company",
question: "How do I update company details after approval?",
answer:
"Edit them in Settings. Changes to regulated fields — trade licence, TIN, legal name — are re-verified by our team before they take effect.",
},
],
},
],
footer: {
heading: "Still need a hand?",
body: "Our team is on {{supportEmail}} and {{supportPhone}}, or you can start a chat from the support button inside the portal.",
ctaLabel: "Go to Help & Support",
ctaTo: "/help",
},
},
PRIVACY: {
title: "Privacy Policy",
subtitle:
"How EDR Freight collects, uses, shares and protects the information you provide when you use the platform.",
lastUpdated: "6 August 2026",
sections: [
{
id: "privacy-1",
heading: "1. Introduction",
body: 'The Ethio-Djibouti Standard Gauge Rail Share Company ("EDR", "we", "us") operates the EDR Freight platform, which lets customers register their business, agree freight contracts, raise bookings, track consignments and settle invoices online.\n\nThis policy explains what personal and business information we collect through the platform, why we collect it, how long we keep it and what rights you have over it. It applies to the EDR Freight customer portal and the services reached through it.',
},
{
id: "privacy-2",
heading: "2. Information we collect",
body: "We collect information you give us, information generated by your use of the platform, and information we receive from the regulators and financial institutions we work with.\n\n- Account details — name, work email address, phone number and the credentials used to sign in.\n- Company and compliance records — legal name, trade licence, TIN certificate, VAT registration, ownership and manager details, and the operational services you apply for.\n- Identity verification data — where you verify through a national identity service, the verification result and the attributes that service returns to us.\n- Operational data — contracts, bookings, consignment and cargo details, container and wagon assignments, tracking events and delivery confirmations.\n- Financial data — invoices, payment references, transaction status and settlement confirmations received from banks. We do not store your card numbers or online banking credentials.\n- Support data — the messages and files you send us through the in-app support chat or by email.\n- Technical data — IP address, device and browser information, and event logs generated when you use the platform.",
},
{
id: "privacy-3",
heading: "3. How we use your information",
body: "- To create and administer your account and verify that your company is entitled to the services it applies for.\n- To perform the freight contracts and bookings you place, including allocating capacity and coordinating rail and last-mile movements.\n- To issue invoices, process payments and keep the accounting records the law requires us to keep.\n- To provide customer support and respond to the questions and complaints you raise.\n- To keep the platform secure, detect misuse and investigate incidents.\n- To meet our legal, tax, customs and regulatory obligations in Ethiopia and Djibouti.\n- To improve the platform — measuring which features are used and where users encounter errors, using aggregated and pseudonymised data wherever that is sufficient.",
},
{
id: "privacy-4",
heading: "4. Legal basis for processing",
body: "We process your information because it is necessary to perform the contract between you and EDR, because we have a legal obligation to do so (customs, tax and transport regulation), or because we have a legitimate interest in operating and securing the platform. Where we rely on your consent — for example, optional marketing messages — you can withdraw it at any time.",
},
{
id: "privacy-5",
heading: "5. Sharing your information",
body: "We do not sell your information. We share it only where it is necessary to deliver the service or where the law requires it.\n\n- Government and regulatory bodies — customs, revenue and transport authorities in Ethiopia and Djibouti, to the extent required for the movement of your cargo.\n- Ports, terminals and last-mile transporters involved in executing your bookings.\n- Banks and payment providers, to initiate and reconcile the payments you make.\n- Technology suppliers who host and maintain the platform on our behalf, under contracts that restrict them to processing data on our instructions.\n- Courts, law enforcement and other authorities where we are legally compelled to disclose.",
},
{
id: "privacy-6",
heading: "6. International transfers",
body: "Cross-border freight inherently involves parties in more than one country, so consignment and clearance information is shared with counterparties and authorities in Djibouti as well as Ethiopia. Where we transfer information outside Ethiopia, we do so only as far as the movement requires or the law permits, and we require recipients to protect it to a comparable standard.",
},
{
id: "privacy-7",
heading: "7. Data retention",
body: "We keep account and company records for as long as your account is active. Contract, booking, customs and financial records are kept for the period required by Ethiopian commercial, tax and customs law after the relevant transaction, because we are obliged to be able to produce them. Support conversations and technical logs are kept for a shorter period, sufficient to resolve disputes and investigate security incidents.",
},
{
id: "privacy-8",
heading: "8. Security",
body: "Access to the platform requires authentication, and staff access to customer records is limited to what each role needs. Data is transmitted over encrypted connections and stored on systems protected by access controls and logging. No system is perfectly secure, so please keep your credentials confidential and tell us immediately if you believe your account has been compromised.",
},
{
id: "privacy-9",
heading: "9. Your rights",
body: "Subject to Ethiopian law, you may ask us to give you a copy of the personal information we hold about you, correct it if it is inaccurate, restrict or object to certain processing, or delete it where we are not required to keep it. Requests are handled through the contact details below; we may need to verify your identity before acting.",
},
{
id: "privacy-10",
heading: "10. Cookies and similar technologies",
body: "The platform uses cookies and browser storage to keep you signed in, remember your interface preferences and measure how the product is used so we can fix problems. Essential cookies cannot be turned off without breaking sign-in. You can clear or block the rest through your browser settings.",
},
{
id: "privacy-11",
heading: "11. Children",
body: "The platform is a business service and is not directed at children. We do not knowingly collect information from anyone under 18.",
},
{
id: "privacy-12",
heading: "12. Changes to this policy",
body: "We may update this policy as the platform and the law change. Material changes are announced in the portal before they take effect, and the date at the top of this page always reflects the current version.",
},
{
id: "privacy-13",
heading: "13. Contact us",
body: "Questions about this policy or about how we handle your information can be sent to {{supportEmail}}, called in on {{supportPhone}}, or addressed to our head office in {{supportOffice}}.",
},
],
},
TERMS: {
title: "Terms of Service",
subtitle:
"The terms on which EDR provides the EDR Freight platform and the freight services you request through it.",
lastUpdated: "6 August 2026",
sections: [
{
id: "terms-1",
heading: "1. These terms",
body: 'These terms govern your use of the EDR Freight platform operated by the Ethio-Djibouti Standard Gauge Rail Share Company ("EDR"). By creating an account or using the platform, the company you represent agrees to them.\n\nThe platform is the channel through which you register, request and manage freight services. The commercial terms of each movement — routes, rates, volumes and payment terms — are set out in the freight contract you sign in the platform. Where a signed contract and these terms conflict, the signed contract governs that movement.',
},
{
id: "terms-2",
heading: "2. Eligibility and accounts",
body: "- The platform is for registered businesses. You confirm that you are authorised to act for the company you register and to bind it to these terms.\n- The information and documents you submit — trade licence, TIN, VAT registration, ownership details — must be accurate, current and genuine.\n- Accounts and operational profiles are activated only after EDR has reviewed and approved them, and approval may be refused or withdrawn.\n- You are responsible for keeping credentials confidential and for everything done under your account. Tell us at once if you suspect unauthorised use.",
},
{
id: "terms-3",
heading: "3. Contracts and bookings",
body: "- A freight contract takes effect when it is signed in the platform by you and countersigned by EDR.\n- A booking is a request for a specific movement under a contract. It becomes binding when EDR confirms it and allocates capacity — submission alone does not reserve a wagon or container.\n- You are responsible for the accuracy of consignment data: commodity description, weight, dimensions, container numbers, hazardous classification and consignee details.\n- Capacity is finite. EDR may decline, defer or reschedule a booking where capacity, safety, operating conditions or regulatory direction require it.",
},
{
id: "terms-4",
heading: "4. Cargo, documents and compliance",
body: "- You must obtain and provide every permit, customs declaration and clearance document the movement requires, and you warrant that the cargo may lawfully be carried.\n- Prohibited and restricted goods may not be tendered without EDR's prior written agreement and any licence the law requires.\n- Cargo must be packed, secured and, where applicable, labelled to the standard the mode of carriage requires. EDR may inspect, refuse or offload cargo that is misdeclared or unsafe.\n- You are liable for fines, demurrage, storage charges and losses arising from misdeclared cargo, missing documents or delays attributable to you.",
},
{
id: "terms-5",
heading: "5. Rates, invoicing and payment",
body: "- Charges are calculated from the rates in your contract, the tariffs published in the platform, and any accessorial services actually rendered.\n- Invoices are issued in the platform and are payable by the due date shown on them, through the payment channels the platform offers.\n- Payment is confirmed when the funds are confirmed by the bank, not when payment is initiated.\n- Overdue amounts may attract interest and may result in suspension of new bookings or of the account until the balance is cleared.\n- Taxes and statutory duties are your responsibility unless the contract expressly says otherwise.",
},
{
id: "terms-6",
heading: "6. Delivery, delay and liability",
body: "Transit times shown in the platform are estimates based on planned schedules. They are not guarantees, and EDR is not liable for indirect or consequential loss, loss of profit or loss of market arising from delay.\n\nEDR's liability for loss of or damage to cargo is limited to the extent set out in the applicable freight contract and in the transport law governing the carriage. Claims must be notified in writing within the period the contract specifies; late claims may be rejected.\n\nNeither party is liable for failure to perform caused by events beyond its reasonable control, including natural disasters, industrial action, civil unrest, infrastructure failure, or acts of government and regulatory authorities.",
},
{
id: "terms-7",
heading: "7. Acceptable use of the platform",
body: "- Use the platform only for its intended purpose and in accordance with applicable law.\n- Do not attempt to gain unauthorised access, probe or disrupt the service, or interfere with other customers' data.\n- Do not scrape, resell or redistribute platform content, rates or data without written permission.\n- Do not upload malware or content that infringes the rights of others.",
},
{
id: "terms-8",
heading: "8. Electronic signatures and records",
body: "You agree that contracts signed in the platform using your stored signature and stamp are validly executed, that the records the platform keeps of those signatures are admissible evidence of them, and that they carry the same effect as signatures on paper.",
},
{
id: "terms-9",
heading: "9. Availability and changes to the service",
body: "We aim to keep the platform available, but it may be interrupted for maintenance, upgrades or reasons outside our control. We may add, change or withdraw features. Where a change materially affects how you use the platform, we will give reasonable notice in the portal.",
},
{
id: "terms-10",
heading: "10. Suspension and termination",
body: "We may suspend or terminate access where these terms are breached, where documents prove to be false, where amounts remain unpaid, or where the law or a regulator requires it. You may stop using the platform at any time. Termination does not affect obligations already incurred — cargo in transit, invoices outstanding, or records we are required to retain.",
},
{
id: "terms-11",
heading: "11. Intellectual property",
body: "The platform, its software, design and content belong to EDR or its licensors. You are granted a non-exclusive, non-transferable right to use it for your own freight operations. Your commercial and consignment data remains yours; you grant us the right to process it as needed to deliver the service and as described in the Privacy Policy.",
},
{
id: "terms-12",
heading: "12. Confidentiality and data protection",
body: "Each party will keep the other's non-public commercial information confidential and use it only for the purposes of the services. Our handling of personal information is described in the Privacy Policy, which forms part of these terms.",
},
{
id: "terms-13",
heading: "13. Governing law and disputes",
body: "These terms are governed by the laws of the Federal Democratic Republic of Ethiopia. The parties will first attempt to resolve any dispute amicably; failing that, the dispute is subject to the jurisdiction of the competent courts of Ethiopia, without prejudice to any arbitration clause agreed in a specific freight contract.",
},
{
id: "terms-14",
heading: "14. Changes to these terms",
body: "We may update these terms as the service and the law change. Updates are published here and announced in the portal. Continuing to use the platform after an update takes effect means you accept the revised terms.",
},
{
id: "terms-15",
heading: "15. Contact",
body: "For questions about these terms, write to {{supportEmail}} or call {{supportPhone}}.",
},
],
},
};

View File

@@ -0,0 +1,206 @@
/**
* Customer-facing copy for the freight portal's public pages — /help, /faq,
* /terms and /privacy. Edited in the backoffice, served to the portal by one
* public endpoint, and versioned so a bad edit can be rolled back.
*
* Body copy is **markdown**. It is rendered with `react-markdown` and no
* `rehype-raw`, so raw HTML inside it is inert — the fields that actually need
* validating are the structured ones that land in `href`/`src` attributes.
*/
/** One editable document. Each is a row in `freight.support_documents`. */
export const SUPPORT_DOC_SLUGS = [
"CONTACT",
"HELP",
"FAQ",
"PRIVACY",
"TERMS",
] as const;
export type SupportDocSlug = (typeof SUPPORT_DOC_SLUGS)[number];
/** MinIO key prefix every uploaded help attachment is stored under. */
export const SUPPORT_MEDIA_PREFIX = "support-content/";
/**
* Marks a MinIO object reference inside markdown, e.g.
* `![Wagon](minio:support-content/abc.png)`.
*
* Stored copy always holds the *key*, never a signed URL: a presigned URL
* expires, so writing one into the saved markdown would silently rot every
* embedded image a few hours later. The API swaps these for freshly signed
* URLs on each read instead.
*/
export const PORTAL_MEDIA_URI_SCHEME = "minio:";
/** 50 MB — walkthrough videos are the large case. */
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;
}
/**
* Placeholders usable inside any markdown or link field. They are substituted
* against the CONTACT document when the portal reads the bundle, which is what
* keeps the support address in the privacy/terms prose from drifting out of
* sync with the contact cards.
*
* `supportPhoneTel` is the whitespace-stripped phone, for `tel:` hrefs.
*/
export const PORTAL_CONTENT_VARS = [
"supportEmail",
"supportPhone",
"supportPhoneTel",
"supportOffice",
"supportHours",
] as const;
export type PortalContentVar = (typeof PORTAL_CONTENT_VARS)[number];
/** Slug `CONTACT`. The one place support contact details are edited. */
export interface PortalSupportContact {
email: string;
phone: string;
office: string;
hours: string;
}
/** One numbered section of a legal document. `body` is markdown. */
export interface PortalDocSection {
/** Stable per-section id — the React key, since headings can collide. */
id: string;
heading: string;
body: string;
}
/** Slugs `PRIVACY` and `TERMS` — same shape, separate documents. */
export interface PortalLegalContent {
title: string;
subtitle: string;
/** Free text, e.g. "6 August 2026". Shown as "Last updated …". */
lastUpdated: string;
sections: PortalDocSection[];
}
export interface PortalFaqItem {
id: string;
question: string;
/** Markdown. */
answer: string;
}
export interface PortalFaqGroup {
id: string;
title: string;
items: PortalFaqItem[];
}
/** A call-to-action card closing a page. `body` is markdown. */
export interface PortalCtaCard {
heading: string;
body: string;
ctaLabel: string;
/** In-app route (`/help`) or absolute URL. */
ctaTo: string;
}
/** Slug `FAQ`. */
export interface PortalFaqContent {
title: string;
subtitle: string;
groups: PortalFaqGroup[];
/** The "Still need a hand?" card. Null hides it. */
footer: PortalCtaCard | null;
}
/**
* One free-form block of the help page: a heading, a full markdown body, and
* any number of attached images or videos.
*
* 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.
*/
export interface PortalHelpSection {
id: string;
heading: string;
/**
* Markdown. Embedded images reference uploads as
* `![alt](minio:support-content/<file>)` — see {@link PORTAL_MEDIA_URI_SCHEME}.
*/
body: string;
/** Rendered under the body, in order. */
media: PortalMedia[];
}
/** Slug `HELP`. */
export interface PortalHelpContent {
title: string;
subtitle: string;
sections: PortalHelpSection[];
}
/** Payload shape per slug — the jsonb column's type, keyed by document. */
export interface SupportDocPayloadMap {
CONTACT: PortalSupportContact;
HELP: PortalHelpContent;
FAQ: PortalFaqContent;
PRIVACY: PortalLegalContent;
TERMS: PortalLegalContent;
}
export type SupportDocPayload = SupportDocPayloadMap[SupportDocSlug];
/** What `GET /api/support-content` returns — everything the portal needs. */
export interface PortalContentBundle {
contact: PortalSupportContact;
help: PortalHelpContent;
faq: PortalFaqContent;
privacy: PortalLegalContent;
terms: PortalLegalContent;
}
// ── Backoffice (staff) projections ─────────────────────────────────────────
/** A document row without its payload — the admin list. */
export interface SupportDocumentSummary {
id: string;
slug: SupportDocSlug;
version: number;
updatedAt: string;
updatedById: string | null;
}
export interface SupportDocumentDetail<
S extends SupportDocSlug = SupportDocSlug,
> extends SupportDocumentSummary {
slug: S;
payload: SupportDocPayloadMap[S];
}
/** A history row without its payload — the version list. */
export interface SupportDocVersionSummary {
id: string;
version: number;
actorId: string | null;
note: string | null;
createdAt: string;
}
/** A history row with its payload — fetched when a version is previewed. */
export interface SupportDocVersionDetail extends SupportDocVersionSummary {
payload: SupportDocPayload;
}

2399
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
portal-content-contact.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB