# Policy Pages — Mobile Integration Guide

Six documents — About, Help centre, Safety centre, Community guidelines, Terms of
use, Privacy policy — come from the API as **versioned markdown**. You build
**one** screen and **one** renderer; the six pages are that screen with a
different `slug`.

There is no per-page layout to write, no section-by-section styling, and no copy
in the app bundle. When legal text changes, the server changes — you ship
nothing.

## What's new

<!-- Newest first, plain language, max 5 entries. Every session that changes this
     guide adds one line here and drops the oldest past 5 — see CLAUDE.md rule 8. -->

- **2026-09-10** — Consent to the Terms and Privacy Policy is now **recorded automatically at signup** — you send nothing for it, and there is still no `POST /accept` to call. Keep the signup consent line as plain text with links; do not add a checkbox. Separately, signup now rejects anyone **under 13** with a 422 — handle that on the date-of-birth field. §8 rewritten.
- **2026-09-10** — First version. The six policy pages are now served as markdown from
  `GET /api/policies` and `GET /api/policies/:slug` — no auth needed, so you can link
  them from the login screen. Build one markdown screen, not six layouts, and do **not**
  hardcode the text in the app: it changes server-side without a release. §4 has a
  ready-to-paste renderer, §5 lists every markdown element the content actually uses,
  and §6 covers the Effective/Last-updated card, which is **metadata, not markdown** —
  you render it yourself above the body.

---

## Short version

```
GET /api/policies         → [{ slug, title, version, effectiveDate, lastUpdated, order }]
GET /api/policies/:slug   → { slug, title, version, effectiveDate, lastUpdated, order, markdown }
```

- **No token.** Both are public — the Terms have to be readable before signup.
- The list is **already sorted** by `order`. Render it in the order you receive it.
- `markdown` is CommonMark + GFM tables. Render it; never parse it yourself.
- The date card at the top of Terms/Privacy is **not** in the markdown — build it
  from `effectiveDate` / `lastUpdated` (§6).
- `version` is an integer bumped by hand on meaning changes. Cache against it (§3).

---

## 1 — The endpoints

### `GET /api/policies`

Metadata only, no bodies — a few hundred bytes. This is what the Settings screen
lists.

```json
{
  "success": true,
  "data": [
    { "slug": "about", "title": "About Clustr", "version": 1, "effectiveDate": null, "lastUpdated": null, "order": 1 },
    { "slug": "help-centre", "title": "Help centre", "version": 1, "effectiveDate": null, "lastUpdated": null, "order": 2 },
    { "slug": "safety-centre", "title": "Safety centre", "version": 1, "effectiveDate": null, "lastUpdated": null, "order": 3 },
    { "slug": "community-guidelines", "title": "Community guidelines", "version": 1, "effectiveDate": null, "lastUpdated": null, "order": 4 },
    { "slug": "terms", "title": "Terms of use", "version": 1, "effectiveDate": "2026-10-05", "lastUpdated": "2026-10-05", "order": 5 },
    { "slug": "privacy", "title": "Privacy policy", "version": 1, "effectiveDate": "2026-10-05", "lastUpdated": "2026-10-05", "order": 6 }
  ]
}
```

**Don't hardcode this list.** It arrives sorted; a seventh document is a server
change, and if you hardcoded six rows the app won't show it.

### `GET /api/policies/:slug`

The same object plus `markdown`.

```json
{
  "success": true,
  "data": {
    "slug": "privacy",
    "title": "Privacy policy",
    "version": 1,
    "effectiveDate": "2026-10-05",
    "lastUpdated": "2026-10-05",
    "order": 6,
    "markdown": "This explains what Clustr collects, why, and what rights you have.\n\n---\n\n## What we collect\n…"
  }
}
```

Unknown slug → **404** `POLICY_NOT_FOUND`.

### Field reference

| Field | Type | Notes |
| --- | --- | --- |
| `slug` | string | URL id and stable key. Use it for navigation and caching. |
| `title` | string | Screen title — put it in the nav bar. Don't hardcode titles. |
| `version` | integer | Bumped by hand when the **meaning** changes. Typo fixes do not move it. |
| `effectiveDate` | string \| null | `YYYY-MM-DD`. Non-null on `terms` and `privacy` only. |
| `lastUpdated` | string \| null | `YYYY-MM-DD`. Same. |
| `order` | integer | Display order. The list is pre-sorted; the field is there so you can re-sort if you ever merge sources. |
| `markdown` | string | Body. Present on the detail response only. |

---

## 2 — Auth and headers

Neither endpoint takes a token. Sending one is harmless, but **don't require a
session** — these screens are reachable from the login screen and from the
signup consent line ("By signing up you agree to our Terms…").

Both responses carry `Cache-Control: public, max-age=3600`.

---

## 3 — Caching, and what `version` is for

The bodies are small but they are static for months at a time. The pattern:

1. Fetch `GET /api/policies` on app start (or on entering Settings). It's cheap.
2. Store each body you download keyed by `slug`, together with the `version` you
   downloaded it at.
3. Opening a document: if your cached `version` for that slug **equals** the
   `version` in the list, render the cached body. Otherwise fetch it again.

`version` moves only on a **material** change — new clause, changed retention
period, new contact. A comma or a typo fix deliberately leaves it alone, so this
number is safe to build "the user must re-read this" logic on later.

Show the cached copy immediately and refresh behind it. A policy screen that
spinners on a cold network is worse than a policy screen that is one revision
stale for two seconds.

---

## 4 — The renderer

One component, used by all six screens.

### Dependency

Markdown-to-React-Native is a solved problem — don't hand-roll a parser.

- **`react-native-markdown-display`** — style-object API, the least code. The
  snippet below targets it. The original package has been quiet for a while;
  `@ronradtke/react-native-markdown-display` is the commonly used fork with the
  same API. **Check which one is currently maintained before you install** —
  either way the code below is unchanged apart from the import.
- **`react-native-marked`** — the alternative if you'd rather supply per-node
  renderers than a style sheet.

```bash
npm install react-native-markdown-display
# or: npm install @ronradtke/react-native-markdown-display
```

### `PolicyScreen.tsx`

```tsx
import React, { useCallback, useEffect, useState } from 'react';
import {
  ActivityIndicator,
  Linking,
  ScrollView,
  StyleSheet,
  Text,
  View,
} from 'react-native';
import Markdown from 'react-native-markdown-display';

const API_BASE = 'https://api.letsclustr.com'; // your existing config value

interface Policy {
  slug: string;
  title: string;
  version: number;
  effectiveDate: string | null;
  lastUpdated: string | null;
  order: number;
  markdown: string;
}

/** "2026-10-05" → "5 October 2026" (matches the design). */
function formatDate(iso: string): string {
  const [y, m, d] = iso.split('-').map(Number);
  return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString('en-GB', {
    day: 'numeric',
    month: 'long',
    year: 'numeric',
    timeZone: 'UTC',
  });
}

export function PolicyScreen({ slug }: { slug: string }) {
  const [policy, setPolicy] = useState<Policy | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;
    fetch(`${API_BASE}/api/policies/${slug}`)
      .then((r) => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then((json) => {
        if (!cancelled) setPolicy(json.data);
      })
      .catch(() => {
        if (!cancelled) setError("We couldn't load this page. Check your connection.");
      });
    return () => {
      cancelled = true;
    };
  }, [slug]);

  // mailto: and https: both go to the OS. Return false so the library does not
  // also try to handle the press.
  const onLinkPress = useCallback((url: string) => {
    Linking.openURL(url).catch(() => {});
    return false;
  }, []);

  if (error) return <Text style={styles.error}>{error}</Text>;
  if (!policy) return <ActivityIndicator style={styles.loading} />;

  return (
    <ScrollView
      style={styles.screen}
      contentContainerStyle={styles.content}
      showsVerticalScrollIndicator={false}
    >
      {/* Metadata card — NOT part of the markdown. See §6. */}
      {policy.effectiveDate && (
        <View style={styles.dateCard}>
          <Text style={styles.dateLine}>
            <Text style={styles.dateLabel}>Effective Date: </Text>
            {formatDate(policy.effectiveDate)}
          </Text>
          {policy.lastUpdated && (
            <Text style={styles.dateLine}>
              <Text style={styles.dateLabel}>Last Updated: </Text>
              {formatDate(policy.lastUpdated)}
            </Text>
          )}
        </View>
      )}

      <Markdown style={markdownStyles} onLinkPress={onLinkPress}>
        {policy.markdown}
      </Markdown>
    </ScrollView>
  );
}
```

### The style map

Every key below corresponds to an element the content actually contains — see
§5. Swap the raw values for your design tokens; the shape is what matters.

```ts
const INK = '#111111';
const MUTED = '#6B7280';
const RULE = '#E5E7EB';
const CARD = '#FFFFFF';
const LINK = '#2563EB';
const SURFACE = '#F5F6F8';

const markdownStyles = StyleSheet.create({
  body: { color: INK, fontSize: 17, lineHeight: 26 },

  // Content starts at h2 — the page title comes from `policy.title` in the nav
  // bar, so there is no h1 in any document.
  heading2: { fontSize: 26, fontWeight: '400', color: INK, marginTop: 32, marginBottom: 12 },
  heading3: { fontSize: 18, fontWeight: '700', color: INK, marginTop: 24, marginBottom: 8 },

  paragraph: { marginTop: 0, marginBottom: 16 },
  strong: { fontWeight: '700' },
  em: { fontStyle: 'italic' },
  link: { color: LINK, textDecorationLine: 'underline' },

  // `---` between sections. The design draws a hairline with generous air.
  hr: { backgroundColor: RULE, height: 1, marginVertical: 28 },

  // Blockquote === the design's callout card. See §5.
  blockquote: {
    backgroundColor: CARD,
    borderRadius: 16,
    paddingVertical: 8,
    paddingHorizontal: 16,
    marginVertical: 8,
    borderLeftWidth: 0, // kill the library's default quote bar
  },

  bullet_list: { marginBottom: 16 },
  ordered_list: { marginBottom: 16 },
  list_item: { flexDirection: 'row', marginBottom: 8 },
  bullet_list_icon: { marginLeft: 4, marginRight: 10, lineHeight: 26 },
  ordered_list_icon: { marginLeft: 4, marginRight: 10, lineHeight: 26 },

  // GFM table — `safety-centre` only.
  table: { borderWidth: 1, borderColor: RULE, borderRadius: 12, overflow: 'hidden', marginBottom: 16 },
  thead: { backgroundColor: SURFACE },
  th: { padding: 12, fontWeight: '700', color: INK },
  tr: { borderBottomWidth: 1, borderColor: RULE },
  td: { padding: 12, color: INK },
});

const styles = StyleSheet.create({
  screen: { flex: 1, backgroundColor: SURFACE },
  content: { paddingHorizontal: 20, paddingBottom: 48 },
  loading: { marginTop: 64 },
  error: { margin: 20, color: MUTED, fontSize: 16, lineHeight: 24 },
  dateCard: {
    backgroundColor: CARD,
    borderRadius: 16,
    padding: 16,
    marginTop: 16,
    marginBottom: 8,
  },
  dateLine: { fontSize: 17, lineHeight: 26, color: INK },
  dateLabel: { fontWeight: '700' },
});
```

---

## 5 — What the markdown actually contains

The complete list. If your renderer handles these, it handles all six documents
and every future one — new content will not introduce elements outside this set
without a note in **What's new**.

| Element | Markdown | Where |
| --- | --- | --- |
| Section heading | `## Heading` | all six |
| Sub-heading | `### Heading` | `community-guidelines` |
| Paragraph | plain text | all six |
| Bold | `**text**` | all six — most bullets are `**Lead-in** - explanation` |
| Bullet list | `- item` | all six |
| Numbered list | `1. item` | `help-centre`, `safety-centre` |
| Link | `[label](mailto:support@clustr.app)` | all six. **`mailto:` only today** — treat `https:` as possible and route both through `Linking.openURL`. |
| Divider | `---` | all six, between sections |
| Callout card | `> …` (blockquote) | `community-guidelines`, `safety-centre`, `help-centre` |
| Table | GFM pipe table | `safety-centre` only — the helplines table |

**Not used, don't build for:** images, code blocks, inline code, nested lists,
h1, h4+, task lists, footnotes.

### The blockquote convention

The design draws certain blocks as a white rounded card on the grey page — the
"quick version" rules, the two urgent-report situations, the four post kinds,
the "we read everything" note. Markdown has no card primitive, so **a blockquote
means a callout card**. Style `blockquote` as the card and remove the default
left quote bar; there is no actual quotation anywhere in the six documents.

### The one table

`safety-centre` ends with a three-column helplines table. Give the table
horizontal scroll on narrow screens or let the columns wrap — the third column
holds phone numbers that must not truncate.

---

## 6 — Assembling the screen

```
┌─────────────────────────────────────┐
│ ‹  Privacy policy                   │  ← nav bar, from `policy.title`
├─────────────────────────────────────┤
│ ┌─────────────────────────────────┐ │
│ │ Effective Date: 5 October 2026  │ │  ← YOU render this from
│ │ Last Updated:   5 October 2026  │ │    effectiveDate / lastUpdated
│ └─────────────────────────────────┘ │
│                                     │
│ This explains what Clustr collects… │  ← <Markdown>{policy.markdown}</Markdown>
│ ─────────────────────────────────── │
│ What we collect                     │
│ …                                   │
└─────────────────────────────────────┘
```

Three rules:

1. **Title comes from the API**, not from a constant in your navigator. It is
   the only place the wording lives.
2. **The date card is metadata, not markdown.** It is deliberately not in the
   body: dates are structured fields you format for the locale, and only `terms`
   and `privacy` have them. Render the card only when `effectiveDate` is
   non-null.
3. **Everything else is the markdown component.** Resist adding per-slug
   conditionals — the moment one screen special-cases `privacy`, the "one
   renderer" property is gone and the next document needs code again.

Settings list — same data, no bodies:

```tsx
const { data } = await fetch(`${API_BASE}/api/policies`).then((r) => r.json());
// data is pre-sorted; render straight through
data.map((p) => <Row key={p.slug} title={p.title} onPress={() => open(p.slug)} />);
```

---

## 7 — Errors

| Case | Response | What to show |
| --- | --- | --- |
| Unknown slug | `404` `POLICY_NOT_FOUND` | Shouldn't happen if you navigate from the list. Go back and refresh the list. |
| Malformed slug | `400` (schema) | Same — you built the URL wrong. Slugs are lowercase letters, digits and hyphens. |
| Offline | — | Cached body if you have one (§3), otherwise a retry message. Never a blank screen. |

Because these screens are reachable while logged out, don't route their failures
through any interceptor that redirects to login on error.

---

## 8 — Consent, and what's still missing

**Consent is recorded for you.** As of 2026-09-10 the server writes a consent
record at signup — one per document, stamped with the version that was current
at that moment. Both signup paths do it (`POST /api/auth/register` and
`POST /api/onboarding/complete`).

**You send nothing.** No new field, no checkbox value, no version number. The
version is taken from the server's own copy of the documents; a request that
tries to supply one is ignored. There is no `POST /accept` to call — signing up
*is* the act of accepting.

Build the signup consent line as plain text with links:

> By continuing you agree to our [Terms of use](#) and [Privacy policy](#)

Link both through the same `PolicyScreen` you already have. **Don't build a
consent checkbox** — the line is the disclosure, and a checkbox would imply a
separate acceptance step that doesn't exist.

**Also new, and it does affect you:** signup now rejects anyone **under 13**
with a 422. Handle it on the date-of-birth field. See the
[Auth](../auth/mobile-auth-integration.md) and
[Onboarding](../user-onboarding/mobile-onboarding-guide.md) guides.

### Still not built

- **Re-consent when a policy changes.** No "you must accept the new Terms" gate.
  The Terms promise 30 days' notice of a material change, not re-acceptance.
- **An annual reminder**, which the Terms do promise.
- **Verified parental consent** (DPDP, 2027). When it lands it will be a real
  endpoint — a guardian is a different person from the account holder, so it
  cannot ride on the user's signup.

None of these change the two endpoints in this guide.

---

## 9 — Quick reference

```
GET /api/policies                         # list, no auth, pre-sorted, no bodies
GET /api/policies/about                   # About Clustr
GET /api/policies/help-centre             # Help centre
GET /api/policies/safety-centre           # Safety centre        (has the one table)
GET /api/policies/community-guidelines    # Community guidelines (has h3 sub-headings)
GET /api/policies/terms                   # Terms of use         (has dates)
GET /api/policies/privacy                 # Privacy policy       (has dates)
```

Swagger: `/api/docs` → **Policies**.
