Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions config/CUSTOMIZATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,51 @@ This allows customization of:

For the complete schema documentation and examples, see the [Sensitive Data Fields Configuration Guide](../frontend-new/sensitive-data-fields-config.md).

## Legal Documents Configuration

Partners can customise the Terms of Use and Privacy Policy documents shown on
`/terms-of-use` and `/privacy-policy`.

- **legal.documents.termsOfUse**: URL (site-relative or absolute) of the Terms of Use markdown document
- **legal.documents.privacyPolicy**: URL (site-relative or absolute) of the Privacy Policy markdown document

There are two ways to provide each document. Pick whichever is more convenient
per document — both keys are optional and can be mixed.

**Path A (recommended): replace the bundled markdown files in your fork.**
Replace the markdown files at `frontend-new/public/legal/terms-of-use.md` and
`frontend-new/public/legal/privacy-policy.md`. Each file may begin with a
YAML frontmatter block to set the page title:

```markdown
---
title: "Terms of Use"
---

# Terms of Use

Body content...
```

If the `title` frontmatter is omitted, the page title falls back to a sensible
default (`Terms of Use` for `termsOfUse`, `Privacy Policy` for `privacyPolicy`).

**Path B (alternative): host the document elsewhere.**
Set `legal.documents.termsOfUse` (and/or `legal.documents.privacyPolicy`) to an
absolute URL pointing to your own host. Make sure your host serves the markdown
with CORS that allows the deployment origin to fetch it.

> **Updates affect only new consents.** Updating a legal document changes what
> users see on the consent screen going forward. Users who previously accepted
> the previous version continue without re-prompts; there is no automated
> re-consent flow.

> **Per-locale documents (planned, not yet supported).** In a future release,
> each value will be extendable to `Record<Locale, string>` so partners can
> ship per-language documents (e.g.
> `"termsOfUse": { "en-US": "/legal/terms-en.md", "es-ES": "/legal/terms-es.md" }`).
> The current `string`-shape config will continue to work unchanged.

## Analytics Configuration

Compass supports Google Analytics 4 (GA4) event tracking via Google Tag Manager (GTM).
Expand Down
6 changes: 6 additions & 0 deletions config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@
"faq": {
"tutorialVideoUrl": "https://player.vimeo.com/video/1193881841?h=8ee6f6190d&title=0&byline=0&portrait=0"
},
"legal": {
"documents": {
"termsOfUse": "/legal/terms-of-use.md",
"privacyPolicy": "/legal/privacy-policy.md"
}
},
"analytics": {
"ga4AccountId": "387253059",
"ga4PropertyId": "528078871",
Expand Down
4 changes: 4 additions & 0 deletions config/inject-config.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
# Analytics
"FRONTEND_GTM_CONTAINER_ID": "analytics.gtmContainerId",
"FRONTEND_GTM_ENABLED": "analytics.enabled",

# Legal documents
"FRONTEND_LEGAL_DOCUMENTS": "legal.documents",
}

FRONTEND_JSON_FIELDS = {
Expand All @@ -67,6 +70,7 @@
'FRONTEND_SKILLS_REPORT_OUTPUT_CONFIG',
'FRONTEND_SUPPORTED_LOCALES',
'FRONTEND_SENSITIVE_DATA_FIELDS',
'FRONTEND_LEGAL_DOCUMENTS',
# Add more JSON.stringify fields here
}

Expand Down
10 changes: 10 additions & 0 deletions frontend-new/public/data/env.example.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,16 @@ window.tabiyaConfig = {
FRONTEND_GTM_ENABLED: btoa("true"),
FRONTEND_GTM_CONTAINER_ID: btoa("GTM-XXXXXXX"),

// ################################################################
// # Legal documents (Terms of Use, Privacy Policy)
// ################################################################
FRONTEND_LEGAL_DOCUMENTS: btoa(
JSON.stringify({
termsOfUse: "/legal/terms-of-use.md",
privacyPolicy: "/legal/privacy-policy.md",
})
),

FRONTEND_SKILLS_REPORT_OUTPUT_CONFIG: btoa(
JSON.stringify({
logos: [
Expand Down
4 changes: 2 additions & 2 deletions frontend-new/src/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -454,11 +454,11 @@ const App = () => {
},
{
path: routerPaths.PRIVACY_POLICY,
element: <LegalDocumentPage variant="privacy" />,
element: <LegalDocumentPage variant="privacyPolicy" />,
},
{
path: routerPaths.TERMS_OF_USE,
element: <LegalDocumentPage variant="terms" />,
element: <LegalDocumentPage variant="termsOfUse" />,
},
{
path: routerPaths.FAQ,
Expand Down
3 changes: 3 additions & 0 deletions frontend-new/src/envService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export enum EnvVariables {
FRONTEND_GTM_CONTAINER_ID = "FRONTEND_GTM_CONTAINER_ID",
FRONTEND_GTM_ENABLED = "FRONTEND_GTM_ENABLED",
FRONTEND_FAQ_TUTORIAL_VIDEO_URL = "FRONTEND_FAQ_TUTORIAL_VIDEO_URL",
FRONTEND_LEGAL_DOCUMENTS = "FRONTEND_LEGAL_DOCUMENTS",
FRONTEND_ILLUSTRATIONS = "FRONTEND_ILLUSTRATIONS",
}

Expand Down Expand Up @@ -238,6 +239,8 @@ export const getGtmEnabled = () => getEnv(EnvVariables.FRONTEND_GTM_ENABLED);

export const getFaqTutorialVideoUrl = () => getEnv(EnvVariables.FRONTEND_FAQ_TUTORIAL_VIDEO_URL);

export const getLegalDocumentsEnvVar = () => getEnv(EnvVariables.FRONTEND_LEGAL_DOCUMENTS);

export const getLogoUrl = () => getEnv(EnvVariables.FRONTEND_LOGO_URL);

export const DEFAULT_MINISTRY_URL = "/ministry-tech.png";
Expand Down
3 changes: 3 additions & 0 deletions frontend-new/src/i18n/locales/en-GB/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1320,5 +1320,8 @@
"tutorialIframeTitle": "{{appName}} tutorial video",
"tutorialUnavailableOffline": "You're offline. Connect to the internet to watch this video.",
"scrollProgressLabel": "Reading progress"
},
"legal": {
"documentUnavailable": "This document is currently unavailable. Please try again."
}
}
3 changes: 3 additions & 0 deletions frontend-new/src/i18n/locales/en-US/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1320,5 +1320,8 @@
"tutorialIframeTitle": "{{appName}} tutorial video",
"tutorialUnavailableOffline": "You're offline. Connect to the internet to watch this video.",
"scrollProgressLabel": "Reading progress"
},
"legal": {
"documentUnavailable": "This document is currently unavailable. Please try again."
}
}
3 changes: 3 additions & 0 deletions frontend-new/src/i18n/locales/es-AR/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1320,5 +1320,8 @@
"tutorialIframeTitle": "Video tutorial de {{appName}}",
"tutorialUnavailableOffline": "Estás sin conexión. Conectate a internet para ver este video.",
"scrollProgressLabel": "Progreso de lectura"
},
"legal": {
"documentUnavailable": "Este documento no está disponible en este momento. Por favor, intentá de nuevo."
}
}
3 changes: 3 additions & 0 deletions frontend-new/src/i18n/locales/es-ES/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1320,5 +1320,8 @@
"tutorialIframeTitle": "Vídeo tutorial de {{appName}}",
"tutorialUnavailableOffline": "Estás sin conexión. Conéctate a internet para ver este video.",
"scrollProgressLabel": "Progreso de lectura"
},
"legal": {
"documentUnavailable": "Este documento no está disponible en este momento. Por favor, inténtalo de nuevo."
}
}
3 changes: 3 additions & 0 deletions frontend-new/src/i18n/locales/ny-ZM/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1320,5 +1320,8 @@
"tutorialIframeTitle": "Vidiyo yophunzitsira za {{appName}}",
"tutorialUnavailableOffline": "Simuli pa intaneti. Lumikizani pa intaneti kuti muone video iyi.",
"scrollProgressLabel": "Kupita patsogolo powerenga"
},
"legal": {
"documentUnavailable": "Chikalata ichi sichipezeka pakali pano. Chonde yesaninso."
}
}
3 changes: 3 additions & 0 deletions frontend-new/src/i18n/locales/pt-MZ/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1320,5 +1320,8 @@
"tutorialIframeTitle": "Vídeo tutorial do {{appName}}",
"tutorialUnavailableOffline": "Está offline. Ligue-se à internet para ver este vídeo.",
"scrollProgressLabel": "Progresso da leitura"
},
"legal": {
"documentUnavailable": "Este documento não está disponível neste momento. Por favor, tente novamente."
}
}
3 changes: 3 additions & 0 deletions frontend-new/src/i18n/locales/sw-KE/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1320,5 +1320,8 @@
"tutorialIframeTitle": "Video ya mafunzo ya {{appName}}",
"tutorialUnavailableOffline": "Hauko mtandaoni. Unganisha kwenye mtandao ili kutazama video hii.",
"scrollProgressLabel": "Maendeleo ya kusoma"
},
"legal": {
"documentUnavailable": "Hati hii haipatikani kwa sasa. Tafadhali jaribu tena."
}
}
66 changes: 66 additions & 0 deletions frontend-new/src/legal/legalDocumentLoader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// mute chatty console
import "src/_test_utilities/consoleMock";

import { getLegalDocument } from "src/legal/legalDocumentLoader";
import * as legalDocumentsConfig from "src/legal/legalDocumentsConfig";
import { setupFetchSpy } from "src/_test_utilities/fetchSpy";

describe("getLegalDocument", () => {
afterEach(() => {
jest.clearAllMocks();
});

test("returns title from frontmatter and trimmed markdown body on a successful fetch", async () => {
// GIVEN a configured URL and a successful fetch returning markdown with a frontmatter title
jest
.spyOn(legalDocumentsConfig, "getLegalDocumentsConfig")
.mockReturnValue({ termsOfUse: "/legal/terms-of-use.md" });
const markdown = '---\ntitle: "Custom Terms"\n---\n\n# Body';
const fetchSpy = setupFetchSpy(200, markdown, "");

// WHEN getLegalDocument is called for that variant
const result = await getLegalDocument("termsOfUse");

// THEN the fetch is made against the configured URL and the parsed document is returned
expect(fetchSpy).toHaveBeenCalledWith("/legal/terms-of-use.md");
expect(result.title).toBe("Custom Terms");
expect(result.markdown).toBe("# Body");
});

test("falls back to a per-variant default title when the markdown has no frontmatter", async () => {
// GIVEN a configured URL and markdown without frontmatter
jest
.spyOn(legalDocumentsConfig, "getLegalDocumentsConfig")
.mockReturnValue({ privacyPolicy: "/legal/privacy-policy.md" });
const markdown = "# Privacy body\n\nSome text.";
setupFetchSpy(200, markdown, "");

// WHEN getLegalDocument is called
const result = await getLegalDocument("privacyPolicy");

// THEN the default per-variant title is used
expect(result.title).toBe("Privacy Policy");
expect(result.markdown).toContain("# Privacy body");
});

test("throws when no URL is configured for the requested variant", async () => {
// GIVEN no configured URL for the variant
jest.spyOn(legalDocumentsConfig, "getLegalDocumentsConfig").mockReturnValue({});

// WHEN getLegalDocument is called THEN it rejects
await expect(getLegalDocument("termsOfUse")).rejects.toThrow(
/No URL configured for legal document variant: termsOfUse/
);
});

test("throws when the fetch responds with a non-OK status", async () => {
// GIVEN a configured URL and a fetch returning 404
jest
.spyOn(legalDocumentsConfig, "getLegalDocumentsConfig")
.mockReturnValue({ termsOfUse: "https://partner.example/terms.md" });
setupFetchSpy(404, "not found", "");

// WHEN getLegalDocument is called THEN it rejects with status detail
await expect(getLegalDocument("termsOfUse")).rejects.toThrow(/404/);
});
});
35 changes: 23 additions & 12 deletions frontend-new/src/legal/legalDocumentLoader.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,36 @@
import { parseYamlFrontmatter } from "src/utils/parseYamlFrontmatter";
import { getLegalDocumentsConfig, LegalDocumentVariant } from "src/legal/legalDocumentsConfig";

import privacyPolicyMd from "!!raw-loader!./documents/privacy-policy.md";
import termsOfUseMd from "!!raw-loader!./documents/terms-of-use.md";

export type LegalDocumentVariant = "privacy" | "terms";

const documentRegistry: Record<LegalDocumentVariant, string> = {
privacy: privacyPolicyMd,
terms: termsOfUseMd,
};
export type { LegalDocumentVariant };

export interface LegalDocument {
title: string;
markdown: string;
}

export const getLegalDocument = (variant: LegalDocumentVariant): LegalDocument => {
const raw = documentRegistry[variant];
const DEFAULT_TITLES: Record<LegalDocumentVariant, string> = {
termsOfUse: "Terms of Use",
privacyPolicy: "Privacy Policy",
};

export const getLegalDocument = async (variant: LegalDocumentVariant): Promise<LegalDocument> => {
const url = getLegalDocumentsConfig()[variant];
if (!url) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If thre is no URL, I think it might be a good idea to return the default values instead of throwing an error.

for backward config compatibility.

"documents": {
      "termsOfUse": "/legal/terms-of-use.md",
      "privacyPolicy": "/legal/privacy-policy.md"
    }

What do you think?

throw new Error(`No URL configured for legal document variant: ${variant}`);
}

const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load ${variant} from ${url}: ${response.status}`);
}

const raw = await response.text();
const { data, content } = parseYamlFrontmatter(raw);
// parseYamlFrontmatter returns "Untitled" when no frontmatter title is present;
// fall back to a sensible per-variant default in that case.
const hasExplicitTitle = typeof data.title === "string" && data.title.length > 0 && data.title !== "Untitled";
return {
title: data.title,
title: hasExplicitTitle ? data.title : DEFAULT_TITLES[variant],
markdown: content.trimStart(),
};
};
Loading
Loading