@distinctagency/cms-client 1.17.1 → 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 +792 -0
- package/package.json +3 -2
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) |
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@distinctagency/cms-client",
|
|
3
|
-
"version": "1.17.
|
|
3
|
+
"version": "1.17.2",
|
|
4
4
|
"description": "Client library for Distinct CMS — query content, products, and manage orders",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
8
|
"files": [
|
|
9
|
-
"dist"
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
10
11
|
],
|
|
11
12
|
"exports": {
|
|
12
13
|
".": {
|