@distinctagency/cms-client 1.17.0 → 1.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,792 @@
1
+ # Connecting a Website to Distinct CMS
2
+
3
+ > **The canonical, copy-paste integration guide lives in the CMS admin** — open it at **Tenants → \<your tenant\> → Developer**. It bakes in your tenant's API keys, real collection schemas, and every section below in one paste-able document. Drop it straight into a Claude Code session and the agent has everything it needs to build the site.
4
+ >
5
+ > This file is the **public, npm-shipped reference** with the same content (minus tenant-specific values). It's what shows up if you `npm view @distinctagency/cms-client` or browse the GitHub repo.
6
+
7
+ This guide explains how to connect a Next.js website to Distinct CMS to read content (events, blog posts, etc.) managed through the admin dashboard.
8
+
9
+ > **Quick links**
10
+ > - [How sync works](#how-sync-works) — mental model
11
+ > - [Webhooks + on-demand ISR](#9-revalidation-isr) — instant cache invalidation
12
+ > - [Cache tags reference](#available-events-and-their-cache-tags)
13
+ > - [Tagging your reads](#tagging-your-reads)
14
+
15
+ ---
16
+
17
+ ## How sync works
18
+
19
+ Three-stage pipeline:
20
+
21
+ ```
22
+ 1. Edit 2. Notify 3. Invalidate
23
+ ┌──────────┐ ┌──────────────────────┐ ┌────────────────────┐
24
+ │ Editor │ → │ CMS fires webhook │ → │ Tenant /api/ │
25
+ │ saves in │ │ POST /your/endpoint │ │ revalidate calls │
26
+ │ admin UI │ │ X-CMS-Event: <name> │ │ revalidateTag(...) │
27
+ └──────────┘ │ X-CMS-Signature: hex │ │ on cache_tags │
28
+ │ body: { │ └────────┬───────────┘
29
+ │ event, │ │
30
+ │ cache_tags: [...], │ ▼
31
+ │ resource_id, ... │ ┌────────────────────┐
32
+ │ } │ │ Next.js refetches │
33
+ └──────────────────────┘ │ on next request │
34
+ └────────────────────┘
35
+ ```
36
+
37
+ **Three commitments to make this work:**
38
+
39
+ 1. **Subscribe.** Configure a webhook in **Tenants → Webhooks** pointing at your site's `/api/revalidate` route. Pick events or use `*`.
40
+ 2. **Receive.** In your route, verify the HMAC signature with `verifyWebhookSignature()` and fan out cache invalidation with `revalidateAllTags()` — the SDK exports both.
41
+ 3. **Tag your reads.** Pass `{ next: { tags: [...] } }` to your fetches using the `cms:*` namespace so the receiver's `revalidateTag()` calls actually hit something. See [Tagging your reads](#tagging-your-reads).
42
+
43
+ Without (3), the webhook fires but nothing is cached under those tags so nothing changes.
44
+
45
+ The `getTrackingConfig()` SDK method is already tagged with `cms:tracking-config` for you. Other reads need explicit tagging.
46
+
47
+ ---
48
+
49
+ ## Prerequisites
50
+
51
+ You need three values from the Distinct CMS team:
52
+
53
+ | Variable | Description | Where to find |
54
+ |---|---|---|
55
+ | `NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL | `https://cms-edge.distinctstudio.co.nz` |
56
+ | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase public anon key | Provided by Distinct |
57
+ | `CMS_API_KEY` | Tenant-specific API key (UUID) | Provided by Distinct (from the CMS admin Tenants page) |
58
+
59
+ > **Important:** `CMS_API_KEY` is a secret. It should be in `.env.local` (not committed) and only used in server-side code (Server Components, API routes, `getStaticProps`). Never expose it to the browser.
60
+
61
+ ---
62
+
63
+ ## 1. Install dependencies
64
+
65
+ ```bash
66
+ # Install the CMS client package and Supabase
67
+ pnpm add @distinctagency/cms-client @supabase/supabase-js
68
+ ```
69
+
70
+ ---
71
+
72
+ ## 2. Set environment variables
73
+
74
+ Add to `.env.local`:
75
+
76
+ ```env
77
+ NEXT_PUBLIC_SUPABASE_URL=https://cms-edge.distinctstudio.co.nz
78
+ NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key-from-distinct>
79
+ CMS_API_KEY=<your-tenant-api-key>
80
+ ```
81
+
82
+ ---
83
+
84
+ ## 3. Create the CMS client
85
+
86
+ Create `src/lib/cms.ts`:
87
+
88
+ ```ts
89
+ import { createClient } from "@supabase/supabase-js"
90
+ import { createCmsClient } from "@distinctagency/cms-client"
91
+
92
+ const supabase = createClient(
93
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
94
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
95
+ )
96
+
97
+ export const cms = createCmsClient(supabase, {
98
+ apiKey: process.env.CMS_API_KEY!,
99
+ })
100
+ ```
101
+
102
+ > This file should only be imported in server-side code. The `CMS_API_KEY` env var has no `NEXT_PUBLIC_` prefix, so it is not available in the browser.
103
+
104
+ ---
105
+
106
+ ## 4. Fetch content in pages
107
+
108
+ ### List items (e.g. all published events)
109
+
110
+ ```tsx
111
+ // src/app/events/page.tsx
112
+ import { cms } from "@/lib/cms"
113
+ import type { ContentItem } from "@distinctagency/cms-client"
114
+
115
+ export default async function EventsPage() {
116
+ const events = await cms.getContentItems("events", {
117
+ status: "published",
118
+ orderBy: "published_at",
119
+ orderDirection: "desc",
120
+ })
121
+
122
+ return (
123
+ <div>
124
+ <h1>Events</h1>
125
+ {events.map((event) => (
126
+ <div key={event.id}>
127
+ <h2>{event.title}</h2>
128
+ <p>{event.excerpt}</p>
129
+ {/* Custom fields are in event.data */}
130
+ <p>Date: {event.data.date as string}</p>
131
+ <p>Location: {event.data.location as string}</p>
132
+ </div>
133
+ ))}
134
+ </div>
135
+ )
136
+ }
137
+ ```
138
+
139
+ ### Single item by slug
140
+
141
+ ```tsx
142
+ // src/app/events/[slug]/page.tsx
143
+ import { cms } from "@/lib/cms"
144
+ import { notFound } from "next/navigation"
145
+
146
+ interface Props {
147
+ params: Promise<{ slug: string }>
148
+ }
149
+
150
+ export default async function EventPage({ params }: Props) {
151
+ const { slug } = await params
152
+ const event = await cms.getContentItemBySlug("events", slug)
153
+
154
+ if (!event) notFound()
155
+
156
+ return (
157
+ <div>
158
+ <h1>{event.title}</h1>
159
+ <p>{event.excerpt}</p>
160
+ <div>{event.data.body as string}</div>
161
+ </div>
162
+ )
163
+ }
164
+
165
+ // Generate static paths for all published events
166
+ export async function generateStaticParams() {
167
+ const slugs = await cms.getAllSlugs("events")
168
+ return slugs
169
+ }
170
+ ```
171
+
172
+ ---
173
+
174
+ ## 5. Available API methods
175
+
176
+ The `cms` client exposes these methods:
177
+
178
+ | Method | Description |
179
+ |---|---|
180
+ | `cms.getContentItems(collectionSlug, options?)` | List items. Options: `status`, `orderBy`, `orderDirection`, `limit`, `offset` |
181
+ | `cms.getContentItemBySlug(collectionSlug, itemSlug)` | Get one item by slug. Returns `null` if not found |
182
+ | `cms.getContentType(collectionSlug)` | Get the collection definition including its field schema |
183
+ | `cms.getContentTypes()` | List all collections for this tenant |
184
+ | `cms.getAllSlugs(collectionSlug)` | Get all published slugs (for `generateStaticParams`) |
185
+ | `cms.getRedirects(collectionSlug)` | Get slug redirects for 301s. Returns `[{ old_slug, new_slug }]` |
186
+ | `cms.getCustomRedirects()` | Get custom path redirects. Returns `[{ source_path, destination_path, permanent }]` |
187
+ | `cms.getReviews(options?)` | Get Google Reviews. Options: `status`, `minRating`, `orderBy`, `orderDirection`, `limit`, `offset` |
188
+ | `getEmbedHtml(embedValue)` | Generate responsive iframe HTML for an embed field. Returns empty string if invalid |
189
+
190
+ ---
191
+
192
+ ## 6. Content item shape
193
+
194
+ Every content item has these standard fields:
195
+
196
+ ```ts
197
+ {
198
+ id: string
199
+ title: string
200
+ slug: string
201
+ status: "draft" | "published" | "archived"
202
+ published_at: string | null
203
+ excerpt: string | null
204
+ seo_title: string | null
205
+ seo_description: string | null
206
+ og_image: string | null
207
+ featured_image: string | null
208
+ sort_order: number
209
+ view_count: number // analytics: total page views
210
+ created_at: string
211
+ updated_at: string
212
+
213
+ // Collection-specific fields live here:
214
+ data: {
215
+ date: "2026-06-15",
216
+ location: "Business Mastery Office, Christchurch",
217
+ event_type: "Growth Intensive",
218
+ body: "Full description text...",
219
+ // ... whatever fields are defined in the collection schema
220
+ }
221
+ }
222
+ ```
223
+
224
+ The `data` field is typed as `Record<string, unknown>`. You can inspect the collection schema at runtime via `cms.getContentType("events")` to see what fields are available, or check the CMS admin under Collections.
225
+
226
+ ---
227
+
228
+ ## 7. Reference fields
229
+
230
+ Some collections have reference fields that point to items in other collections. For example, a Blog Post might have an `author` field that references the Authors collection.
231
+
232
+ The value stored in `data.author` is the **ID** of the referenced content item. To resolve it:
233
+
234
+ ```ts
235
+ const post = await cms.getContentItemBySlug("blog_posts", "my-post")
236
+ const authorId = post?.data.author as string
237
+
238
+ // Fetch the referenced author
239
+ if (authorId) {
240
+ const authors = await cms.getContentItems("authors")
241
+ const author = authors.find((a) => a.id === authorId)
242
+ }
243
+ ```
244
+
245
+ ---
246
+
247
+ ## 8. Slug Redirects (301s)
248
+
249
+ When content slugs are changed in the CMS, a redirect is created automatically. Wire these into your `next.config.ts` to serve 301 redirects and preserve SEO:
250
+
251
+ ```ts
252
+ // next.config.ts
253
+ import { createClient } from "@supabase/supabase-js"
254
+ import { createCmsClient } from "@distinctagency/cms-client"
255
+
256
+ const cms = createCmsClient(
257
+ createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
258
+ { apiKey: process.env.CMS_API_KEY! }
259
+ )
260
+
261
+ export default {
262
+ async redirects() {
263
+ // Slug redirects (auto-created when CMS slugs change)
264
+ const eventRedirects = await cms.getRedirects("events")
265
+ const slugRedirects = eventRedirects.map((r) => ({
266
+ source: `/events/${r.old_slug}`,
267
+ destination: `/events/${r.new_slug}`,
268
+ permanent: true,
269
+ }))
270
+
271
+ // Custom redirects (manually added for site redesigns, legacy URLs)
272
+ const customRedirects = await cms.getCustomRedirects()
273
+ const customMapped = customRedirects.map((r) => ({
274
+ source: r.source_path,
275
+ destination: r.destination_path,
276
+ permanent: r.permanent,
277
+ }))
278
+
279
+ return [...slugRedirects, ...customMapped]
280
+ },
281
+ }
282
+ ```
283
+
284
+ ---
285
+
286
+ ## Tagging your reads
287
+
288
+ For on-demand revalidation to work, the data fetches in your site need to be tagged with the same `cms:*` strings the webhook payload carries. Wrap your CMS reads in tagged `fetch` calls (or pass `{ next: { tags } }` directly to the SDK methods that accept it).
289
+
290
+ | What you're rendering | Tag your fetch with | Webhook event(s) that invalidate it |
291
+ |----------------------------------|---------------------------------------------------|------------------------------------------------------------------|
292
+ | Content list (e.g. all events) | `cms:content-type:events` | `content.published`, `content.unpublished`, `content.updated`, `content.deleted`, `content_type.updated` |
293
+ | Single content item | `cms:content:events:<slug>` | `content.*` for that slug |
294
+ | Collection schema / SEO config | `cms:content-type:<slug>:schema` | `content_type.updated` |
295
+ | Tracking IDs (`getTrackingConfig`) | `cms:tracking-config` *(SDK does this for you)* | `settings.tracking_updated` |
296
+ | Brand (logo / colours) | `cms:brand` | `settings.brand_updated` |
297
+ | Integration config (Stripe, Resend, etc.) | `cms:integration:<provider>` | `settings.integration_updated` |
298
+ | Product list | `cms:products` | `products.updated` |
299
+ | Single product | `cms:product:<slug>` | `products.updated` (when slug-specific) |
300
+ | Product categories | `cms:product-categories` | `product_categories.updated` |
301
+ | Ticket tiers for an event | `cms:ticket-tiers`, `cms:event:<event-id>` | `ticket_tiers.updated` |
302
+ | Membership tiers | `cms:membership-tiers` | `membership_tiers.updated` |
303
+ | Redirects (if served at runtime) | `cms:redirects` | `redirects.updated` |
304
+ | Reviews (`getReviews`) | `cms:reviews` | `reviews.synced` |
305
+ | Flipbooks | `cms:flipbooks`, `cms:flipbook:<id>` | `flipbook.ready` |
306
+ | Motor Central vehicles | `cms:content-type:<vehicles-collection>`, `cms:integration:motor-central` | `content.updated` after each sync |
307
+
308
+ ### Example — tag a content list
309
+
310
+ ```ts
311
+ // Inside a Server Component
312
+ const events = await fetch(
313
+ `${process.env.NEXT_PUBLIC_CMS_URL}/rest/v1/content_items?...`,
314
+ {
315
+ headers: { /* anon + api key */ },
316
+ next: { tags: ["cms:content-type:events"], revalidate: 3600 },
317
+ }
318
+ ).then((r) => r.json())
319
+ ```
320
+
321
+ If you'd rather not hand-roll fetch calls, the SDK methods accept an `options` object you can pass through; for the methods that don't yet, wrap them in your own tiny helper that re-uses the same call signature.
322
+
323
+ ### Example — tag a tracking-config read (already wired)
324
+
325
+ ```ts
326
+ import { cms } from "@/lib/cms"
327
+ const tracking = await cms.getTrackingConfig() // already tagged with cms:tracking-config
328
+ ```
329
+
330
+ ---
331
+
332
+ ## 9. Revalidation (ISR)
333
+
334
+ You have two options:
335
+
336
+ ### Time-based (simple)
337
+
338
+ Add to any page or layout:
339
+
340
+ ```tsx
341
+ // Revalidate this route at most every 60 seconds
342
+ export const revalidate = 60
343
+ ```
344
+
345
+ Pages stay cached for that interval. Fine for low-edit-frequency content; users
346
+ can see stale data for up to `revalidate` seconds after a publish.
347
+
348
+ ### On-demand from CMS webhooks (recommended for editorial sites)
349
+
350
+ The CMS fires an outbound webhook on every content change. Wire a single
351
+ `/api/revalidate` route that calls Next's
352
+ [`revalidatePath()`](https://nextjs.org/docs/app/api-reference/functions/revalidatePath)
353
+ or [`revalidateTag()`](https://nextjs.org/docs/app/api-reference/functions/revalidateTag).
354
+ Editors see updates within a second of pressing Publish, and the rest of the
355
+ site stays statically cached.
356
+
357
+ #### Configure the subscription
358
+
359
+ In the CMS, open **Tenants → \<your tenant\> → Webhooks** and add a row:
360
+
361
+ | Field | Value |
362
+ |--------------|----------------------------------------------------|
363
+ | Endpoint URL | `https://yoursite.com/api/revalidate` |
364
+ | Events | `content.published`, `content.unpublished`, `content.updated`, `content.deleted` (or `*` for everything) |
365
+ | Secret | Click **Generate** (32 random bytes) and copy it. Save it as `CMS_WEBHOOK_SECRET` in your site's env. |
366
+
367
+ The secret is encrypted at rest in the CMS using the per-tenant key.
368
+
369
+ #### Payload contract
370
+
371
+ Every delivery is `POST application/json` with these headers:
372
+
373
+ | Header | Value |
374
+ |---------------------|----------------------------------------------------|
375
+ | `X-CMS-Event` | The event name (e.g. `content.published`) |
376
+ | `X-CMS-Signature` | `hmac_sha256(secret, raw_body)` as lowercase hex (only when a secret is set) |
377
+ | `X-CMS-Mode` | `live` or `staging` — which URL the CMS chose to fire to |
378
+ | `Content-Type` | `application/json` |
379
+
380
+ > **Live + staging URLs.** Each webhook subscription holds both a live URL and an optional staging URL. They share the same secret, events, and signature. A **mode** toggle on the row picks which URL fires. Use this during development to redirect revalidation traffic at your dev/preview site without breaking production.
381
+
382
+ Body shape:
383
+
384
+ ```json
385
+ {
386
+ "event": "content.published",
387
+ "tenant_id": "uuid",
388
+ "content_type_slug": "blog-posts",
389
+ "content_item_id": "uuid",
390
+ "resource_id": "uuid",
391
+ "slug": "hello-world",
392
+ "title": "Hello world",
393
+ "status": "published",
394
+ "cache_tags": ["cms:content-type:blog-posts", "cms:content:blog-posts:hello-world"],
395
+ "data": { "source": "editor" },
396
+ "timestamp": "2026-05-11T03:14:15.926Z"
397
+ }
398
+ ```
399
+
400
+ Every payload carries:
401
+
402
+ - `event` — what happened (see the event list below).
403
+ - `cache_tags` — the Next.js cache tags that should be invalidated.
404
+ All tags are namespaced with `cms:` so they don't collide with your own.
405
+ - `resource_id` — stable identifier for the changed resource (product id,
406
+ flipbook id, integration provider name, etc.).
407
+ - `data` — event-specific extras (e.g. `{ provider: "motor-central" }`,
408
+ `{ reviews_new: 3 }`, `{ source: "import", action: "merge" }`).
409
+
410
+ Content/commerce events also fill the legacy `slug`, `title`, `status`,
411
+ `content_type_slug`, `content_item_id` fields when they apply.
412
+
413
+ #### Example receiver — one-line tag invalidation (recommended)
414
+
415
+ The SDK ships `revalidateAllTags(payload, revalidateTag)` so most receivers
416
+ don't need to switch on event names. It walks `payload.cache_tags` and
417
+ forwards each one to Next's `revalidateTag()`.
418
+
419
+ > ⚠️ **Next 16 changed the `revalidateTag` API — read this before copying.**
420
+ >
421
+ > In Next 16, `revalidateTag(tag)` became `revalidateTag(tag, profile)`. The
422
+ > named profiles (`"default"`, `"max"`, `"days"`, etc.) are **stale-while-revalidate
423
+ > windows, not immediate invalidation modes** — passing them silently leaves
424
+ > cached pages stale for up to 1 year. The only value that means "invalidate
425
+ > now" is `{ expire: 0 }`.
426
+ >
427
+ > **Use SDK v1.17.1+ and the snippet below as-is** — `revalidateAllTags()`
428
+ > passes `{ expire: 0 }` internally, so it works on Next 14, 15, and 16
429
+ > without a wrapper. If you're on an older SDK or call `revalidateTag` for
430
+ > path-specific work, write it explicitly:
431
+ >
432
+ > ```ts
433
+ > revalidateTag("cms:content-type:blog-posts", { expire: 0 })
434
+ > ```
435
+
436
+ ```ts
437
+ // src/app/api/revalidate/route.ts
438
+ import { revalidateTag } from "next/cache"
439
+ import { NextResponse } from "next/server"
440
+ import {
441
+ verifyWebhookSignature,
442
+ revalidateAllTags,
443
+ type WebhookEventPayload,
444
+ } from "@distinctagency/cms-client" // v1.17.1+ — passes { expire: 0 } for Next 16
445
+
446
+ export async function POST(req: Request) {
447
+ const raw = await req.text()
448
+ const sig = req.headers.get("x-cms-signature")
449
+ const secret = process.env.CMS_WEBHOOK_SECRET
450
+
451
+ if (secret && !(await verifyWebhookSignature(secret, raw, sig))) {
452
+ return NextResponse.json({ error: "bad signature" }, { status: 401 })
453
+ }
454
+
455
+ const payload = JSON.parse(raw) as WebhookEventPayload
456
+
457
+ // Ignore the test ping the admin UI sends from the "test" button.
458
+ if (payload.content_type_slug === "_test") {
459
+ return NextResponse.json({ ok: true, ignored: "test" })
460
+ }
461
+
462
+ // SDK forwards every cache_tag to revalidateTag(tag, { expire: 0 }).
463
+ // Don't substitute a named profile like "default" — those are SWR
464
+ // windows, not immediate invalidation.
465
+ const invalidated = revalidateAllTags(payload, revalidateTag)
466
+ return NextResponse.json({ ok: true, invalidated })
467
+ }
468
+ ```
469
+
470
+ For this to do anything, your `getContentItems` / `getTrackingConfig` /
471
+ `getProducts` reads need to be tagged with the same `cms:*` tags. The SDK's
472
+ `getTrackingConfig()` already tags itself with `TRACKING_CONFIG_TAG`
473
+ (= `"cms:tracking-config"`); for other reads, pass `{ next: { tags: [...] } }`
474
+ to your own fetch calls or wrap the SDK methods. See the tag table below.
475
+
476
+ If you'd rather use path-based revalidation, you can still inspect the event
477
+ name and call `revalidatePath()` instead — both styles are supported and can
478
+ be mixed.
479
+
480
+ `verifyWebhookSignature(secret, rawBody, signatureHeader)` returns `true` only
481
+ when the HMAC matches. **Always pass the raw body**, not a re-stringified
482
+ JSON object — re-serialization changes whitespace and invalidates the
483
+ signature.
484
+
485
+ #### Available events and their cache tags
486
+
487
+ | Event | Cache tags | Fires when |
488
+ |--------------------------------|---------------------------------------------------------------------|---------------------------------------|
489
+ | `*` | (subscribe to everything) | — |
490
+ | `content.published` | `cms:content-type:<slug>`, `cms:content:<slug>:<item-slug>` | Editor publishes an item |
491
+ | `content.unpublished` | `cms:content-type:<slug>`, `cms:content:<slug>:<item-slug>` | Editor moves an item back to draft |
492
+ | `content.updated` | `cms:content-type:<slug>`, `cms:content:<slug>:<item-slug>` | Published item edited; bulk syncs (e.g. Motor Central) |
493
+ | `content.deleted` | `cms:content-type:<slug>`, `cms:content:<slug>:<item-slug>` | Item removed |
494
+ | `content_type.updated` | `cms:content-type:<slug>`, `cms:content-type:<slug>:schema` | Schema or SEO config of a collection saved |
495
+ | `content_type.deleted` | `cms:content-type:<slug>` | Collection removed |
496
+ | `settings.tracking_updated` | `cms:tracking-config` | GA / GTM / Meta Pixel / Google Ads ID changed (diffed) |
497
+ | `settings.brand_updated` | `cms:brand` | Brand name / logo / colour changed (diffed) |
498
+ | `settings.integration_updated` | `cms:integration:<provider>` | Stripe / Resend / Anthropic / Motor Central / Google Reviews settings changed (diffed) |
499
+ | `products.updated` | `cms:products`, optionally `cms:product:<slug>` | Product create/edit, bulk import |
500
+ | `product_categories.updated` | `cms:product-categories` | Category create/edit/delete |
501
+ | `ticket_tiers.updated` | `cms:ticket-tiers`, `cms:event:<event-id>` | Ticket tier create/edit/delete |
502
+ | `membership_tiers.updated` | `cms:membership-tiers` | Membership tier create/edit/delete |
503
+ | `redirects.updated` | `cms:redirects` | Custom redirect added/removed |
504
+ | `reviews.synced` | `cms:reviews` | Google Reviews sync produced new rows |
505
+ | `flipbook.ready` | `cms:flipbooks`, `cms:flipbook:<id>` | PDF processing finished |
506
+ | `order.created` / `.paid` / `.payment_failed` / `.refunded` / `.shipped` | (event-specific) | Order lifecycle |
507
+ | `booking.confirmed` | (event-specific) | Free event booking confirmed |
508
+ | `inventory.low_stock` | (event-specific) | Variant stock crosses its low-stock threshold |
509
+
510
+ `settings.*` events are **diffed** on the server — they only fire when a
511
+ relevant field's value actually changed (or, for secret fields, when the
512
+ set/unset state flipped).
513
+
514
+ #### Delivery semantics
515
+
516
+ - **Retries:** 3 attempts with exponential backoff (1s / 4s / 9s). 4xx
517
+ responses are treated as terminal — fix your endpoint, then re-publish to
518
+ trigger redelivery.
519
+ - **Timeouts:** Aim to return within a few seconds. `revalidatePath()` is
520
+ near-instant; if you need to do more work, return `200` first and queue it.
521
+ - **Ordering:** Not guaranteed. Treat the payload as a hint that *something*
522
+ about that slug changed — don't assume it carries the latest field values.
523
+ - **Last-delivery state:** The Webhooks tab shows the most recent HTTP status
524
+ per subscription, plus an error message on failure.
525
+
526
+ ---
527
+
528
+ ## 10. Analytics Tracking
529
+
530
+ Track page views, scroll depth, and time on page. Add to `.env.local`:
531
+
532
+ ```env
533
+ NEXT_PUBLIC_CMS_API_KEY=<your-tenant-api-key>
534
+ ```
535
+
536
+ **Option A: Site-wide tracking (recommended)** — add once in root layout:
537
+
538
+ ```tsx
539
+ // src/app/layout.tsx
540
+ import { PageTracker } from "@distinctagency/cms-client/client"
541
+
542
+ export default function RootLayout({ children }) {
543
+ return (
544
+ <html lang="en">
545
+ <body>
546
+ {children}
547
+ <PageTracker
548
+ trackingUrl={process.env.NEXT_PUBLIC_CMS_URL + "/api/track"}
549
+ apiKey={process.env.NEXT_PUBLIC_CMS_API_KEY!}
550
+ />
551
+ </body>
552
+ </html>
553
+ )
554
+ }
555
+ ```
556
+
557
+ **Option B: Per-page tracking** — for specific content pages:
558
+
559
+ ```tsx
560
+ import { CmsAnalytics } from "@distinctagency/cms-client/client"
561
+
562
+ <CmsAnalytics
563
+ trackingUrl={process.env.NEXT_PUBLIC_CMS_URL + "/api/track"}
564
+ apiKey={process.env.NEXT_PUBLIC_CMS_API_KEY!}
565
+ contentTypeSlug="events"
566
+ itemSlug={slug}
567
+ contentItemId={item.id} // optional
568
+ />
569
+ ```
570
+
571
+ Do NOT use both on the same page. Both components render nothing visually. Tracking is fire-and-forget, no cookies, GDPR-friendly.
572
+
573
+ ---
574
+
575
+ ## 11. Google Reviews
576
+
577
+ Display curated Google Reviews on your website. Reviews are pulled from Google via the tenant's Place ID, curated (approved/hidden) in the CMS admin, and served through the client SDK.
578
+
579
+ ### Prerequisites
580
+
581
+ The tenant must have:
582
+ 1. A **Google Place ID** configured in CMS admin → Integrations → Reviews tab
583
+ 2. At least one sync completed (automatic daily via cron, or manual "Sync Now" by a super admin)
584
+ 3. Some reviews marked as **approved** in the CMS admin → Reviews page
585
+
586
+ ### Fetch approved reviews
587
+
588
+ ```tsx
589
+ // src/app/reviews/page.tsx (or wherever you want to show them)
590
+ import { cms } from "@/lib/cms"
591
+ import type { GoogleReview } from "@distinctagency/cms-client"
592
+
593
+ export default async function ReviewsSection() {
594
+ const reviews = await cms.getReviews({
595
+ minRating: 4, // only 4+ star reviews
596
+ limit: 10,
597
+ })
598
+
599
+ return (
600
+ <section>
601
+ <h2>What Our Customers Say</h2>
602
+ {reviews.map((review) => (
603
+ <div key={review.id}>
604
+ <div>
605
+ {"★".repeat(review.rating)}{"☆".repeat(5 - review.rating)}
606
+ </div>
607
+ <p>{review.text}</p>
608
+ <span>— {review.author_name}</span>
609
+ </div>
610
+ ))}
611
+ </section>
612
+ )
613
+ }
614
+ ```
615
+
616
+ ### Review shape
617
+
618
+ ```ts
619
+ {
620
+ id: string
621
+ author_name: string
622
+ author_photo_url: string | null // Google profile photo URL
623
+ rating: number // 1–5
624
+ text: string | null // Some reviews are rating-only
625
+ review_timestamp: string // ISO date of the original review
626
+ }
627
+ ```
628
+
629
+ ### Query options
630
+
631
+ ```ts
632
+ cms.getReviews({
633
+ status?: "approved" | "pending" | "hidden" // default: "approved"
634
+ minRating?: number // e.g. 4 for 4+ stars only
635
+ limit?: number // default: 50
636
+ offset?: number // for pagination
637
+ orderBy?: "rating" | "review_timestamp" // default: "review_timestamp"
638
+ orderDirection?: "asc" | "desc" // default: "desc" (newest first)
639
+ })
640
+ ```
641
+
642
+ > **Note:** Client sites should only ever need `status: "approved"` (the default). Pending and hidden reviews are for admin use only.
643
+
644
+ ### How reviews get into the system
645
+
646
+ 1. Tenant configures their Google Place ID in CMS admin → Integrations → Reviews
647
+ 2. A daily Vercel Cron job fetches reviews from Google Places API (max 5 per request — Google's limit)
648
+ 3. New reviews land as **pending** in the CMS
649
+ 4. If the tenant has an Anthropic API key + AI triage enabled, new reviews get an AI recommendation (approve/hide)
650
+ 5. Tenant (or super admin) approves or hides reviews in CMS admin → Reviews page
651
+ 6. Approved reviews are available via `cms.getReviews()`
652
+
653
+ Over time, repeated syncs accumulate more reviews as Google rotates which 5 it returns.
654
+
655
+ ### Google attribution
656
+
657
+ Google's Terms of Service require displaying "Reviews from Google" branding when showing reviews. Add a small attribution line near your reviews:
658
+
659
+ ```tsx
660
+ <p className="text-xs text-muted-foreground mt-4">Reviews from Google</p>
661
+ ```
662
+
663
+ ---
664
+
665
+ ## 12. Embed Fields
666
+
667
+ Embed fields store a URL with dimensions and render as responsive, sandboxed iframes. Use them for Matterport tours, YouTube videos, Calendly widgets, and any other iframe-based embed.
668
+
669
+ ### Render with React
670
+
671
+ ```tsx
672
+ import { CmsEmbed } from '@distinctagency/cms-client/client'
673
+
674
+ <CmsEmbed field={item.data.virtual_tour} className="my-4" />
675
+ ```
676
+
677
+ ### Render as HTML (non-React)
678
+
679
+ ```ts
680
+ import { getEmbedHtml } from '@distinctagency/cms-client'
681
+
682
+ const html = getEmbedHtml(item.data.virtual_tour)
683
+ // Returns iframe HTML string, or empty string if invalid
684
+ ```
685
+
686
+ ### Embed field shape
687
+
688
+ ```ts
689
+ {
690
+ url: string // Must be https://
691
+ width: string // CSS value: "100%", "640px", etc.
692
+ aspect_ratio: string // "16:9", "4:3", "1:1", "21:9", or custom "N:N"
693
+ }
694
+ ```
695
+
696
+ The iframe is sandboxed with `allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox`. Returns null/empty if the URL is missing or not HTTPS.
697
+
698
+ ---
699
+
700
+ ## 13. Motor Central (Vehicles)
701
+
702
+ If the tenant has Motor Central configured, vehicles are synced from their inventory system and stored as content items in a "Vehicles" collection. Query them like any other collection.
703
+
704
+ ### List available vehicles
705
+
706
+ ```tsx
707
+ const vehicles = await cms.getContentItems("vehicles", {
708
+ status: "published",
709
+ limit: 50,
710
+ })
711
+ ```
712
+
713
+ ### Filter out sold vehicles
714
+
715
+ Vehicles removed from Motor Central are flagged with `car_status: "sold"` in their data. Filter them client-side:
716
+
717
+ ```ts
718
+ const available = vehicles.filter(v => v.data.car_status !== "sold")
719
+ ```
720
+
721
+ ### Vehicle images
722
+
723
+ - `featured_image` — the hero/main photo (standard content item field)
724
+ - `data.gallery` — array of additional photo URLs
725
+
726
+ ```tsx
727
+ <img src={vehicle.featured_image} alt={vehicle.title} />
728
+ {(vehicle.data.gallery as string[] ?? []).map((url, i) => (
729
+ <img key={i} src={url} alt={`${vehicle.title} photo ${i + 2}`} />
730
+ ))}
731
+ ```
732
+
733
+ ### Common vehicle data fields
734
+
735
+ All fields depend on the Motor Central field mapping configured by the admin. Common fields include:
736
+
737
+ | Field | Type | Description |
738
+ |---|---|---|
739
+ | `data.make` | string | Manufacturer |
740
+ | `data.model` | string | Model |
741
+ | `data.year` | number | Year of manufacture |
742
+ | `data.variant` | string | Variant/trim |
743
+ | `data.price` | number | Retail price |
744
+ | `data.mileage` | number | Odometer reading |
745
+ | `data.transmission` | string | Transmission type |
746
+ | `data.fuel_type` | string | Fuel type |
747
+ | `data.body_style` | string | Body style |
748
+ | `data.color` | string | Exterior colour |
749
+ | `data.stock_no` | string | Dealer stock number |
750
+ | `data.vin` | string | Vehicle identification number |
751
+ | `data.car_status` | string | "sold" if removed from inventory |
752
+ | `data.description` | string | Full description |
753
+ | `data.dealership` | string | Dealership location |
754
+ | `data.gallery` | string[] | Additional photo URLs |
755
+
756
+ ### Sync schedule
757
+
758
+ Vehicles sync automatically within the configured window (typically overnight). New vehicles appear as published immediately. Removed vehicles get `car_status: "sold"`. Images are processed to WebP and served from CDN.
759
+
760
+ ### Instant sync via webhook
761
+
762
+ When a Motor Central sync touches at least one vehicle, the CMS fires a `content.updated` webhook with `cache_tags: ["cms:content-type:<vehicles-collection>", "cms:integration:motor-central"]`. If your `getContentItems("vehicles")` reads are tagged with `cms:content-type:vehicles`, your inventory pages refresh on the next request after the sync completes — no waiting for the next ISR interval.
763
+
764
+ ---
765
+
766
+ ## Troubleshooting
767
+
768
+ | Problem | Cause | Fix |
769
+ |---|---|---|
770
+ | "Invalid CMS API key — no tenant found" | Wrong or missing `CMS_API_KEY` | Check `.env.local` matches the key shown in CMS admin > Tenants |
771
+ | Empty results | Content not published | Check content status is "published" in the CMS admin |
772
+ | `Cannot find module '@distinctagency/cms-client'` | Package not installed | Run `pnpm add @distinctagency/cms-client` |
773
+ | Data fields are `unknown` | TypeScript limitation | Cast: `event.data.date as string` or create typed wrappers |
774
+ | Analytics 403 | API key has trailing whitespace | Remove trailing whitespace/newline from `NEXT_PUBLIC_CMS_API_KEY` in `.env.local` and redeploy |
775
+ | Analytics not tracking | Component not client-side | Ensure `PageTracker`/`CmsAnalytics` renders in a client component (needs `"use client"` parent) |
776
+ | Analytics infinite loop | Rendered inside a loop | Only render tracking component once per page, never inside `.map()` |
777
+ | `getReviews()` returns empty | No approved reviews | Check CMS admin → Reviews — reviews must be approved before they appear |
778
+ | `getReviews()` returns empty | No Place ID configured | Configure Google Place ID in CMS admin → Integrations → Reviews tab |
779
+ | `getReviews()` returns empty | No sync run yet | Super admin must click "Sync Now" on Reviews page, or wait for daily cron |
780
+ | Webhook fires (200 response in CMS) but page doesn't refresh on Next 16 | `revalidateTag` was called with a named profile like `"default"` or `"max"` — those are stale-while-revalidate windows (~136 years for `"max"`), not immediate invalidation | Upgrade to `@distinctagency/cms-client@^1.17.1` and use `revalidateAllTags(payload, revalidateTag)` as shown in §9. If calling `revalidateTag` directly, pass `{ expire: 0 }` as the second argument |
781
+ | `revalidateTag` deprecation warning in Next 16 logs | Calling `revalidateTag(tag)` with one argument | Same fix — let `revalidateAllTags()` handle it, or pass `{ expire: 0 }` |
782
+ | TypeScript error: "Expected 2 arguments, but got 1" on `revalidateTag` | Next 16 made the cache-profile arg required | Same fix — SDK v1.17.1+ wraps it for you, or call `revalidateTag(tag, { expire: 0 })` |
783
+
784
+ ---
785
+
786
+ ## Project locations
787
+
788
+ | Project | Path |
789
+ |---|---|
790
+ | Distinct CMS (admin + database) | `/Users/alexbrowning/VSCode/distinct-cms` |
791
+ | Client package source | `/Users/alexbrowning/VSCode/distinct-cms/packages/client` |
792
+ | Supabase project | `tfictdetndaezlyearyj` (ap-southeast-2) |