@distinctagency/cms-client 1.17.1 → 1.18.0

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,812 @@
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
+ ## 7.1 Multi-reference fields
248
+
249
+ A `multi-reference` field stores an **ordered array** of item IDs from another collection (e.g. a Blog Post's `related_posts`).
250
+
251
+ The value stored in `data.related_posts` is `string[]` — an array of item IDs in the order the editor arranged them. To resolve:
252
+
253
+ ```ts
254
+ const post = await cms.getContentItemBySlug("blog_posts", "my-post")
255
+ const relatedIds = (post?.data.related_posts as string[]) ?? []
256
+
257
+ // Fetch the target collection and resolve client-side, preserving editor order.
258
+ const all = await cms.getContentItems("blog_posts")
259
+ const byId = new Map(all.map((it) => [it.id, it]))
260
+ const related = relatedIds.map((id) => byId.get(id)).filter(Boolean)
261
+ ```
262
+
263
+ Schema-side, the type literal is `"multi-reference"`. Optional `min_items` and `max_items` enforce a count range in the admin UI.
264
+
265
+ ---
266
+
267
+ ## 8. Slug Redirects (301s)
268
+
269
+ 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:
270
+
271
+ ```ts
272
+ // next.config.ts
273
+ import { createClient } from "@supabase/supabase-js"
274
+ import { createCmsClient } from "@distinctagency/cms-client"
275
+
276
+ const cms = createCmsClient(
277
+ createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
278
+ { apiKey: process.env.CMS_API_KEY! }
279
+ )
280
+
281
+ export default {
282
+ async redirects() {
283
+ // Slug redirects (auto-created when CMS slugs change)
284
+ const eventRedirects = await cms.getRedirects("events")
285
+ const slugRedirects = eventRedirects.map((r) => ({
286
+ source: `/events/${r.old_slug}`,
287
+ destination: `/events/${r.new_slug}`,
288
+ permanent: true,
289
+ }))
290
+
291
+ // Custom redirects (manually added for site redesigns, legacy URLs)
292
+ const customRedirects = await cms.getCustomRedirects()
293
+ const customMapped = customRedirects.map((r) => ({
294
+ source: r.source_path,
295
+ destination: r.destination_path,
296
+ permanent: r.permanent,
297
+ }))
298
+
299
+ return [...slugRedirects, ...customMapped]
300
+ },
301
+ }
302
+ ```
303
+
304
+ ---
305
+
306
+ ## Tagging your reads
307
+
308
+ 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).
309
+
310
+ | What you're rendering | Tag your fetch with | Webhook event(s) that invalidate it |
311
+ |----------------------------------|---------------------------------------------------|------------------------------------------------------------------|
312
+ | Content list (e.g. all events) | `cms:content-type:events` | `content.published`, `content.unpublished`, `content.updated`, `content.deleted`, `content_type.updated` |
313
+ | Single content item | `cms:content:events:<slug>` | `content.*` for that slug |
314
+ | Collection schema / SEO config | `cms:content-type:<slug>:schema` | `content_type.updated` |
315
+ | Tracking IDs (`getTrackingConfig`) | `cms:tracking-config` *(SDK does this for you)* | `settings.tracking_updated` |
316
+ | Brand (logo / colours) | `cms:brand` | `settings.brand_updated` |
317
+ | Integration config (Stripe, Resend, etc.) | `cms:integration:<provider>` | `settings.integration_updated` |
318
+ | Product list | `cms:products` | `products.updated` |
319
+ | Single product | `cms:product:<slug>` | `products.updated` (when slug-specific) |
320
+ | Product categories | `cms:product-categories` | `product_categories.updated` |
321
+ | Ticket tiers for an event | `cms:ticket-tiers`, `cms:event:<event-id>` | `ticket_tiers.updated` |
322
+ | Membership tiers | `cms:membership-tiers` | `membership_tiers.updated` |
323
+ | Redirects (if served at runtime) | `cms:redirects` | `redirects.updated` |
324
+ | Reviews (`getReviews`) | `cms:reviews` | `reviews.synced` |
325
+ | Flipbooks | `cms:flipbooks`, `cms:flipbook:<id>` | `flipbook.ready` |
326
+ | Motor Central vehicles | `cms:content-type:<vehicles-collection>`, `cms:integration:motor-central` | `content.updated` after each sync |
327
+
328
+ ### Example — tag a content list
329
+
330
+ ```ts
331
+ // Inside a Server Component
332
+ const events = await fetch(
333
+ `${process.env.NEXT_PUBLIC_CMS_URL}/rest/v1/content_items?...`,
334
+ {
335
+ headers: { /* anon + api key */ },
336
+ next: { tags: ["cms:content-type:events"], revalidate: 3600 },
337
+ }
338
+ ).then((r) => r.json())
339
+ ```
340
+
341
+ 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.
342
+
343
+ ### Example — tag a tracking-config read (already wired)
344
+
345
+ ```ts
346
+ import { cms } from "@/lib/cms"
347
+ const tracking = await cms.getTrackingConfig() // already tagged with cms:tracking-config
348
+ ```
349
+
350
+ ---
351
+
352
+ ## 9. Revalidation (ISR)
353
+
354
+ You have two options:
355
+
356
+ ### Time-based (simple)
357
+
358
+ Add to any page or layout:
359
+
360
+ ```tsx
361
+ // Revalidate this route at most every 60 seconds
362
+ export const revalidate = 60
363
+ ```
364
+
365
+ Pages stay cached for that interval. Fine for low-edit-frequency content; users
366
+ can see stale data for up to `revalidate` seconds after a publish.
367
+
368
+ ### On-demand from CMS webhooks (recommended for editorial sites)
369
+
370
+ The CMS fires an outbound webhook on every content change. Wire a single
371
+ `/api/revalidate` route that calls Next's
372
+ [`revalidatePath()`](https://nextjs.org/docs/app/api-reference/functions/revalidatePath)
373
+ or [`revalidateTag()`](https://nextjs.org/docs/app/api-reference/functions/revalidateTag).
374
+ Editors see updates within a second of pressing Publish, and the rest of the
375
+ site stays statically cached.
376
+
377
+ #### Configure the subscription
378
+
379
+ In the CMS, open **Tenants → \<your tenant\> → Webhooks** and add a row:
380
+
381
+ | Field | Value |
382
+ |--------------|----------------------------------------------------|
383
+ | Endpoint URL | `https://yoursite.com/api/revalidate` |
384
+ | Events | `content.published`, `content.unpublished`, `content.updated`, `content.deleted` (or `*` for everything) |
385
+ | Secret | Click **Generate** (32 random bytes) and copy it. Save it as `CMS_WEBHOOK_SECRET` in your site's env. |
386
+
387
+ The secret is encrypted at rest in the CMS using the per-tenant key.
388
+
389
+ #### Payload contract
390
+
391
+ Every delivery is `POST application/json` with these headers:
392
+
393
+ | Header | Value |
394
+ |---------------------|----------------------------------------------------|
395
+ | `X-CMS-Event` | The event name (e.g. `content.published`) |
396
+ | `X-CMS-Signature` | `hmac_sha256(secret, raw_body)` as lowercase hex (only when a secret is set) |
397
+ | `X-CMS-Mode` | `live` or `staging` — which URL the CMS chose to fire to |
398
+ | `Content-Type` | `application/json` |
399
+
400
+ > **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.
401
+
402
+ Body shape:
403
+
404
+ ```json
405
+ {
406
+ "event": "content.published",
407
+ "tenant_id": "uuid",
408
+ "content_type_slug": "blog-posts",
409
+ "content_item_id": "uuid",
410
+ "resource_id": "uuid",
411
+ "slug": "hello-world",
412
+ "title": "Hello world",
413
+ "status": "published",
414
+ "cache_tags": ["cms:content-type:blog-posts", "cms:content:blog-posts:hello-world"],
415
+ "data": { "source": "editor" },
416
+ "timestamp": "2026-05-11T03:14:15.926Z"
417
+ }
418
+ ```
419
+
420
+ Every payload carries:
421
+
422
+ - `event` — what happened (see the event list below).
423
+ - `cache_tags` — the Next.js cache tags that should be invalidated.
424
+ All tags are namespaced with `cms:` so they don't collide with your own.
425
+ - `resource_id` — stable identifier for the changed resource (product id,
426
+ flipbook id, integration provider name, etc.).
427
+ - `data` — event-specific extras (e.g. `{ provider: "motor-central" }`,
428
+ `{ reviews_new: 3 }`, `{ source: "import", action: "merge" }`).
429
+
430
+ Content/commerce events also fill the legacy `slug`, `title`, `status`,
431
+ `content_type_slug`, `content_item_id` fields when they apply.
432
+
433
+ #### Example receiver — one-line tag invalidation (recommended)
434
+
435
+ The SDK ships `revalidateAllTags(payload, revalidateTag)` so most receivers
436
+ don't need to switch on event names. It walks `payload.cache_tags` and
437
+ forwards each one to Next's `revalidateTag()`.
438
+
439
+ > ⚠️ **Next 16 changed the `revalidateTag` API — read this before copying.**
440
+ >
441
+ > In Next 16, `revalidateTag(tag)` became `revalidateTag(tag, profile)`. The
442
+ > named profiles (`"default"`, `"max"`, `"days"`, etc.) are **stale-while-revalidate
443
+ > windows, not immediate invalidation modes** — passing them silently leaves
444
+ > cached pages stale for up to 1 year. The only value that means "invalidate
445
+ > now" is `{ expire: 0 }`.
446
+ >
447
+ > **Use SDK v1.17.1+ and the snippet below as-is** — `revalidateAllTags()`
448
+ > passes `{ expire: 0 }` internally, so it works on Next 14, 15, and 16
449
+ > without a wrapper. If you're on an older SDK or call `revalidateTag` for
450
+ > path-specific work, write it explicitly:
451
+ >
452
+ > ```ts
453
+ > revalidateTag("cms:content-type:blog-posts", { expire: 0 })
454
+ > ```
455
+
456
+ ```ts
457
+ // src/app/api/revalidate/route.ts
458
+ import { revalidateTag } from "next/cache"
459
+ import { NextResponse } from "next/server"
460
+ import {
461
+ verifyWebhookSignature,
462
+ revalidateAllTags,
463
+ type WebhookEventPayload,
464
+ } from "@distinctagency/cms-client" // v1.17.1+ — passes { expire: 0 } for Next 16
465
+
466
+ export async function POST(req: Request) {
467
+ const raw = await req.text()
468
+ const sig = req.headers.get("x-cms-signature")
469
+ const secret = process.env.CMS_WEBHOOK_SECRET
470
+
471
+ if (secret && !(await verifyWebhookSignature(secret, raw, sig))) {
472
+ return NextResponse.json({ error: "bad signature" }, { status: 401 })
473
+ }
474
+
475
+ const payload = JSON.parse(raw) as WebhookEventPayload
476
+
477
+ // Ignore the test ping the admin UI sends from the "test" button.
478
+ if (payload.content_type_slug === "_test") {
479
+ return NextResponse.json({ ok: true, ignored: "test" })
480
+ }
481
+
482
+ // SDK forwards every cache_tag to revalidateTag(tag, { expire: 0 }).
483
+ // Don't substitute a named profile like "default" — those are SWR
484
+ // windows, not immediate invalidation.
485
+ const invalidated = revalidateAllTags(payload, revalidateTag)
486
+ return NextResponse.json({ ok: true, invalidated })
487
+ }
488
+ ```
489
+
490
+ For this to do anything, your `getContentItems` / `getTrackingConfig` /
491
+ `getProducts` reads need to be tagged with the same `cms:*` tags. The SDK's
492
+ `getTrackingConfig()` already tags itself with `TRACKING_CONFIG_TAG`
493
+ (= `"cms:tracking-config"`); for other reads, pass `{ next: { tags: [...] } }`
494
+ to your own fetch calls or wrap the SDK methods. See the tag table below.
495
+
496
+ If you'd rather use path-based revalidation, you can still inspect the event
497
+ name and call `revalidatePath()` instead — both styles are supported and can
498
+ be mixed.
499
+
500
+ `verifyWebhookSignature(secret, rawBody, signatureHeader)` returns `true` only
501
+ when the HMAC matches. **Always pass the raw body**, not a re-stringified
502
+ JSON object — re-serialization changes whitespace and invalidates the
503
+ signature.
504
+
505
+ #### Available events and their cache tags
506
+
507
+ | Event | Cache tags | Fires when |
508
+ |--------------------------------|---------------------------------------------------------------------|---------------------------------------|
509
+ | `*` | (subscribe to everything) | — |
510
+ | `content.published` | `cms:content-type:<slug>`, `cms:content:<slug>:<item-slug>` | Editor publishes an item |
511
+ | `content.unpublished` | `cms:content-type:<slug>`, `cms:content:<slug>:<item-slug>` | Editor moves an item back to draft |
512
+ | `content.updated` | `cms:content-type:<slug>`, `cms:content:<slug>:<item-slug>` | Published item edited; bulk syncs (e.g. Motor Central) |
513
+ | `content.deleted` | `cms:content-type:<slug>`, `cms:content:<slug>:<item-slug>` | Item removed |
514
+ | `content_type.updated` | `cms:content-type:<slug>`, `cms:content-type:<slug>:schema` | Schema or SEO config of a collection saved |
515
+ | `content_type.deleted` | `cms:content-type:<slug>` | Collection removed |
516
+ | `settings.tracking_updated` | `cms:tracking-config` | GA / GTM / Meta Pixel / Google Ads ID changed (diffed) |
517
+ | `settings.brand_updated` | `cms:brand` | Brand name / logo / colour changed (diffed) |
518
+ | `settings.integration_updated` | `cms:integration:<provider>` | Stripe / Resend / Anthropic / Motor Central / Google Reviews settings changed (diffed) |
519
+ | `products.updated` | `cms:products`, optionally `cms:product:<slug>` | Product create/edit, bulk import |
520
+ | `product_categories.updated` | `cms:product-categories` | Category create/edit/delete |
521
+ | `ticket_tiers.updated` | `cms:ticket-tiers`, `cms:event:<event-id>` | Ticket tier create/edit/delete |
522
+ | `membership_tiers.updated` | `cms:membership-tiers` | Membership tier create/edit/delete |
523
+ | `redirects.updated` | `cms:redirects` | Custom redirect added/removed |
524
+ | `reviews.synced` | `cms:reviews` | Google Reviews sync produced new rows |
525
+ | `flipbook.ready` | `cms:flipbooks`, `cms:flipbook:<id>` | PDF processing finished |
526
+ | `order.created` / `.paid` / `.payment_failed` / `.refunded` / `.shipped` | (event-specific) | Order lifecycle |
527
+ | `booking.confirmed` | (event-specific) | Free event booking confirmed |
528
+ | `inventory.low_stock` | (event-specific) | Variant stock crosses its low-stock threshold |
529
+
530
+ `settings.*` events are **diffed** on the server — they only fire when a
531
+ relevant field's value actually changed (or, for secret fields, when the
532
+ set/unset state flipped).
533
+
534
+ #### Delivery semantics
535
+
536
+ - **Retries:** 3 attempts with exponential backoff (1s / 4s / 9s). 4xx
537
+ responses are treated as terminal — fix your endpoint, then re-publish to
538
+ trigger redelivery.
539
+ - **Timeouts:** Aim to return within a few seconds. `revalidatePath()` is
540
+ near-instant; if you need to do more work, return `200` first and queue it.
541
+ - **Ordering:** Not guaranteed. Treat the payload as a hint that *something*
542
+ about that slug changed — don't assume it carries the latest field values.
543
+ - **Last-delivery state:** The Webhooks tab shows the most recent HTTP status
544
+ per subscription, plus an error message on failure.
545
+
546
+ ---
547
+
548
+ ## 10. Analytics Tracking
549
+
550
+ Track page views, scroll depth, and time on page. Add to `.env.local`:
551
+
552
+ ```env
553
+ NEXT_PUBLIC_CMS_API_KEY=<your-tenant-api-key>
554
+ ```
555
+
556
+ **Option A: Site-wide tracking (recommended)** — add once in root layout:
557
+
558
+ ```tsx
559
+ // src/app/layout.tsx
560
+ import { PageTracker } from "@distinctagency/cms-client/client"
561
+
562
+ export default function RootLayout({ children }) {
563
+ return (
564
+ <html lang="en">
565
+ <body>
566
+ {children}
567
+ <PageTracker
568
+ trackingUrl={process.env.NEXT_PUBLIC_CMS_URL + "/api/track"}
569
+ apiKey={process.env.NEXT_PUBLIC_CMS_API_KEY!}
570
+ />
571
+ </body>
572
+ </html>
573
+ )
574
+ }
575
+ ```
576
+
577
+ **Option B: Per-page tracking** — for specific content pages:
578
+
579
+ ```tsx
580
+ import { CmsAnalytics } from "@distinctagency/cms-client/client"
581
+
582
+ <CmsAnalytics
583
+ trackingUrl={process.env.NEXT_PUBLIC_CMS_URL + "/api/track"}
584
+ apiKey={process.env.NEXT_PUBLIC_CMS_API_KEY!}
585
+ contentTypeSlug="events"
586
+ itemSlug={slug}
587
+ contentItemId={item.id} // optional
588
+ />
589
+ ```
590
+
591
+ Do NOT use both on the same page. Both components render nothing visually. Tracking is fire-and-forget, no cookies, GDPR-friendly.
592
+
593
+ ---
594
+
595
+ ## 11. Google Reviews
596
+
597
+ 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.
598
+
599
+ ### Prerequisites
600
+
601
+ The tenant must have:
602
+ 1. A **Google Place ID** configured in CMS admin → Integrations → Reviews tab
603
+ 2. At least one sync completed (automatic daily via cron, or manual "Sync Now" by a super admin)
604
+ 3. Some reviews marked as **approved** in the CMS admin → Reviews page
605
+
606
+ ### Fetch approved reviews
607
+
608
+ ```tsx
609
+ // src/app/reviews/page.tsx (or wherever you want to show them)
610
+ import { cms } from "@/lib/cms"
611
+ import type { GoogleReview } from "@distinctagency/cms-client"
612
+
613
+ export default async function ReviewsSection() {
614
+ const reviews = await cms.getReviews({
615
+ minRating: 4, // only 4+ star reviews
616
+ limit: 10,
617
+ })
618
+
619
+ return (
620
+ <section>
621
+ <h2>What Our Customers Say</h2>
622
+ {reviews.map((review) => (
623
+ <div key={review.id}>
624
+ <div>
625
+ {"★".repeat(review.rating)}{"☆".repeat(5 - review.rating)}
626
+ </div>
627
+ <p>{review.text}</p>
628
+ <span>— {review.author_name}</span>
629
+ </div>
630
+ ))}
631
+ </section>
632
+ )
633
+ }
634
+ ```
635
+
636
+ ### Review shape
637
+
638
+ ```ts
639
+ {
640
+ id: string
641
+ author_name: string
642
+ author_photo_url: string | null // Google profile photo URL
643
+ rating: number // 1–5
644
+ text: string | null // Some reviews are rating-only
645
+ review_timestamp: string // ISO date of the original review
646
+ }
647
+ ```
648
+
649
+ ### Query options
650
+
651
+ ```ts
652
+ cms.getReviews({
653
+ status?: "approved" | "pending" | "hidden" // default: "approved"
654
+ minRating?: number // e.g. 4 for 4+ stars only
655
+ limit?: number // default: 50
656
+ offset?: number // for pagination
657
+ orderBy?: "rating" | "review_timestamp" // default: "review_timestamp"
658
+ orderDirection?: "asc" | "desc" // default: "desc" (newest first)
659
+ })
660
+ ```
661
+
662
+ > **Note:** Client sites should only ever need `status: "approved"` (the default). Pending and hidden reviews are for admin use only.
663
+
664
+ ### How reviews get into the system
665
+
666
+ 1. Tenant configures their Google Place ID in CMS admin → Integrations → Reviews
667
+ 2. A daily Vercel Cron job fetches reviews from Google Places API (max 5 per request — Google's limit)
668
+ 3. New reviews land as **pending** in the CMS
669
+ 4. If the tenant has an Anthropic API key + AI triage enabled, new reviews get an AI recommendation (approve/hide)
670
+ 5. Tenant (or super admin) approves or hides reviews in CMS admin → Reviews page
671
+ 6. Approved reviews are available via `cms.getReviews()`
672
+
673
+ Over time, repeated syncs accumulate more reviews as Google rotates which 5 it returns.
674
+
675
+ ### Google attribution
676
+
677
+ Google's Terms of Service require displaying "Reviews from Google" branding when showing reviews. Add a small attribution line near your reviews:
678
+
679
+ ```tsx
680
+ <p className="text-xs text-muted-foreground mt-4">Reviews from Google</p>
681
+ ```
682
+
683
+ ---
684
+
685
+ ## 12. Embed Fields
686
+
687
+ 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.
688
+
689
+ ### Render with React
690
+
691
+ ```tsx
692
+ import { CmsEmbed } from '@distinctagency/cms-client/client'
693
+
694
+ <CmsEmbed field={item.data.virtual_tour} className="my-4" />
695
+ ```
696
+
697
+ ### Render as HTML (non-React)
698
+
699
+ ```ts
700
+ import { getEmbedHtml } from '@distinctagency/cms-client'
701
+
702
+ const html = getEmbedHtml(item.data.virtual_tour)
703
+ // Returns iframe HTML string, or empty string if invalid
704
+ ```
705
+
706
+ ### Embed field shape
707
+
708
+ ```ts
709
+ {
710
+ url: string // Must be https://
711
+ width: string // CSS value: "100%", "640px", etc.
712
+ aspect_ratio: string // "16:9", "4:3", "1:1", "21:9", or custom "N:N"
713
+ }
714
+ ```
715
+
716
+ 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.
717
+
718
+ ---
719
+
720
+ ## 13. Motor Central (Vehicles)
721
+
722
+ 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.
723
+
724
+ ### List available vehicles
725
+
726
+ ```tsx
727
+ const vehicles = await cms.getContentItems("vehicles", {
728
+ status: "published",
729
+ limit: 50,
730
+ })
731
+ ```
732
+
733
+ ### Filter out sold vehicles
734
+
735
+ Vehicles removed from Motor Central are flagged with `car_status: "sold"` in their data. Filter them client-side:
736
+
737
+ ```ts
738
+ const available = vehicles.filter(v => v.data.car_status !== "sold")
739
+ ```
740
+
741
+ ### Vehicle images
742
+
743
+ - `featured_image` — the hero/main photo (standard content item field)
744
+ - `data.gallery` — array of additional photo URLs
745
+
746
+ ```tsx
747
+ <img src={vehicle.featured_image} alt={vehicle.title} />
748
+ {(vehicle.data.gallery as string[] ?? []).map((url, i) => (
749
+ <img key={i} src={url} alt={`${vehicle.title} photo ${i + 2}`} />
750
+ ))}
751
+ ```
752
+
753
+ ### Common vehicle data fields
754
+
755
+ All fields depend on the Motor Central field mapping configured by the admin. Common fields include:
756
+
757
+ | Field | Type | Description |
758
+ |---|---|---|
759
+ | `data.make` | string | Manufacturer |
760
+ | `data.model` | string | Model |
761
+ | `data.year` | number | Year of manufacture |
762
+ | `data.variant` | string | Variant/trim |
763
+ | `data.price` | number | Retail price |
764
+ | `data.mileage` | number | Odometer reading |
765
+ | `data.transmission` | string | Transmission type |
766
+ | `data.fuel_type` | string | Fuel type |
767
+ | `data.body_style` | string | Body style |
768
+ | `data.color` | string | Exterior colour |
769
+ | `data.stock_no` | string | Dealer stock number |
770
+ | `data.vin` | string | Vehicle identification number |
771
+ | `data.car_status` | string | "sold" if removed from inventory |
772
+ | `data.description` | string | Full description |
773
+ | `data.dealership` | string | Dealership location |
774
+ | `data.gallery` | string[] | Additional photo URLs |
775
+
776
+ ### Sync schedule
777
+
778
+ 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.
779
+
780
+ ### Instant sync via webhook
781
+
782
+ 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.
783
+
784
+ ---
785
+
786
+ ## Troubleshooting
787
+
788
+ | Problem | Cause | Fix |
789
+ |---|---|---|
790
+ | "Invalid CMS API key — no tenant found" | Wrong or missing `CMS_API_KEY` | Check `.env.local` matches the key shown in CMS admin > Tenants |
791
+ | Empty results | Content not published | Check content status is "published" in the CMS admin |
792
+ | `Cannot find module '@distinctagency/cms-client'` | Package not installed | Run `pnpm add @distinctagency/cms-client` |
793
+ | Data fields are `unknown` | TypeScript limitation | Cast: `event.data.date as string` or create typed wrappers |
794
+ | Analytics 403 | API key has trailing whitespace | Remove trailing whitespace/newline from `NEXT_PUBLIC_CMS_API_KEY` in `.env.local` and redeploy |
795
+ | Analytics not tracking | Component not client-side | Ensure `PageTracker`/`CmsAnalytics` renders in a client component (needs `"use client"` parent) |
796
+ | Analytics infinite loop | Rendered inside a loop | Only render tracking component once per page, never inside `.map()` |
797
+ | `getReviews()` returns empty | No approved reviews | Check CMS admin → Reviews — reviews must be approved before they appear |
798
+ | `getReviews()` returns empty | No Place ID configured | Configure Google Place ID in CMS admin → Integrations → Reviews tab |
799
+ | `getReviews()` returns empty | No sync run yet | Super admin must click "Sync Now" on Reviews page, or wait for daily cron |
800
+ | 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 |
801
+ | `revalidateTag` deprecation warning in Next 16 logs | Calling `revalidateTag(tag)` with one argument | Same fix — let `revalidateAllTags()` handle it, or pass `{ expire: 0 }` |
802
+ | 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 })` |
803
+
804
+ ---
805
+
806
+ ## Project locations
807
+
808
+ | Project | Path |
809
+ |---|---|
810
+ | Distinct CMS (admin + database) | `/Users/alexbrowning/VSCode/distinct-cms` |
811
+ | Client package source | `/Users/alexbrowning/VSCode/distinct-cms/packages/client` |
812
+ | Supabase project | `tfictdetndaezlyearyj` (ap-southeast-2) |