@crayonscodetech/cms-sdk 1.0.1
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 +2689 -0
- package/dist/index.cjs +680 -0
- package/dist/index.d.cts +1112 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +1112 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +645 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +60 -0
package/README.md
ADDED
|
@@ -0,0 +1,2689 @@
|
|
|
1
|
+
# @crayons/cms-sdk
|
|
2
|
+
|
|
3
|
+
A robust, type-safe SDK/Package for fetching data from the Crayons CMS. Designed for Next.js.
|
|
4
|
+
|
|
5
|
+
## Technical Overview
|
|
6
|
+
|
|
7
|
+
Data is fetched from the CMS backend at: <https://api.cms.deployown.com>
|
|
8
|
+
|
|
9
|
+
Content updates and management are handled through the CMS dashboard:
|
|
10
|
+
[https://cms.deployown.com](https://cms.deployown.com)
|
|
11
|
+
|
|
12
|
+
### How it Works
|
|
13
|
+
|
|
14
|
+
- **Headless CMS:** This package is purely for data fetching. It provides the raw content (JSON) without any UI or layout constraints.
|
|
15
|
+
- **Conditional Rendering:** You should fetch the data and use conditional logic to render your components based on the content received.
|
|
16
|
+
- **Full Style Control:** The backend does not provide CSS or styling. You have total creative freedom to define your own styles and themes within your frontend application.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- 🛠 **Type-safe**: Complete TypeScript definitions for all CMS entities.
|
|
21
|
+
- ⚡️ **Next.js Optimized**: Seamless integration with Next.js `fetch` (caching, revalidation, tags).
|
|
22
|
+
- 🔄 **Resilient**: Automatic retries for transient server errors (502, 503, 504).
|
|
23
|
+
- 🧱 **Structured**: Easy-to-use API for Headers, Footers, Blogs, Events, and more.
|
|
24
|
+
- 🎨 **Section Variants**: All page sections support optional `variant` field (e.g., "home-1", "about-2") for flexible conditional styling.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
You can install the SDK directly from private GitHub repository. Ensure you have access before proceeding.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install git+ssh://git@github.com/CrayonsCodeTech/cms-sdk.git
|
|
32
|
+
# or
|
|
33
|
+
pnpm add git+ssh://git@github.com/CrayonsCodeTech/cms-sdk.git --allow-build=@crayons/cms-sdk
|
|
34
|
+
# or
|
|
35
|
+
bun add git+ssh://git@github.com/CrayonsCodeTech/cms-sdk.git && bun pm trust @crayons/cms-sdk
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick Start: Creating a New Next.js App (Cloudflare)
|
|
39
|
+
|
|
40
|
+
If you are starting a new project, we recommend using the Cloudflare Next.js starter which comes with **OpenNext** support out of the box.
|
|
41
|
+
|
|
42
|
+
Run the following command to initialize your app:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm create cloudflare@latest -- my-next-app --framework=next
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
For detailed instructions on deploying Next.js to Cloudflare Workers, refer to the [official Cloudflare documentation](https://developers.cloudflare.com/workers/framework-guides/web-apps/nextjs/).
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Getting Started
|
|
53
|
+
|
|
54
|
+
### 1. Environment Variables
|
|
55
|
+
|
|
56
|
+
Create a `.env.local` file in your root directory with the following variables:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
NEXT_PUBLIC_CMS_BASE_URL=https://api.yourcms.com
|
|
60
|
+
NEXT_PUBLIC_CMS_SITE_ID=your-site-id-here
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
> **Development Environment**
|
|
64
|
+
>
|
|
65
|
+
> Visit [cms.deployown.com/login](https://cms.deployown.com/login) to view and edit data for the website.
|
|
66
|
+
>
|
|
67
|
+
> Use the following credentials to log in:
|
|
68
|
+
>
|
|
69
|
+
> | Field | Value |
|
|
70
|
+
> | -------- | --------------------- |
|
|
71
|
+
> | Username | `development` |
|
|
72
|
+
> | Password | _(provided by admin)_ |
|
|
73
|
+
>
|
|
74
|
+
> Add these to your `.env.local`:
|
|
75
|
+
>
|
|
76
|
+
> ```bash
|
|
77
|
+
> NEXT_PUBLIC_CMS_BASE_URL=https://api.cms.deployown.com
|
|
78
|
+
> NEXT_PUBLIC_CMS_SITE_ID=30de3c6b-70bd-45dd-a0bd-58143f738902
|
|
79
|
+
> ```
|
|
80
|
+
|
|
81
|
+
### 2. Initialization
|
|
82
|
+
|
|
83
|
+
It is recommended to create a singleton instance of the CMS client in your project (e.g., `lib/cms.ts`).
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
import { createCmsClient } from "@crayons/cms-sdk";
|
|
87
|
+
|
|
88
|
+
export const cms = createCmsClient({
|
|
89
|
+
baseUrl: process.env.NEXT_PUBLIC_CMS_BASE_URL || "https://api.example.com",
|
|
90
|
+
defaultOptions: {
|
|
91
|
+
revalidate: 3600, // Default 1 hour cache
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const SITE_ID = process.env.NEXT_PUBLIC_CMS_SITE_ID || "";
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 3. Usage in Components
|
|
99
|
+
|
|
100
|
+
#### Conditional Rendering — Header & Footer Inner Data
|
|
101
|
+
|
|
102
|
+
`fetchHeader` and `fetchFooter` return `null` on failure. Inside your components, guard each field individually since arrays may be empty and optional fields may be absent.
|
|
103
|
+
|
|
104
|
+
**SiteHeader example:**
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
// components/site-header.tsx
|
|
108
|
+
import type { Header, SiteConfig } from "@crayons/cms-sdk";
|
|
109
|
+
import Link from "next/link";
|
|
110
|
+
|
|
111
|
+
interface Props {
|
|
112
|
+
header: Header;
|
|
113
|
+
siteConfig: SiteConfig | null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function SiteHeader({ header, siteConfig }: Props) {
|
|
117
|
+
return (
|
|
118
|
+
<nav>
|
|
119
|
+
{/* Logo — use logo.logo_primary (light) or logo.logo_dark as needed */}
|
|
120
|
+
{siteConfig?.logo.logo_primary && (
|
|
121
|
+
<Link href="/">
|
|
122
|
+
<img
|
|
123
|
+
src={siteConfig.logo.logo_primary}
|
|
124
|
+
alt={siteConfig.site_name ?? "Logo"}
|
|
125
|
+
/>
|
|
126
|
+
</Link>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{/* Nav links — each link may have nested children */}
|
|
130
|
+
{header.nav_links.length > 0 && (
|
|
131
|
+
<ul>
|
|
132
|
+
{header.nav_links.map((link) => (
|
|
133
|
+
<li key={link.url}>
|
|
134
|
+
<Link href={link.url}>{link.title}</Link>
|
|
135
|
+
|
|
136
|
+
{/* Dropdown children — only render if they exist */}
|
|
137
|
+
{link.children && link.children.length > 0 && (
|
|
138
|
+
<ul>
|
|
139
|
+
{link.children.map((child) => (
|
|
140
|
+
<li key={child.url}>
|
|
141
|
+
<Link href={child.url}>{child.title}</Link>
|
|
142
|
+
</li>
|
|
143
|
+
))}
|
|
144
|
+
</ul>
|
|
145
|
+
)}
|
|
146
|
+
</li>
|
|
147
|
+
))}
|
|
148
|
+
</ul>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{/* CTAs — map through the array, skip if empty */}
|
|
152
|
+
{header.ctas.length > 0 && (
|
|
153
|
+
<div>
|
|
154
|
+
{header.ctas.map((cta) => (
|
|
155
|
+
<Link key={cta.title_url} href={cta.title_url}>
|
|
156
|
+
{cta.title}
|
|
157
|
+
</Link>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
</nav>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**SiteFooter example:**
|
|
167
|
+
|
|
168
|
+
```tsx
|
|
169
|
+
// components/site-footer.tsx
|
|
170
|
+
import type { Footer } from "@crayons/cms-sdk";
|
|
171
|
+
import Link from "next/link";
|
|
172
|
+
|
|
173
|
+
export function SiteFooter({ footer }: { footer: Footer }) {
|
|
174
|
+
return (
|
|
175
|
+
<footer>
|
|
176
|
+
{/* Nav groups — each group has a name and a list of links */}
|
|
177
|
+
{footer.nav_groups.length > 0 && (
|
|
178
|
+
<div>
|
|
179
|
+
{footer.nav_groups.map((group) => (
|
|
180
|
+
<div key={group.name}>
|
|
181
|
+
<h4>{group.name}</h4>
|
|
182
|
+
|
|
183
|
+
{group.links.length > 0 && (
|
|
184
|
+
<ul>
|
|
185
|
+
{group.links.map((link) => (
|
|
186
|
+
<li key={link.url}>
|
|
187
|
+
<Link href={link.url}>{link.title}</Link>
|
|
188
|
+
|
|
189
|
+
{/* Nested children under each footer link */}
|
|
190
|
+
{link.children && link.children.length > 0 && (
|
|
191
|
+
<ul>
|
|
192
|
+
{link.children.map((child) => (
|
|
193
|
+
<li key={child.url}>
|
|
194
|
+
<Link href={child.url}>{child.title}</Link>
|
|
195
|
+
</li>
|
|
196
|
+
))}
|
|
197
|
+
</ul>
|
|
198
|
+
)}
|
|
199
|
+
</li>
|
|
200
|
+
))}
|
|
201
|
+
</ul>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</footer>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**Using `SiteConfig` contact & social data in the footer:**
|
|
213
|
+
|
|
214
|
+
`SiteConfig` also carries the site's contact details and social links — render these directly in the footer rather than hardcoding them.
|
|
215
|
+
|
|
216
|
+
```tsx
|
|
217
|
+
// Destructure the fields you need from siteConfig
|
|
218
|
+
const { site_name, logo, contact } = siteConfig;
|
|
219
|
+
|
|
220
|
+
// Phone numbers (array)
|
|
221
|
+
{
|
|
222
|
+
contact.phone_number.map((phone) => (
|
|
223
|
+
<a key={phone} href={`tel:${phone}`}>
|
|
224
|
+
{phone}
|
|
225
|
+
</a>
|
|
226
|
+
));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Emails (array)
|
|
230
|
+
{
|
|
231
|
+
contact.email.map((email) => (
|
|
232
|
+
<a key={email} href={`mailto:${email}`}>
|
|
233
|
+
{email}
|
|
234
|
+
</a>
|
|
235
|
+
));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Social links
|
|
239
|
+
{
|
|
240
|
+
contact.socials.map((social) => (
|
|
241
|
+
<a
|
|
242
|
+
key={social.site_name}
|
|
243
|
+
href={social.link}
|
|
244
|
+
target="_blank"
|
|
245
|
+
rel="noopener noreferrer"
|
|
246
|
+
>
|
|
247
|
+
{social.site_name}
|
|
248
|
+
</a>
|
|
249
|
+
));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Location (optional)
|
|
253
|
+
{
|
|
254
|
+
contact.location?.location.map((line) => <p key={line}>{line}</p>);
|
|
255
|
+
}
|
|
256
|
+
{
|
|
257
|
+
contact.location?.google_maps_url && (
|
|
258
|
+
<a
|
|
259
|
+
href={contact.location.google_maps_url}
|
|
260
|
+
target="_blank"
|
|
261
|
+
rel="noopener noreferrer"
|
|
262
|
+
>
|
|
263
|
+
View on Map
|
|
264
|
+
</a>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
> `siteConfig` can be `null` if the fetch fails, so always guard it at the layout level and pass it down only if it exists.
|
|
270
|
+
|
|
271
|
+
#### 4. Icon Component (Lucide)
|
|
272
|
+
|
|
273
|
+
The SDK provides a built-in `Icon` component to render CMS-driven icons. It uses `lucide-react` under the hood.
|
|
274
|
+
|
|
275
|
+
```tsx
|
|
276
|
+
import { Icon } from "@crayons/cms-sdk";
|
|
277
|
+
|
|
278
|
+
export function FeatureItem({
|
|
279
|
+
iconName,
|
|
280
|
+
title,
|
|
281
|
+
}: {
|
|
282
|
+
iconName: string;
|
|
283
|
+
title: string;
|
|
284
|
+
}) {
|
|
285
|
+
return (
|
|
286
|
+
<div>
|
|
287
|
+
{/* Renders the Lucide icon by name, falling back to HelpCircle if not found */}
|
|
288
|
+
<Icon name={iconName} size={24} className="text-primary" />
|
|
289
|
+
<h3>{title}</h3>
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
> **Requirements**: To use the `Icon` component, you must have `lucide-react` and `react` installed in your project.
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
#### Advanced UI Implementation (Recommended)
|
|
300
|
+
|
|
301
|
+
For production-grade applications, we recommend a declarative approach using a **Registry** and **Router**. This pattern removes the need for hardcoded folders (like `/blog` or `/services`) and handles all CMS-driven URLs dynamically.
|
|
302
|
+
|
|
303
|
+
##### 1. Declarative Page Registry
|
|
304
|
+
|
|
305
|
+
Map CMS `page_type` strings to their corresponding React components. This centralizes your UI mapping.
|
|
306
|
+
|
|
307
|
+
```tsx
|
|
308
|
+
// lib/cms-registry.ts
|
|
309
|
+
import HomePage from "@/components/pages/HomePage";
|
|
310
|
+
import AboutPage from "@/components/pages/AboutPage";
|
|
311
|
+
import BlogsPage from "@/components/pages/BlogPage";
|
|
312
|
+
import ServicesPage from "@/components/pages/ServicesPage";
|
|
313
|
+
import EventsPage from "@/components/pages/EventPage";
|
|
314
|
+
import GalleryPage from "@/components/pages/GalleryPage";
|
|
315
|
+
import TeamPage from "@/components/pages/TeamPage";
|
|
316
|
+
import ContactPage from "@/components/pages/ContactPage";
|
|
317
|
+
import CustomPage from "@/components/pages/CustomPage";
|
|
318
|
+
import ProductsPage from "@/components/pages/ProductsPage";
|
|
319
|
+
|
|
320
|
+
// Detail views (sub-pages)
|
|
321
|
+
import BlogDetailPage from "@/components/pages/BlogDetailPage";
|
|
322
|
+
import ServiceDetailPage from "@/components/pages/ServiceDetailPage";
|
|
323
|
+
import EventDetailPage from "@/components/pages/EventDetailPage";
|
|
324
|
+
import GalleryDetailPage from "@/components/pages/GalleryDetailPage";
|
|
325
|
+
import TeamMemberDetailPage from "@/components/pages/TeamMemberDetailPage";
|
|
326
|
+
import TeamCategoryPage from "@/components/pages/TeamCategoryPage";
|
|
327
|
+
import ProductDetailPage from "@/components/pages/ProductDetailPage";
|
|
328
|
+
|
|
329
|
+
export const PAGE_COMPONENT_MAP: Record<string, any> = {
|
|
330
|
+
home: HomePage,
|
|
331
|
+
about: AboutPage,
|
|
332
|
+
blog: BlogsPage,
|
|
333
|
+
services: ServicesPage,
|
|
334
|
+
events: EventsPage,
|
|
335
|
+
gallery: GalleryPage,
|
|
336
|
+
team: TeamPage,
|
|
337
|
+
contact: ContactPage,
|
|
338
|
+
custom: CustomPage,
|
|
339
|
+
products: ProductsPage,
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// Maps parent page type to its detail component
|
|
343
|
+
export const DETAIL_COMPONENT_MAP: Record<string, any> = {
|
|
344
|
+
blog: BlogDetailPage,
|
|
345
|
+
services: ServiceDetailPage,
|
|
346
|
+
events: EventDetailPage,
|
|
347
|
+
gallery: GalleryDetailPage,
|
|
348
|
+
products: ProductDetailPage,
|
|
349
|
+
team: {
|
|
350
|
+
member: TeamMemberDetailPage,
|
|
351
|
+
category: TeamCategoryPage,
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
##### 2. Route Resolution Helper
|
|
357
|
+
|
|
358
|
+
This utility determines if a URL path is an exact CMS page or a "Detail" page (e.g., a specific blog post).
|
|
359
|
+
|
|
360
|
+
```tsx
|
|
361
|
+
// lib/cms-router.ts
|
|
362
|
+
import { cms, SITE_ID } from "./cms";
|
|
363
|
+
|
|
364
|
+
export async function resolveCmsRoute(slug: string[]) {
|
|
365
|
+
const urlPath = slug.length > 0 ? `/${slug.join("/")}` : "/";
|
|
366
|
+
const exactPage = await cms.fetchPageByUrl(SITE_ID, urlPath);
|
|
367
|
+
|
|
368
|
+
if (exactPage) return { type: "page" as const, data: exactPage };
|
|
369
|
+
|
|
370
|
+
// 2. Check for detail page (walking up the path)
|
|
371
|
+
// Example: /blog/my-post or /news/my-post
|
|
372
|
+
if (slug.length > 0) {
|
|
373
|
+
for (let i = slug.length - 1; i >= 0; i--) {
|
|
374
|
+
const parentPath = "/" + slug.slice(0, i).join("/");
|
|
375
|
+
const parentPage = await cms.fetchPageByUrl(SITE_ID, parentPath || "/");
|
|
376
|
+
|
|
377
|
+
if (parentPage) {
|
|
378
|
+
return {
|
|
379
|
+
type: "detail" as const,
|
|
380
|
+
parentType: parentPage.page_type,
|
|
381
|
+
slug: slug.slice(i), // e.g., ["my-post-slug"]
|
|
382
|
+
parentUrl: parentPage.url,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
##### 3. Unified Catch-All Route
|
|
393
|
+
|
|
394
|
+
Using the registry and router, your `app/[[...slug]]/page.tsx` handles every route dynamically.
|
|
395
|
+
|
|
396
|
+
```tsx
|
|
397
|
+
// app/[[...slug]]/page.tsx
|
|
398
|
+
import { notFound } from "next/navigation";
|
|
399
|
+
import { resolveCmsRoute } from "@/lib/cms-router";
|
|
400
|
+
import { PAGE_COMPONENT_MAP, DETAIL_COMPONENT_MAP } from "@/lib/cms-registry";
|
|
401
|
+
|
|
402
|
+
export default async function CatchAllPage({
|
|
403
|
+
params,
|
|
404
|
+
}: {
|
|
405
|
+
params: Promise<{ slug?: string[] }>;
|
|
406
|
+
}) {
|
|
407
|
+
const { slug = [] } = await params;
|
|
408
|
+
const resolution = await resolveCmsRoute(slug);
|
|
409
|
+
|
|
410
|
+
if (!resolution) notFound();
|
|
411
|
+
|
|
412
|
+
if (resolution.type === "page") {
|
|
413
|
+
const Component =
|
|
414
|
+
PAGE_COMPONENT_MAP[resolution.data.page_type] ||
|
|
415
|
+
PAGE_COMPONENT_MAP.custom;
|
|
416
|
+
return <Component page={resolution.data} />;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (resolution.type === "detail") {
|
|
420
|
+
const Component = DETAIL_COMPONENT_MAP[resolution.parentType];
|
|
421
|
+
if (!Component) notFound();
|
|
422
|
+
|
|
423
|
+
// Special handling for nested detail types (like Team)
|
|
424
|
+
if (resolution.parentType === "team") {
|
|
425
|
+
if (resolution.slug.length === 1) {
|
|
426
|
+
return (
|
|
427
|
+
<Component.category
|
|
428
|
+
params={Promise.resolve({ category: resolution.slug[0] })}
|
|
429
|
+
/>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
return (
|
|
433
|
+
<Component.member
|
|
434
|
+
params={Promise.resolve({
|
|
435
|
+
category: resolution.slug[0],
|
|
436
|
+
slug: resolution.slug[1],
|
|
437
|
+
})}
|
|
438
|
+
/>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return (
|
|
443
|
+
<Component
|
|
444
|
+
params={Promise.resolve({ slug: resolution.slug[0] })}
|
|
445
|
+
parentUrl={resolution.parentUrl}
|
|
446
|
+
/>
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
notFound();
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
#### Dynamic Page Sections — `RenderSections` Component
|
|
455
|
+
|
|
456
|
+
The `RenderSections` component is the core rendering primitive. It receives `page.sections` and maps each section type to its component. Every known section type from the CMS is handled; unknown types are warned and skipped.
|
|
457
|
+
|
|
458
|
+
> **Important:** All section types include an optional `variant` field (e.g., `"home-1"`, `"about-1"`, `"contact-2"`). Use this for conditional rendering to create different visual styles of the same section type. See the example below for how to handle variants.
|
|
459
|
+
|
|
460
|
+
```tsx
|
|
461
|
+
// components/render-sections.tsx
|
|
462
|
+
import type { Section } from "@crayons/cms-sdk";
|
|
463
|
+
|
|
464
|
+
// Import base section components
|
|
465
|
+
import { HeroSection } from "@/components/sections/hero";
|
|
466
|
+
import { CustomSection } from "@/components/sections/custom";
|
|
467
|
+
import { CtaSection } from "@/components/sections/cta";
|
|
468
|
+
import { ServiceSection } from "@/components/sections/service";
|
|
469
|
+
import { TestimonialSection } from "@/components/sections/testimonial";
|
|
470
|
+
import { MultiValueSection } from "@/components/sections/multi-value";
|
|
471
|
+
import { TeamSection } from "@/components/sections/team";
|
|
472
|
+
import { ClientsSection } from "@/components/sections/clients";
|
|
473
|
+
import { GallerySection } from "@/components/sections/gallery";
|
|
474
|
+
import { EventSection } from "@/components/sections/event";
|
|
475
|
+
import { BlogSection } from "@/components/sections/blog";
|
|
476
|
+
import { RichContentSection } from "@/components/sections/rich-content";
|
|
477
|
+
import { AboutSection } from "@/components/sections/about";
|
|
478
|
+
import { FaqSection } from "@/components/sections/faq";
|
|
479
|
+
import { MarqueeSection } from "@/components/sections/marquee";
|
|
480
|
+
import { HistorySection } from "@/components/sections/history";
|
|
481
|
+
import { ProductsSection } from "@/components/sections/products";
|
|
482
|
+
import { CollectionGroupSection } from "@/components/sections/collection-group";
|
|
483
|
+
|
|
484
|
+
// Import variant components as needed (example imports)
|
|
485
|
+
import { HeroDark } from "@/components/sections/hero-dark";
|
|
486
|
+
import { HeroCentered } from "@/components/sections/hero-centered";
|
|
487
|
+
import { CtaPrimary } from "@/components/sections/cta-primary";
|
|
488
|
+
|
|
489
|
+
export function RenderSections({ sections }: { sections: Section[] }) {
|
|
490
|
+
return (
|
|
491
|
+
<>
|
|
492
|
+
{sections.map((section) => {
|
|
493
|
+
// Each section has: { id, type, variant?, content }
|
|
494
|
+
// - id: auto-generated identifier (e.g., "hero-1", "custom-2", "cta-3")
|
|
495
|
+
// - type: section type discriminator (e.g., "hero", "custom", "cta")
|
|
496
|
+
// - variant: optional style variant (e.g., "home-1", "home-2", "about-1")
|
|
497
|
+
// - content: the actual content data for that section
|
|
498
|
+
//
|
|
499
|
+
// Example section object:
|
|
500
|
+
// { id: "hero-1", type: "hero", variant: "home-1", content: [...] }
|
|
501
|
+
switch (section.type) {
|
|
502
|
+
case "hero":
|
|
503
|
+
// Variant-aware rendering: check section.variant for conditional styling
|
|
504
|
+
if (section.variant === "home-1") {
|
|
505
|
+
return <HeroDark key={section.id} content={section.content} />;
|
|
506
|
+
}
|
|
507
|
+
if (section.variant === "home-2") {
|
|
508
|
+
return <HeroCentered key={section.id} content={section.content} />;
|
|
509
|
+
}
|
|
510
|
+
// Default fallback when variant is undefined/null
|
|
511
|
+
return <HeroSection key={section.id} content={section.content} />;
|
|
512
|
+
|
|
513
|
+
case "custom":
|
|
514
|
+
return <CustomSection key={section.id} content={section.content} />;
|
|
515
|
+
|
|
516
|
+
case "cta":
|
|
517
|
+
// Example: different CTA styles based on variant
|
|
518
|
+
if (section.variant === "home-1") {
|
|
519
|
+
return <CtaPrimary key={section.id} content={section.content} />;
|
|
520
|
+
}
|
|
521
|
+
return <CtaSection key={section.id} content={section.content} />;
|
|
522
|
+
|
|
523
|
+
case "service":
|
|
524
|
+
return <ServiceSection key={section.id} content={section.content} />;
|
|
525
|
+
|
|
526
|
+
case "testimonial":
|
|
527
|
+
return <TestimonialSection key={section.id} content={section.content} />;
|
|
528
|
+
|
|
529
|
+
case "multi-value":
|
|
530
|
+
return <MultiValueSection key={section.id} content={section.content} />;
|
|
531
|
+
|
|
532
|
+
case "team":
|
|
533
|
+
return <TeamSection key={section.id} content={section.content} />;
|
|
534
|
+
|
|
535
|
+
case "clients":
|
|
536
|
+
return <ClientsSection key={section.id} content={section.content} />;
|
|
537
|
+
|
|
538
|
+
case "gallery":
|
|
539
|
+
return <GallerySection key={section.id} content={section.content} />;
|
|
540
|
+
|
|
541
|
+
case "event":
|
|
542
|
+
return <EventSection key={section.id} content={section.content} />;
|
|
543
|
+
|
|
544
|
+
case "blog":
|
|
545
|
+
return <BlogSection key={section.id} content={section.content} />;
|
|
546
|
+
|
|
547
|
+
case "rich-content":
|
|
548
|
+
return <RichContentSection key={section.id} content={section.content} />;
|
|
549
|
+
|
|
550
|
+
case "about":
|
|
551
|
+
return <AboutSection key={section.id} content={section.content} />;
|
|
552
|
+
|
|
553
|
+
case "faq":
|
|
554
|
+
return <FaqSection key={section.id} content={section.content} />;
|
|
555
|
+
|
|
556
|
+
case "marquee":
|
|
557
|
+
return <MarqueeSection key={section.id} content={section.content} />;
|
|
558
|
+
|
|
559
|
+
case "history":
|
|
560
|
+
return <HistorySection key={section.id} content={section.content} />;
|
|
561
|
+
|
|
562
|
+
case "products":
|
|
563
|
+
return <ProductsSection key={section.id} content={section.content} />;
|
|
564
|
+
|
|
565
|
+
case "collection-group":
|
|
566
|
+
return (
|
|
567
|
+
<CollectionGroupSection key={section.id} content={section.content} />
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
default:
|
|
571
|
+
console.warn(`Unknown section type: ${(section as any).type}`);
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
})}
|
|
575
|
+
</>
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
> **Note:** Each section includes:
|
|
581
|
+
> - **`id`**: Auto-generated unique identifier in format `{type}-{count}` (e.g., `"hero-1"`, `"hero-2"`, `"custom-1"`, `"cta-3"`). Useful for targeting specific sections or debugging.
|
|
582
|
+
> - **`variant`**: Optional style variant (e.g., `"home-1"`, `"home-2"`, `"about-1"`) for conditional styling.
|
|
583
|
+
>
|
|
584
|
+
> Always provide a default fallback when `section.variant` is undefined or null. If your design doesn't use variants, you can simplify the switch cases to just render single components per type. Use `section.id` when you need to target or reference specific sections programmatically.
|
|
585
|
+
|
|
586
|
+
#### Data-Driven Section Components
|
|
587
|
+
|
|
588
|
+
Several section types only carry **display text** (headings, subtitles) in `section.content`. The actual entity data must be fetched separately and passed into the section component. This is the same pattern as services, blogs, and events — just applied inside individual section components.
|
|
589
|
+
|
|
590
|
+
| Section name | `type` discriminant | Content type | Primary table/entity | API call(s) needed |
|
|
591
|
+
| ---------------- | ------------------- | --------------------- | ----------------------------- | ------------------------------------------------------------------------------------------- |
|
|
592
|
+
| Hero | `"hero"` | `HeroContent[]` | `page.sections` (from `page`) | None — content is inline |
|
|
593
|
+
| Custom | `"custom"` | `CustomContent` | `page.sections` (from `page`) | None — content is inline |
|
|
594
|
+
| Call to Action | `"cta"` | `CTAContent` | `page.sections` (from `page`) | None — content is inline |
|
|
595
|
+
| Rich Content | `"rich-content"` | `RichContentSection` | `page.sections` (from `page`) | None — content is inline |
|
|
596
|
+
| About | `"about"` | `AboutSection` | `page.sections` + `about-us` | `fetchAboutUs(siteId)` for profile/vision/mission/stats |
|
|
597
|
+
| Multi Value | `"multi-value"` | `MultiValueSection` | `page.sections` (from `page`) | None — content is inline |
|
|
598
|
+
| Services | `"service"` | `ServicesSection` | `services` | `fetchServices(siteId)` |
|
|
599
|
+
| Testimonials | `"testimonial"` | `TestimonialsSection` | `testimonials` | `fetchTestimonials(siteId, { type })` — use `content.type` to filter |
|
|
600
|
+
| Team | `"team"` | `TeamSection` | `team-members` | `fetchTeamMembers(siteId)` / `fetchTeamMembersByCategory(siteId, content.team_category_id)` |
|
|
601
|
+
| FAQ | `"faq"` | `FaqSection` | `faq-groups` + `faqs` | `fetchFaqGroups(siteId)` or `fetchFaqs(siteId, { group_id: content.group_id })` |
|
|
602
|
+
| Clients / Brands | `"clients"` | `ClientsSection` | `brand-groups` + `brands` | `fetchBrandGroups(siteId)` + `fetchBrands(siteId, { group_id: content.brand_group_id })` |
|
|
603
|
+
| Gallery | `"gallery"` | `GallerySection` | `albums` + `album-items` | `fetchAlbums(siteId)` + `fetchAlbumItems(siteId, { album_id })` as needed |
|
|
604
|
+
| Events | `"event"` | `GenericSection` | `events` | `fetchEvents(siteId, { page, limit, search })` |
|
|
605
|
+
| Blog | `"blog"` | `GenericSection` | `blog` | `fetchBlogs(siteId, { page, limit, search })` |
|
|
606
|
+
| Products | `"products"` | `ProductsSection` | `products` / `collections` | `fetchProducts(...)` or `fetchCollectionDetailById(...)`, depending on `content.filter` |
|
|
607
|
+
| Collection Group | `"collection-group"` | `CollectionGroupSection` | `collections` | `fetchCollections(siteId, { id: content.collection_groups.join(',') })` |
|
|
608
|
+
| Marquee | `"marquee"` | `MarqueeSection` | `page.sections` (from `page`) | None — content is inline |
|
|
609
|
+
| History | `"history"` | `HistorySection` | `page.sections` (from `page`) | None — content is inline |
|
|
610
|
+
|
|
611
|
+
**How to handle this in section components:**
|
|
612
|
+
|
|
613
|
+
Each section component receives its own `content` prop from `RenderSections`. When it needs live entity data, it fetches it itself using the ID or type from `content`.
|
|
614
|
+
|
|
615
|
+
For the new store-aware sections:
|
|
616
|
+
|
|
617
|
+
- `products` content uses `filter` plus one of `collection_id`, `category_id`, or `tag_id`, along with `limit`
|
|
618
|
+
- `collection-group` content uses `collection_groups: string[]`
|
|
619
|
+
|
|
620
|
+
```tsx
|
|
621
|
+
// components/sections/testimonial.tsx
|
|
622
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
623
|
+
import type { TestimonialsSection } from "@crayons/cms-sdk";
|
|
624
|
+
|
|
625
|
+
interface Props {
|
|
626
|
+
content: TestimonialsSection;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export async function TestimonialSection({ content }: Props) {
|
|
630
|
+
// content.type is "testimonial" | "review" — use it to filter
|
|
631
|
+
const testimonials = await cms.fetchTestimonials(SITE_ID, {
|
|
632
|
+
type: content.type as "testimonial" | "review",
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
return (
|
|
636
|
+
<section>
|
|
637
|
+
<h2>{content.title}</h2>
|
|
638
|
+
{content.subtitle && <p>{content.subtitle}</p>}
|
|
639
|
+
|
|
640
|
+
{testimonials.map((t) => (
|
|
641
|
+
<blockquote key={t.id}>
|
|
642
|
+
{t.image_url && <img src={t.image_url} alt={t.image_alt ?? t.name} />}
|
|
643
|
+
<p>{t.quote}</p>
|
|
644
|
+
<cite>
|
|
645
|
+
{t.name}
|
|
646
|
+
{t.position && `, ${t.position}`}
|
|
647
|
+
{t.company && ` — ${t.company}`}
|
|
648
|
+
</cite>
|
|
649
|
+
</blockquote>
|
|
650
|
+
))}
|
|
651
|
+
</section>
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
```tsx
|
|
657
|
+
// components/sections/team.tsx
|
|
658
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
659
|
+
import type { TeamSection } from "@crayons/cms-sdk";
|
|
660
|
+
|
|
661
|
+
export async function TeamSection({ content }: { content: TeamSection }) {
|
|
662
|
+
const members = await cms.fetchTeamMembers(SITE_ID);
|
|
663
|
+
|
|
664
|
+
return (
|
|
665
|
+
<section>
|
|
666
|
+
<h2>{content.title}</h2>
|
|
667
|
+
{content.subtitle && <p>{content.subtitle}</p>}
|
|
668
|
+
|
|
669
|
+
<div className="grid">
|
|
670
|
+
{members.map((member) => (
|
|
671
|
+
<div key={member.id}>
|
|
672
|
+
{member.profile_image && (
|
|
673
|
+
<img src={member.profile_image} alt={member.name} />
|
|
674
|
+
)}
|
|
675
|
+
<h3>{member.name}</h3>
|
|
676
|
+
{member.position && <p>{member.position}</p>}
|
|
677
|
+
{member.socials && member.socials.length > 0 && (
|
|
678
|
+
<ul>
|
|
679
|
+
{member.socials.map(
|
|
680
|
+
(s) =>
|
|
681
|
+
s.url && (
|
|
682
|
+
<li key={s.platform}>
|
|
683
|
+
<a
|
|
684
|
+
href={s.url}
|
|
685
|
+
target="_blank"
|
|
686
|
+
rel="noopener noreferrer"
|
|
687
|
+
>
|
|
688
|
+
{s.platform}
|
|
689
|
+
</a>
|
|
690
|
+
</li>
|
|
691
|
+
),
|
|
692
|
+
)}
|
|
693
|
+
</ul>
|
|
694
|
+
)}
|
|
695
|
+
</div>
|
|
696
|
+
))}
|
|
697
|
+
</div>
|
|
698
|
+
</section>
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
```tsx
|
|
704
|
+
// components/sections/faq.tsx
|
|
705
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
706
|
+
import type { FaqSection } from "@crayons/cms-sdk";
|
|
707
|
+
|
|
708
|
+
export async function FaqSection({ content }: { content: FaqSection }) {
|
|
709
|
+
// Fetch the specific group if a group_id is set, otherwise fetch all
|
|
710
|
+
const groups = content.group_id
|
|
711
|
+
? await cms.fetchFaqGroups(SITE_ID)
|
|
712
|
+
: await cms.fetchFaqGroups(SITE_ID);
|
|
713
|
+
|
|
714
|
+
const targetGroup = content.group_id
|
|
715
|
+
? groups.find((g) => g.id === content.group_id)
|
|
716
|
+
: null;
|
|
717
|
+
|
|
718
|
+
const faqs = targetGroup ? targetGroup.faqs : await cms.fetchFaqs(SITE_ID);
|
|
719
|
+
|
|
720
|
+
return (
|
|
721
|
+
<section>
|
|
722
|
+
<h2>{content.title}</h2>
|
|
723
|
+
{content.subtitle && <p>{content.subtitle}</p>}
|
|
724
|
+
|
|
725
|
+
<dl>
|
|
726
|
+
{faqs.map((faq) => (
|
|
727
|
+
<div key={faq.id}>
|
|
728
|
+
<dt>{faq.question}</dt>
|
|
729
|
+
<dd>{faq.answer}</dd>
|
|
730
|
+
</div>
|
|
731
|
+
))}
|
|
732
|
+
</dl>
|
|
733
|
+
</section>
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
> Section components that fetch their own data must be **async server components**. This works because `RenderSections` itself is also a server component — you can `await` inside any section component freely.
|
|
739
|
+
|
|
740
|
+
---
|
|
741
|
+
|
|
742
|
+
## Page Architecture
|
|
743
|
+
|
|
744
|
+
Different page types follow different rendering strategies. Understanding these patterns is key to building correctly.
|
|
745
|
+
|
|
746
|
+
### Page Fetch Map (Route -> Table/Entity -> SDK Fetch)
|
|
747
|
+
|
|
748
|
+
| Route | Primary tables/entities | Required fetch call(s) |
|
|
749
|
+
| ----------------------- | --------------------------------- | ------------------------------------------------------------------------------ |
|
|
750
|
+
| `/` (home) | `page` (+ inline `page.sections`) | `fetchPageByUrl(siteId, "/")` |
|
|
751
|
+
| `[[...slug]]` CMS pages | `page` (+ inline `page.sections`) | `fetchPageByUrl(siteId, urlPath)` |
|
|
752
|
+
| `/about` | `page` + `about-us` | `fetchPageByUrl(siteId, "/about")` + `fetchAboutUs(siteId)` |
|
|
753
|
+
| `/services` | `page` + `services` | `fetchPageByUrl(siteId, "/services")` + `fetchServices(siteId)` |
|
|
754
|
+
| `/services/[slug]` | `services` | `fetchServices(siteId)` (slug lookup) or `fetchServiceById(siteId, id)` |
|
|
755
|
+
| `/blog` or `/news` | `page` + `blog` | `fetchPageByUrl(siteId, "/blog")` (or your CMS-defined base url) + `fetchBlogs(siteId, params)` |
|
|
756
|
+
| `/blog/[slug]` | `blog` | `fetchBlogBySlug(siteId, slug)` |
|
|
757
|
+
| `/events` | `page` + `events` | `fetchPageByUrl(siteId, "/events")` + `fetchEvents(siteId, params)` |
|
|
758
|
+
| `/events/[slug]` | `events` | `fetchEvents(siteId, { limit })` (slug lookup) or `fetchEventById(siteId, id)` |
|
|
759
|
+
| `/gallery` | `page` + `albums` | `fetchPageByUrl(siteId, "/gallery")` + `fetchAlbums(siteId, params)` |
|
|
760
|
+
| `/gallery/[slug]` | `albums` + `album-items` | `fetchAlbums(siteId, { limit })` + `fetchAlbumItems(siteId, { album: slug })` |
|
|
761
|
+
| `/team/[slug]` | `team-members` | `fetchTeamMembers(siteId)` (slug lookup) or custom `fetch` |
|
|
762
|
+
| `/contact` | `contact` (form submissions) | `submitContactForm(siteId, payload)` |
|
|
763
|
+
|
|
764
|
+
### Home Page — Section Rendering with Targeting
|
|
765
|
+
|
|
766
|
+
The home page is a CMS-managed page (`page_type: "home"`). It uses `RenderSections` to render its sections in order. However, because the home page often needs precise control over layout (e.g. placing a specific section above the fold, or inserting non-CMS UI between sections), you can target sections by **type + index** or by **section id** instead of blindly rendering all sections in sequence.
|
|
767
|
+
|
|
768
|
+
**Option A — target by section type and index:**
|
|
769
|
+
|
|
770
|
+
```tsx
|
|
771
|
+
// components/pages/HomePage.tsx
|
|
772
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
773
|
+
import { notFound } from "next/navigation";
|
|
774
|
+
import { HeroSection } from "@/components/sections/hero";
|
|
775
|
+
import { RenderSections } from "@/components/render-sections";
|
|
776
|
+
import type { Page } from "@crayons/cms-sdk";
|
|
777
|
+
|
|
778
|
+
export default async function HomePage({ page }: { page: Page }) {
|
|
779
|
+
const { sections } = page;
|
|
780
|
+
|
|
781
|
+
// Pull specific sections out by type for precise placement
|
|
782
|
+
const heroSections = sections.filter((s) => s.type === "hero");
|
|
783
|
+
const remainingSections = sections.filter((s) => s.type !== "hero");
|
|
784
|
+
|
|
785
|
+
return (
|
|
786
|
+
<>
|
|
787
|
+
{/* Render the first hero section at the top of the page */}
|
|
788
|
+
{heroSections[0] && <HeroSection content={heroSections[0].content} />}
|
|
789
|
+
|
|
790
|
+
{/* Your own custom UI can go here between sections */}
|
|
791
|
+
|
|
792
|
+
{/* Render the rest of the sections in CMS order */}
|
|
793
|
+
<RenderSections sections={remainingSections} />
|
|
794
|
+
</>
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
**Option B — target by section id:**
|
|
800
|
+
|
|
801
|
+
```tsx
|
|
802
|
+
// Pull a specific section by its id (visible in the CMS dashboard)
|
|
803
|
+
const featuredSection = sections.find((s) => s.id === "your-section-id");
|
|
804
|
+
const otherSections = sections.filter((s) => s.id !== "your-section-id");
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
> For most home pages, just rendering all sections with `<RenderSections sections={page.sections} />` in CMS order is the simplest and correct approach. Only reach for targeting when the design requires it.
|
|
808
|
+
|
|
809
|
+
---
|
|
810
|
+
|
|
811
|
+
### Custom Pages — Render Sections in Order
|
|
812
|
+
|
|
813
|
+
Pages with `page_type: "custom"` (e.g. About, Pricing, any landing page) are fully CMS-driven. Render their sections exactly as they come — no targeting or special logic needed.
|
|
814
|
+
|
|
815
|
+
```tsx
|
|
816
|
+
// This is handled automatically by the [[...slug]] catch-all route.
|
|
817
|
+
// The page component just passes sections straight through:
|
|
818
|
+
return <RenderSections sections={page.sections} />;
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
The CMS editor controls the order and content of all sections. Your job is to make sure every section type is handled in `RenderSections`.
|
|
822
|
+
|
|
823
|
+
---
|
|
824
|
+
|
|
825
|
+
### About Us Page — Page Sections + About Data
|
|
826
|
+
|
|
827
|
+
About pages usually combine:
|
|
828
|
+
|
|
829
|
+
- **Page-managed section chrome** (`section_heading`, `title`, CTA labels) from `fetchPageByUrl("/about")`
|
|
830
|
+
- **Actual about content** (company profile, vision, mission, stats, values) from `fetchAboutUs`
|
|
831
|
+
|
|
832
|
+
```tsx
|
|
833
|
+
// components/pages/AboutPage.tsx
|
|
834
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
835
|
+
import { notFound } from "next/navigation";
|
|
836
|
+
import SafeHtml from "@/components/safe-html";
|
|
837
|
+
import type { Page, SiteConfig } from "@crayons/cms-sdk";
|
|
838
|
+
|
|
839
|
+
export default async function AboutPage({
|
|
840
|
+
page,
|
|
841
|
+
site,
|
|
842
|
+
}: {
|
|
843
|
+
page: Page;
|
|
844
|
+
site?: SiteConfig | null;
|
|
845
|
+
}) {
|
|
846
|
+
const about = await cms.fetchAboutUs(SITE_ID);
|
|
847
|
+
if (!about) notFound();
|
|
848
|
+
|
|
849
|
+
const aboutSection = page.sections.find((s) => s.type === "about");
|
|
850
|
+
|
|
851
|
+
return (
|
|
852
|
+
<main>
|
|
853
|
+
{aboutSection && (
|
|
854
|
+
<header>
|
|
855
|
+
<p>{aboutSection.content.section_heading}</p>
|
|
856
|
+
<h1>{aboutSection.content.title}</h1>
|
|
857
|
+
</header>
|
|
858
|
+
)}
|
|
859
|
+
|
|
860
|
+
<section>
|
|
861
|
+
<h2>Company Profile</h2>
|
|
862
|
+
<SafeHtml html={about.company_profile} />
|
|
863
|
+
</section>
|
|
864
|
+
|
|
865
|
+
<section>
|
|
866
|
+
<h2>Vision</h2>
|
|
867
|
+
<SafeHtml html={about.vision} />
|
|
868
|
+
</section>
|
|
869
|
+
|
|
870
|
+
<section>
|
|
871
|
+
<h2>Mission</h2>
|
|
872
|
+
<SafeHtml html={about.mission} />
|
|
873
|
+
</section>
|
|
874
|
+
|
|
875
|
+
{about.values.length > 0 && (
|
|
876
|
+
<section>
|
|
877
|
+
<h2>Core Values</h2>
|
|
878
|
+
<ul>
|
|
879
|
+
{about.values.map((value) => (
|
|
880
|
+
<li key={value.title}>
|
|
881
|
+
<h3>{value.title}</h3>
|
|
882
|
+
<p>{value.description}</p>
|
|
883
|
+
</li>
|
|
884
|
+
))}
|
|
885
|
+
</ul>
|
|
886
|
+
</section>
|
|
887
|
+
)}
|
|
888
|
+
</main>
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
```
|
|
892
|
+
|
|
893
|
+
---
|
|
894
|
+
|
|
895
|
+
### Services Page — Fetch & Render Service Data
|
|
896
|
+
|
|
897
|
+
The `service` section type on a page provides only CMS-controlled **headings and labels** (e.g. `section_heading`, `title`, `subtitle`). The actual list of services must be fetched separately with `fetchServices`.
|
|
898
|
+
|
|
899
|
+
```tsx
|
|
900
|
+
// components/pages/ServicesPage.tsx
|
|
901
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
902
|
+
import { notFound } from "next/navigation";
|
|
903
|
+
import { ServiceCard } from "@/components/service-card";
|
|
904
|
+
import type { Page } from "@crayons/cms-sdk";
|
|
905
|
+
|
|
906
|
+
export default async function ServicesPage({ page }: { page: Page }) {
|
|
907
|
+
const services = await cms.fetchServices(SITE_ID);
|
|
908
|
+
|
|
909
|
+
// The "service" section from the page carries the heading/subtitle
|
|
910
|
+
const serviceSection = page.sections.find((s) => s.type === "service");
|
|
911
|
+
|
|
912
|
+
return (
|
|
913
|
+
<>
|
|
914
|
+
{serviceSection && (
|
|
915
|
+
<header>
|
|
916
|
+
<h1>{serviceSection.content.title}</h1>
|
|
917
|
+
{serviceSection.content.subtitle && (
|
|
918
|
+
<p>{serviceSection.content.subtitle}</p>
|
|
919
|
+
)}
|
|
920
|
+
</header>
|
|
921
|
+
)}
|
|
922
|
+
|
|
923
|
+
<div className="grid">
|
|
924
|
+
{services.map((service) => (
|
|
925
|
+
<ServiceCard key={service.id} service={service} />
|
|
926
|
+
))}
|
|
927
|
+
</div>
|
|
928
|
+
</>
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
**Service detail page:**
|
|
934
|
+
|
|
935
|
+
```tsx
|
|
936
|
+
// components/pages/ServiceDetailPage.tsx
|
|
937
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
938
|
+
import { notFound } from "next/navigation";
|
|
939
|
+
import { RenderSections } from "@/components/render-sections";
|
|
940
|
+
|
|
941
|
+
export default async function ServiceDetailPage({
|
|
942
|
+
params,
|
|
943
|
+
parentUrl,
|
|
944
|
+
}: {
|
|
945
|
+
params: Promise<{ slug: string }>;
|
|
946
|
+
parentUrl?: string;
|
|
947
|
+
}) {
|
|
948
|
+
const { slug } = await params;
|
|
949
|
+
const services = await cms.fetchServices(SITE_ID);
|
|
950
|
+
const service = services.find((s) => s.slug === slug);
|
|
951
|
+
|
|
952
|
+
if (!service) notFound();
|
|
953
|
+
|
|
954
|
+
return (
|
|
955
|
+
<article>
|
|
956
|
+
{service.image_url && (
|
|
957
|
+
<img src={service.image_url} alt={service.image_alt} />
|
|
958
|
+
)}
|
|
959
|
+
<h1>{service.title}</h1>
|
|
960
|
+
<p>{service.short_description}</p>
|
|
961
|
+
<div dangerouslySetInnerHTML={{ __html: service.description }} />
|
|
962
|
+
{service.features.length > 0 && (
|
|
963
|
+
<ul>
|
|
964
|
+
{service.features.map((f) => (
|
|
965
|
+
<li key={f}>{f}</li>
|
|
966
|
+
))}
|
|
967
|
+
</ul>
|
|
968
|
+
)}
|
|
969
|
+
|
|
970
|
+
{/* Render sections from extra.sections if present */}
|
|
971
|
+
{service.extra?.sections && service.extra.sections.length > 0 && (
|
|
972
|
+
<RenderSections sections={service.extra.sections} />
|
|
973
|
+
)}
|
|
974
|
+
</article>
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
> **Note**: Services can have custom sections stored in `extra.sections`. Use `<RenderSections />` to render them on the detail page. This is optional — if no sections are defined, the service renders normally as shown above.
|
|
980
|
+
|
|
981
|
+
---
|
|
982
|
+
|
|
983
|
+
### Blog Page — Listing & Detail
|
|
984
|
+
|
|
985
|
+
The `blog` section type carries heading/subtitle text only. Fetch the actual posts with `fetchBlogs`.
|
|
986
|
+
|
|
987
|
+
**Listing page:**
|
|
988
|
+
|
|
989
|
+
```tsx
|
|
990
|
+
// components/pages/BlogPage.tsx
|
|
991
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
992
|
+
import { notFound } from "next/navigation";
|
|
993
|
+
import Link from "next/link";
|
|
994
|
+
import type { Page } from "@crayons/cms-sdk";
|
|
995
|
+
|
|
996
|
+
export default async function BlogPage({ page }: { page: Page }) {
|
|
997
|
+
const { data: blogs } = await cms.fetchBlogs(SITE_ID, { page: 1, limit: 12 });
|
|
998
|
+
|
|
999
|
+
const blogSection = page.sections.find((s) => s.type === "blog");
|
|
1000
|
+
|
|
1001
|
+
return (
|
|
1002
|
+
<>
|
|
1003
|
+
{blogSection && <h1>{blogSection.content.title}</h1>}
|
|
1004
|
+
|
|
1005
|
+
<div className="grid">
|
|
1006
|
+
{blogs.map((blog) => (
|
|
1007
|
+
<Link key={blog.id} href={`/blog/${blog.slug}`}>
|
|
1008
|
+
{blog.image_url && (
|
|
1009
|
+
<img src={blog.image_url} alt={blog.image_alt ?? blog.title} />
|
|
1010
|
+
)}
|
|
1011
|
+
<h2>{blog.title}</h2>
|
|
1012
|
+
{blog.excerpt && <p>{blog.excerpt}</p>}
|
|
1013
|
+
</Link>
|
|
1014
|
+
))}
|
|
1015
|
+
</div>
|
|
1016
|
+
</>
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
**Search and category filtering:**
|
|
1022
|
+
|
|
1023
|
+
```tsx
|
|
1024
|
+
// Search — pass the query as a param
|
|
1025
|
+
const { data: results } = await cms.fetchBlogs(SITE_ID, {
|
|
1026
|
+
search: searchQuery,
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
// Category filtering — fetch categories then filter client-side, or show per-category pages
|
|
1030
|
+
const categories = await cms.fetchCategories(SITE_ID);
|
|
1031
|
+
|
|
1032
|
+
// Blogs don't have a direct category_id param — fetch categories for display,
|
|
1033
|
+
// then use them as navigation labels linking to filtered URLs
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
**Blog detail page:**
|
|
1037
|
+
|
|
1038
|
+
```tsx
|
|
1039
|
+
// components/pages/BlogDetailPage.tsx
|
|
1040
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
1041
|
+
import { notFound } from "next/navigation";
|
|
1042
|
+
import { RenderSections } from "@/components/render-sections";
|
|
1043
|
+
|
|
1044
|
+
export default async function BlogDetailPage({
|
|
1045
|
+
params,
|
|
1046
|
+
}: {
|
|
1047
|
+
params: Promise<{ slug: string }>;
|
|
1048
|
+
}) {
|
|
1049
|
+
const { slug } = await params;
|
|
1050
|
+
const full = await cms.fetchBlogBySlug(SITE_ID, slug);
|
|
1051
|
+
if (!full) notFound();
|
|
1052
|
+
|
|
1053
|
+
return (
|
|
1054
|
+
<article>
|
|
1055
|
+
{full.image_url && (
|
|
1056
|
+
<img src={full.image_url} alt={full.image_alt ?? full.title} />
|
|
1057
|
+
)}
|
|
1058
|
+
<h1>{full.title}</h1>
|
|
1059
|
+
<p>By {full.author}</p>
|
|
1060
|
+
{full.description && (
|
|
1061
|
+
<div dangerouslySetInnerHTML={{ __html: full.description }} />
|
|
1062
|
+
)}
|
|
1063
|
+
|
|
1064
|
+
{/* Render sections from extra.sections if present */}
|
|
1065
|
+
{full.extra?.sections && full.extra.sections.length > 0 && (
|
|
1066
|
+
<RenderSections sections={full.extra.sections} />
|
|
1067
|
+
)}
|
|
1068
|
+
</article>
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
```
|
|
1072
|
+
|
|
1073
|
+
> **Note**: Blogs can have custom sections stored in `extra.sections`. Use `<RenderSections />` to render them on the detail page. This is optional — if no sections are defined, the blog renders normally as shown above.
|
|
1074
|
+
|
|
1075
|
+
---
|
|
1076
|
+
|
|
1077
|
+
### Events Page — Listing & Detail
|
|
1078
|
+
|
|
1079
|
+
Same pattern as blogs. The `event` section carries display text; actual event data comes from `fetchEvents`.
|
|
1080
|
+
|
|
1081
|
+
**Listing page:**
|
|
1082
|
+
|
|
1083
|
+
```tsx
|
|
1084
|
+
// components/pages/EventPage.tsx
|
|
1085
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
1086
|
+
import { notFound } from "next/navigation";
|
|
1087
|
+
import Link from "next/link";
|
|
1088
|
+
import type { Page, SiteConfig } from "@crayons/cms-sdk";
|
|
1089
|
+
|
|
1090
|
+
export default async function EventsPage({
|
|
1091
|
+
page,
|
|
1092
|
+
site,
|
|
1093
|
+
}: {
|
|
1094
|
+
page: Page;
|
|
1095
|
+
site?: SiteConfig | null;
|
|
1096
|
+
}) {
|
|
1097
|
+
const { data: events } = await cms.fetchEvents(SITE_ID, {
|
|
1098
|
+
page: 1,
|
|
1099
|
+
limit: 12,
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
return (
|
|
1103
|
+
<div>
|
|
1104
|
+
{events.map((event) => (
|
|
1105
|
+
<Link key={event.id} href={`/events/${event.slug}`}>
|
|
1106
|
+
{event.image_url && (
|
|
1107
|
+
<img src={event.image_url} alt={event.image_alt ?? event.title} />
|
|
1108
|
+
)}
|
|
1109
|
+
<h2>{event.title}</h2>
|
|
1110
|
+
<time>{event.start_date}</time>
|
|
1111
|
+
{event.location_name && <p>{event.location_name}</p>}
|
|
1112
|
+
{event.excerpt && <p>{event.excerpt}</p>}
|
|
1113
|
+
</Link>
|
|
1114
|
+
))}
|
|
1115
|
+
</div>
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
```
|
|
1119
|
+
|
|
1120
|
+
**Event detail page:**
|
|
1121
|
+
|
|
1122
|
+
```tsx
|
|
1123
|
+
// components/pages/EventDetailPage.tsx
|
|
1124
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
1125
|
+
import { notFound } from "next/navigation";
|
|
1126
|
+
import { RenderSections } from "@/components/render-sections";
|
|
1127
|
+
|
|
1128
|
+
export default async function EventDetailPage({
|
|
1129
|
+
params,
|
|
1130
|
+
}: {
|
|
1131
|
+
params: Promise<{ slug: string }>;
|
|
1132
|
+
}) {
|
|
1133
|
+
const { slug } = await params;
|
|
1134
|
+
const { data: events } = await cms.fetchEvents(SITE_ID, { limit: 1000 });
|
|
1135
|
+
const event = events.find((e) => e.slug === slug);
|
|
1136
|
+
|
|
1137
|
+
if (!event) notFound();
|
|
1138
|
+
|
|
1139
|
+
return (
|
|
1140
|
+
<article>
|
|
1141
|
+
{event.image_url && (
|
|
1142
|
+
<img src={event.image_url} alt={event.image_alt ?? event.title} />
|
|
1143
|
+
)}
|
|
1144
|
+
<h1>{event.title}</h1>
|
|
1145
|
+
<time>{event.start_date}</time>
|
|
1146
|
+
{event.end_date && <time> – {event.end_date}</time>}
|
|
1147
|
+
{event.location_name && <p>{event.location_name}</p>}
|
|
1148
|
+
{event.address && <address>{event.address}</address>}
|
|
1149
|
+
{event.description && (
|
|
1150
|
+
<div dangerouslySetInnerHTML={{ __html: event.description }} />
|
|
1151
|
+
)}
|
|
1152
|
+
|
|
1153
|
+
{/* Render sections from extra.sections if present */}
|
|
1154
|
+
{event.extra?.sections && event.extra.sections.length > 0 && (
|
|
1155
|
+
<RenderSections sections={event.extra.sections} />
|
|
1156
|
+
)}
|
|
1157
|
+
</article>
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
```
|
|
1161
|
+
|
|
1162
|
+
> **Note**: Events can have custom sections stored in `extra.sections`. Use `<RenderSections />` to render them on the detail page. This is optional — if no sections are defined, the event renders normally as shown above.
|
|
1163
|
+
|
|
1164
|
+
---
|
|
1165
|
+
|
|
1166
|
+
### Gallery Page — Albums & Photos
|
|
1167
|
+
|
|
1168
|
+
The `gallery` section carries the `album_id` to display. Fetch albums with `fetchAlbums`; each album includes its `items` (photos).
|
|
1169
|
+
|
|
1170
|
+
```tsx
|
|
1171
|
+
// components/pages/GalleryPage.tsx
|
|
1172
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
1173
|
+
import { notFound } from "next/navigation";
|
|
1174
|
+
import Link from "next/link";
|
|
1175
|
+
import type { Page } from "@crayons/cms-sdk";
|
|
1176
|
+
|
|
1177
|
+
export default async function GalleryPage({ page }: { page: Page }) {
|
|
1178
|
+
const { data: albums } = await cms.fetchAlbums(SITE_ID);
|
|
1179
|
+
|
|
1180
|
+
return (
|
|
1181
|
+
<div className="grid">
|
|
1182
|
+
{albums.map((album) => (
|
|
1183
|
+
<Link key={album.id} href={`/gallery/${album.slug}`}>
|
|
1184
|
+
{album.cover_image_url && (
|
|
1185
|
+
<img
|
|
1186
|
+
src={album.cover_image_url}
|
|
1187
|
+
alt={album.cover_image_alt ?? album.title}
|
|
1188
|
+
/>
|
|
1189
|
+
)}
|
|
1190
|
+
<h2>{album.title}</h2>
|
|
1191
|
+
{album.description && <p>{album.description}</p>}
|
|
1192
|
+
</Link>
|
|
1193
|
+
))}
|
|
1194
|
+
</div>
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
```
|
|
1198
|
+
|
|
1199
|
+
**Album detail page:**
|
|
1200
|
+
|
|
1201
|
+
```tsx
|
|
1202
|
+
// components/pages/GalleryDetailPage.tsx
|
|
1203
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
1204
|
+
import { notFound } from "next/navigation";
|
|
1205
|
+
|
|
1206
|
+
export default async function AlbumDetailPage({
|
|
1207
|
+
params,
|
|
1208
|
+
}: {
|
|
1209
|
+
params: Promise<{ slug: string }>;
|
|
1210
|
+
}) {
|
|
1211
|
+
const { slug } = await params;
|
|
1212
|
+
const { data: albums } = await cms.fetchAlbums(SITE_ID);
|
|
1213
|
+
const album = albums.find((a) => a.slug === slug);
|
|
1214
|
+
|
|
1215
|
+
if (!album) notFound();
|
|
1216
|
+
|
|
1217
|
+
return (
|
|
1218
|
+
<div>
|
|
1219
|
+
<h1>{album.title}</h1>
|
|
1220
|
+
{album.description && <p>{album.description}</p>}
|
|
1221
|
+
|
|
1222
|
+
<div className="grid">
|
|
1223
|
+
{album.items?.map((item) => (
|
|
1224
|
+
<figure key={item.id}>
|
|
1225
|
+
<img src={item.image_url} alt={item.image_alt ?? ""} />
|
|
1226
|
+
{item.caption && <figcaption>{item.caption}</figcaption>}
|
|
1227
|
+
</figure>
|
|
1228
|
+
))}
|
|
1229
|
+
</div>
|
|
1230
|
+
</div>
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
```
|
|
1234
|
+
|
|
1235
|
+
---
|
|
1236
|
+
|
|
1237
|
+
### Contact Page — Form Only, No Section Rendering
|
|
1238
|
+
|
|
1239
|
+
The contact page does **not** use `RenderSections`. It is a dedicated form page that submits directly to the CMS via `submitContactForm`. Do not render CMS sections here — just build your form UI and wire it to the SDK.
|
|
1240
|
+
|
|
1241
|
+
```tsx
|
|
1242
|
+
// components/pages/ContactPage.tsx
|
|
1243
|
+
import { ContactForm } from "@/components/contact-form";
|
|
1244
|
+
import type { Page, SiteConfig } from "@crayons/cms-sdk";
|
|
1245
|
+
|
|
1246
|
+
export default function ContactPage({
|
|
1247
|
+
page,
|
|
1248
|
+
site,
|
|
1249
|
+
}: {
|
|
1250
|
+
page: Page;
|
|
1251
|
+
site?: SiteConfig | null;
|
|
1252
|
+
}) {
|
|
1253
|
+
return (
|
|
1254
|
+
<main>
|
|
1255
|
+
<h1>Contact Us</h1>
|
|
1256
|
+
<ContactForm />
|
|
1257
|
+
</main>
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
```
|
|
1261
|
+
|
|
1262
|
+
```tsx
|
|
1263
|
+
// components/contact-form.tsx (client component — handles submission)
|
|
1264
|
+
"use client";
|
|
1265
|
+
|
|
1266
|
+
import { useState } from "react";
|
|
1267
|
+
import type { ContactPayload } from "@crayons/cms-sdk";
|
|
1268
|
+
|
|
1269
|
+
export function ContactForm() {
|
|
1270
|
+
const [status, setStatus] = useState<
|
|
1271
|
+
"idle" | "sending" | "success" | "error"
|
|
1272
|
+
>("idle");
|
|
1273
|
+
|
|
1274
|
+
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
1275
|
+
e.preventDefault();
|
|
1276
|
+
setStatus("sending");
|
|
1277
|
+
|
|
1278
|
+
const form = e.currentTarget;
|
|
1279
|
+
const payload: ContactPayload = {
|
|
1280
|
+
name: (form.elements.namedItem("name") as HTMLInputElement).value,
|
|
1281
|
+
email: (form.elements.namedItem("email") as HTMLInputElement).value,
|
|
1282
|
+
subject: (form.elements.namedItem("subject") as HTMLInputElement).value,
|
|
1283
|
+
message: (form.elements.namedItem("message") as HTMLTextAreaElement)
|
|
1284
|
+
.value,
|
|
1285
|
+
type: "contact",
|
|
1286
|
+
};
|
|
1287
|
+
|
|
1288
|
+
try {
|
|
1289
|
+
// submitContactForm is called from a server action or API route to keep SITE_ID server-side
|
|
1290
|
+
const res = await fetch("/api/contact", {
|
|
1291
|
+
method: "POST",
|
|
1292
|
+
body: JSON.stringify(payload),
|
|
1293
|
+
headers: { "Content-Type": "application/json" },
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
setStatus(res.ok ? "success" : "error");
|
|
1297
|
+
} catch {
|
|
1298
|
+
setStatus("error");
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
return (
|
|
1303
|
+
<form onSubmit={handleSubmit}>
|
|
1304
|
+
<input name="name" placeholder="Name" required />
|
|
1305
|
+
<input name="email" type="email" placeholder="Email" />
|
|
1306
|
+
<input name="subject" placeholder="Subject" />
|
|
1307
|
+
<textarea name="message" placeholder="Message" required />
|
|
1308
|
+
<button type="submit" disabled={status === "sending"}>
|
|
1309
|
+
{status === "sending" ? "Sending…" : "Send"}
|
|
1310
|
+
</button>
|
|
1311
|
+
{status === "success" && <p>Message sent!</p>}
|
|
1312
|
+
{status === "error" && <p>Something went wrong. Please try again.</p>}
|
|
1313
|
+
</form>
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
```
|
|
1317
|
+
|
|
1318
|
+
```ts
|
|
1319
|
+
// app/api/contact/route.ts (server — keeps SITE_ID out of the client bundle)
|
|
1320
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
1321
|
+
import type { ContactPayload } from "@crayons/cms-sdk";
|
|
1322
|
+
|
|
1323
|
+
export async function POST(req: Request) {
|
|
1324
|
+
const payload: ContactPayload = await req.json();
|
|
1325
|
+
const result = await cms.submitContactForm(SITE_ID, payload);
|
|
1326
|
+
return Response.json(result);
|
|
1327
|
+
}
|
|
1328
|
+
```
|
|
1329
|
+
|
|
1330
|
+
> `SITE_ID` is kept server-side. Never call `submitContactForm` directly from a client component.
|
|
1331
|
+
|
|
1332
|
+
---
|
|
1333
|
+
|
|
1334
|
+
## Store
|
|
1335
|
+
|
|
1336
|
+
The store is a separate product/e-commerce layer built on top of the CMS. It uses a completely different API prefix (`/api/public/store/`) and its routes are **hardcoded in the catch-all** — they are not CMS-managed pages.
|
|
1337
|
+
|
|
1338
|
+
### Overview
|
|
1339
|
+
|
|
1340
|
+
| Concern | CMS | Store |
|
|
1341
|
+
|---|---|---|
|
|
1342
|
+
| API prefix | `/api/public/cms/{siteId}/` | `/api/public/store/{siteId}/` |
|
|
1343
|
+
| Route management | CMS dashboard (page_type) | Hardcoded in `[[...slug]]` |
|
|
1344
|
+
| Content editing | Via CMS | Via store admin |
|
|
1345
|
+
|
|
1346
|
+
**Feature flag** — gate all store UI behind a constant so it can be disabled per project:
|
|
1347
|
+
|
|
1348
|
+
```ts
|
|
1349
|
+
// config/store.ts
|
|
1350
|
+
export const STORE_ENABLED = true;
|
|
1351
|
+
```
|
|
1352
|
+
|
|
1353
|
+
---
|
|
1354
|
+
|
|
1355
|
+
### Store Types
|
|
1356
|
+
|
|
1357
|
+
| File | Exports |
|
|
1358
|
+
|---|---|
|
|
1359
|
+
| `product.ts` | `Product`, `ProductVariant`, `ProductImage`, `ProductStatus` |
|
|
1360
|
+
| `product-category.ts` | `ProductCategory` |
|
|
1361
|
+
| `product-brand.ts` | `ProductBrand` |
|
|
1362
|
+
| `collection.ts` | `Collection`, `CollectionDetail`, `CollectionItem` |
|
|
1363
|
+
| `order.ts` | `Order`, `OrderItem`, `ShippingAddress`, `PlaceOrderPayload`, `CartItem`, `OrderStatus` |
|
|
1364
|
+
| `seo.ts` | `ProductSEO`, `ProductExtraData` |
|
|
1365
|
+
|
|
1366
|
+
```ts
|
|
1367
|
+
import type {
|
|
1368
|
+
Product,
|
|
1369
|
+
ProductVariant,
|
|
1370
|
+
ProductCategory,
|
|
1371
|
+
ProductBrand,
|
|
1372
|
+
Collection,
|
|
1373
|
+
CollectionDetail,
|
|
1374
|
+
CollectionItem,
|
|
1375
|
+
Order,
|
|
1376
|
+
PlaceOrderPayload,
|
|
1377
|
+
CartItem,
|
|
1378
|
+
ProductSEO,
|
|
1379
|
+
} from "@crayons/cms-sdk";
|
|
1380
|
+
```
|
|
1381
|
+
|
|
1382
|
+
> **SEO and Extra Fields**
|
|
1383
|
+
>
|
|
1384
|
+
> The following types include `seo` and `extra` fields:
|
|
1385
|
+
> - `Product`
|
|
1386
|
+
> - `ProductListItem`
|
|
1387
|
+
> - `ProductCategory`
|
|
1388
|
+
> - `ProductBrand`
|
|
1389
|
+
> - `Collection` / `CollectionListItem`
|
|
1390
|
+
>
|
|
1391
|
+
> The `ProductSEO` interface contains:
|
|
1392
|
+
> ```ts
|
|
1393
|
+
> interface ProductSEO {
|
|
1394
|
+
> title?: string | null;
|
|
1395
|
+
> description?: string | null;
|
|
1396
|
+
> tags?: string[] | null;
|
|
1397
|
+
> }
|
|
1398
|
+
> ```
|
|
1399
|
+
>
|
|
1400
|
+
> `ProductExtraData` is a flexible `Record<string, unknown>` for custom data.
|
|
1401
|
+
|
|
1402
|
+
> `Product.description` is HTML — render with `dangerouslySetInnerHTML`. Public product variants expose `inventory` as a boolean plus `low_stock`. Collection detail responses normalize both manual and smart collections into `collection.items`.
|
|
1403
|
+
|
|
1404
|
+
---
|
|
1405
|
+
|
|
1406
|
+
### Route Structure
|
|
1407
|
+
|
|
1408
|
+
Store routes are top-level and handled before CMS page resolution in `[[...slug]]/page.tsx`:
|
|
1409
|
+
|
|
1410
|
+
```
|
|
1411
|
+
/products → product listing
|
|
1412
|
+
/products/[slug] → product detail
|
|
1413
|
+
/categories → all categories
|
|
1414
|
+
/categories/[slug] → products filtered by category
|
|
1415
|
+
/brands → all brands
|
|
1416
|
+
/brands/[slug] → products filtered by brand
|
|
1417
|
+
/collections → all collections
|
|
1418
|
+
/collections/[slug] → collection detail + its products
|
|
1419
|
+
```
|
|
1420
|
+
|
|
1421
|
+
**Catch-all integration — handle store routes first:**
|
|
1422
|
+
|
|
1423
|
+
```tsx
|
|
1424
|
+
// app/[[...slug]]/page.tsx
|
|
1425
|
+
import { STORE_ENABLED } from "@/config/store";
|
|
1426
|
+
import ProductsPage from "@/components/pages/ProductsPage";
|
|
1427
|
+
import ProductDetailPage from "@/components/pages/ProductDetailPage";
|
|
1428
|
+
import ProductCategoriesPage from "@/components/pages/ProductCategoriesPage";
|
|
1429
|
+
import CategoryProductsPage from "@/components/pages/CategoryProductsPage";
|
|
1430
|
+
import BrandsPage from "@/components/pages/BrandsPage";
|
|
1431
|
+
import BrandProductsPage from "@/components/pages/BrandProductsPage";
|
|
1432
|
+
import CollectionsPage from "@/components/pages/CollectionsPage";
|
|
1433
|
+
import CollectionDetailPage from "@/components/pages/CollectionDetailPage";
|
|
1434
|
+
|
|
1435
|
+
export default async function CatchAll({ params, searchParams }) {
|
|
1436
|
+
const { slug = [] } = await params;
|
|
1437
|
+
|
|
1438
|
+
// ── Store routes (resolved before CMS pages) ──────────────────────────────
|
|
1439
|
+
if (!STORE_ENABLED && ["products","categories","brands","collections"].includes(slug[0])) {
|
|
1440
|
+
notFound();
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
if (slug[0] === "products") {
|
|
1444
|
+
if (slug.length === 1) return <ProductsPage searchParams={searchParams} />;
|
|
1445
|
+
return <ProductDetailPage params={Promise.resolve({ slug: slug[1] })} />;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
if (slug[0] === "categories") {
|
|
1449
|
+
if (slug.length === 1) return <ProductCategoriesPage />;
|
|
1450
|
+
return <CategoryProductsPage params={Promise.resolve({ slug: slug[1] })} searchParams={searchParams} />;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
if (slug[0] === "brands") {
|
|
1454
|
+
if (slug.length === 1) return <BrandsPage />;
|
|
1455
|
+
return <BrandProductsPage params={Promise.resolve({ slug: slug[1] })} searchParams={searchParams} />;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
if (slug[0] === "collections") {
|
|
1459
|
+
if (slug.length === 1) return <CollectionsPage />;
|
|
1460
|
+
return <CollectionDetailPage params={Promise.resolve({ slug: slug[1] })} />;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// ── CMS pages (catch-all continues below) ────────────────────────────────
|
|
1464
|
+
// ... resolveCmsRoute / fetchPageByUrl logic
|
|
1465
|
+
}
|
|
1466
|
+
```
|
|
1467
|
+
|
|
1468
|
+
---
|
|
1469
|
+
|
|
1470
|
+
### Products Page
|
|
1471
|
+
|
|
1472
|
+
```tsx
|
|
1473
|
+
// components/pages/ProductsPage.tsx
|
|
1474
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
1475
|
+
import type { Product, ProductCategory } from "@crayons/cms-sdk";
|
|
1476
|
+
import Link from "next/link";
|
|
1477
|
+
|
|
1478
|
+
interface Props {
|
|
1479
|
+
searchParams: Promise<{ page?: string; search?: string; category_id?: string }>;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
export default async function ProductsPage({ searchParams }: Props) {
|
|
1483
|
+
const { page = "1", search, category_id } = await searchParams;
|
|
1484
|
+
|
|
1485
|
+
const [{ data: products, pagination }, categories] = await Promise.all([
|
|
1486
|
+
cms.fetchProducts(SITE_ID, {
|
|
1487
|
+
page: Number(page),
|
|
1488
|
+
limit: 12,
|
|
1489
|
+
search,
|
|
1490
|
+
category_id,
|
|
1491
|
+
}),
|
|
1492
|
+
cms.fetchProductCategories(SITE_ID),
|
|
1493
|
+
]);
|
|
1494
|
+
|
|
1495
|
+
return (
|
|
1496
|
+
<div>
|
|
1497
|
+
{/* Category filter tabs */}
|
|
1498
|
+
<nav>
|
|
1499
|
+
<Link href="/products">All</Link>
|
|
1500
|
+
{categories.map((cat) => (
|
|
1501
|
+
<Link key={cat.id} href={`/products?category_id=${cat.id}`}>
|
|
1502
|
+
{cat.name}
|
|
1503
|
+
</Link>
|
|
1504
|
+
))}
|
|
1505
|
+
</nav>
|
|
1506
|
+
|
|
1507
|
+
{/* Product grid */}
|
|
1508
|
+
<div className="grid">
|
|
1509
|
+
{products.map((product) => (
|
|
1510
|
+
<Link key={product.id} href={`/products/${product.slug}`}>
|
|
1511
|
+
{product.thumbnail_url && (
|
|
1512
|
+
<img src={product.thumbnail_url} alt={product.name} />
|
|
1513
|
+
)}
|
|
1514
|
+
<h2>{product.name}</h2>
|
|
1515
|
+
{product.subtitle && <p>{product.subtitle}</p>}
|
|
1516
|
+
{product.is_featured && <span>Featured</span>}
|
|
1517
|
+
</Link>
|
|
1518
|
+
))}
|
|
1519
|
+
</div>
|
|
1520
|
+
|
|
1521
|
+
{/* Pagination */}
|
|
1522
|
+
<p>Page {pagination.page} • Total products: {pagination.total}</p>
|
|
1523
|
+
</div>
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
```
|
|
1527
|
+
|
|
1528
|
+
---
|
|
1529
|
+
|
|
1530
|
+
### Product Detail Page
|
|
1531
|
+
|
|
1532
|
+
The detail page is split into a **server component** (data fetch) and a **client component** (interactivity — variant selection, cart). `variants` is only returned by `fetchProductDetail`, not the list endpoint.
|
|
1533
|
+
|
|
1534
|
+
```tsx
|
|
1535
|
+
// components/pages/ProductDetailPage.tsx (server component)
|
|
1536
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
1537
|
+
import { notFound } from "next/navigation";
|
|
1538
|
+
import ProductDetailClient from "@/components/store/ProductDetailClient";
|
|
1539
|
+
|
|
1540
|
+
export default async function ProductDetailPage({
|
|
1541
|
+
params,
|
|
1542
|
+
}: {
|
|
1543
|
+
params: Promise<{ slug: string }>;
|
|
1544
|
+
}) {
|
|
1545
|
+
const { slug } = await params;
|
|
1546
|
+
const product = await cms.fetchProductDetail(SITE_ID, slug);
|
|
1547
|
+
|
|
1548
|
+
if (!product) notFound();
|
|
1549
|
+
|
|
1550
|
+
return <ProductDetailClient product={product} />;
|
|
1551
|
+
}
|
|
1552
|
+
```
|
|
1553
|
+
|
|
1554
|
+
```tsx
|
|
1555
|
+
// components/store/ProductDetailClient.tsx (client component)
|
|
1556
|
+
"use client";
|
|
1557
|
+
|
|
1558
|
+
import { useState } from "react";
|
|
1559
|
+
import type { Product, ProductVariant } from "@crayons/cms-sdk";
|
|
1560
|
+
import { useCart } from "@/context/CartContext";
|
|
1561
|
+
|
|
1562
|
+
export default function ProductDetailClient({ product }: { product: Product }) {
|
|
1563
|
+
const { addItem } = useCart();
|
|
1564
|
+
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
|
|
1565
|
+
product.variants?.[0] ?? null
|
|
1566
|
+
);
|
|
1567
|
+
const [quantity, setQuantity] = useState(1);
|
|
1568
|
+
|
|
1569
|
+
function handleAddToCart() {
|
|
1570
|
+
if (!selectedVariant) return;
|
|
1571
|
+
addItem(product, selectedVariant, quantity);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
return (
|
|
1575
|
+
<article>
|
|
1576
|
+
{product.thumbnail_url && (
|
|
1577
|
+
<img src={product.thumbnail_url} alt={product.name} />
|
|
1578
|
+
)}
|
|
1579
|
+
<h1>{product.name}</h1>
|
|
1580
|
+
{product.subtitle && <p>{product.subtitle}</p>}
|
|
1581
|
+
|
|
1582
|
+
{/* Variant selector */}
|
|
1583
|
+
{product.variants && product.variants.length > 0 && (
|
|
1584
|
+
<div>
|
|
1585
|
+
{product.variants.map((v) => (
|
|
1586
|
+
<button
|
|
1587
|
+
key={v.id}
|
|
1588
|
+
onClick={() => setSelectedVariant(v)}
|
|
1589
|
+
aria-pressed={selectedVariant?.id === v.id}
|
|
1590
|
+
>
|
|
1591
|
+
{v.name ?? v.sku} — ${v.sale_price ?? v.price}
|
|
1592
|
+
{v.inventory === 0 && " (Out of stock)"}
|
|
1593
|
+
</button>
|
|
1594
|
+
))}
|
|
1595
|
+
</div>
|
|
1596
|
+
)}
|
|
1597
|
+
|
|
1598
|
+
{/* Quantity + add to cart */}
|
|
1599
|
+
<div>
|
|
1600
|
+
<button onClick={() => setQuantity((q) => Math.max(1, q - 1))}>-</button>
|
|
1601
|
+
<span>{quantity}</span>
|
|
1602
|
+
<button onClick={() => setQuantity((q) => q + 1)}>+</button>
|
|
1603
|
+
</div>
|
|
1604
|
+
<button
|
|
1605
|
+
onClick={handleAddToCart}
|
|
1606
|
+
disabled={!selectedVariant || selectedVariant.inventory === 0}
|
|
1607
|
+
>
|
|
1608
|
+
Add to Cart
|
|
1609
|
+
</button>
|
|
1610
|
+
|
|
1611
|
+
{/* Rich-text description */}
|
|
1612
|
+
<div dangerouslySetInnerHTML={{ __html: product.description }} />
|
|
1613
|
+
|
|
1614
|
+
{/* Variant specs */}
|
|
1615
|
+
{selectedVariant?.specifications &&
|
|
1616
|
+
Object.entries(selectedVariant.specifications).map(([group, specs]) => (
|
|
1617
|
+
<div key={group}>
|
|
1618
|
+
<h3>{group}</h3>
|
|
1619
|
+
{Object.entries(specs).map(([k, v]) => (
|
|
1620
|
+
<p key={k}><strong>{k}:</strong> {v}</p>
|
|
1621
|
+
))}
|
|
1622
|
+
</div>
|
|
1623
|
+
))}
|
|
1624
|
+
</article>
|
|
1625
|
+
);
|
|
1626
|
+
}
|
|
1627
|
+
```
|
|
1628
|
+
|
|
1629
|
+
---
|
|
1630
|
+
|
|
1631
|
+
### Categories Page
|
|
1632
|
+
|
|
1633
|
+
```tsx
|
|
1634
|
+
// components/pages/ProductCategoriesPage.tsx
|
|
1635
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
1636
|
+
import Link from "next/link";
|
|
1637
|
+
|
|
1638
|
+
export default async function ProductCategoriesPage() {
|
|
1639
|
+
const categories = await cms.fetchProductCategories(SITE_ID);
|
|
1640
|
+
|
|
1641
|
+
return (
|
|
1642
|
+
<div className="grid">
|
|
1643
|
+
{categories.map((cat) => (
|
|
1644
|
+
<Link key={cat.id} href={`/categories/${cat.slug}`}>
|
|
1645
|
+
{cat.image_url && <img src={cat.image_url} alt={cat.name} />}
|
|
1646
|
+
<h2>{cat.name}</h2>
|
|
1647
|
+
{cat.description && <p>{cat.description}</p>}
|
|
1648
|
+
</Link>
|
|
1649
|
+
))}
|
|
1650
|
+
</div>
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
```
|
|
1654
|
+
|
|
1655
|
+
**Category detail page — products filtered by category:**
|
|
1656
|
+
|
|
1657
|
+
```tsx
|
|
1658
|
+
// components/pages/CategoryProductsPage.tsx
|
|
1659
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
1660
|
+
import { notFound } from "next/navigation";
|
|
1661
|
+
import Link from "next/link";
|
|
1662
|
+
|
|
1663
|
+
interface Props {
|
|
1664
|
+
params: Promise<{ slug: string }>;
|
|
1665
|
+
searchParams: Promise<{ page?: string; search?: string }>;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
export default async function CategoryProductsPage({ params, searchParams }: Props) {
|
|
1669
|
+
const { slug } = await params;
|
|
1670
|
+
const { page = "1", search } = await searchParams;
|
|
1671
|
+
|
|
1672
|
+
// Resolve category_id from slug
|
|
1673
|
+
const categories = await cms.fetchProductCategories(SITE_ID);
|
|
1674
|
+
const category = categories.find((c) => c.slug === slug);
|
|
1675
|
+
if (!category) notFound();
|
|
1676
|
+
|
|
1677
|
+
const { data: products, pagination } = await cms.fetchProducts(SITE_ID, {
|
|
1678
|
+
category_id: category.id,
|
|
1679
|
+
page: Number(page),
|
|
1680
|
+
limit: 12,
|
|
1681
|
+
search,
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
return (
|
|
1685
|
+
<div>
|
|
1686
|
+
<h1>{category.name}</h1>
|
|
1687
|
+
{category.description && <p>{category.description}</p>}
|
|
1688
|
+
|
|
1689
|
+
<div className="grid">
|
|
1690
|
+
{products.map((product) => (
|
|
1691
|
+
<Link key={product.id} href={`/products/${product.slug}`}>
|
|
1692
|
+
{product.thumbnail_url && (
|
|
1693
|
+
<img src={product.thumbnail_url} alt={product.name} />
|
|
1694
|
+
)}
|
|
1695
|
+
<h2>{product.name}</h2>
|
|
1696
|
+
</Link>
|
|
1697
|
+
))}
|
|
1698
|
+
</div>
|
|
1699
|
+
</div>
|
|
1700
|
+
);
|
|
1701
|
+
}
|
|
1702
|
+
```
|
|
1703
|
+
|
|
1704
|
+
---
|
|
1705
|
+
|
|
1706
|
+
### Brands Page
|
|
1707
|
+
|
|
1708
|
+
```tsx
|
|
1709
|
+
// components/pages/BrandsPage.tsx
|
|
1710
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
1711
|
+
import Link from "next/link";
|
|
1712
|
+
|
|
1713
|
+
export default async function BrandsPage() {
|
|
1714
|
+
const brands = await cms.fetchProductBrands(SITE_ID);
|
|
1715
|
+
|
|
1716
|
+
return (
|
|
1717
|
+
<div className="grid">
|
|
1718
|
+
{brands.map((brand) => (
|
|
1719
|
+
<Link key={brand.id} href={`/brands/${brand.slug}`}>
|
|
1720
|
+
{brand.logo_url && <img src={brand.logo_url} alt={brand.name} />}
|
|
1721
|
+
<h2>{brand.name}</h2>
|
|
1722
|
+
{brand.description && <p>{brand.description}</p>}
|
|
1723
|
+
</Link>
|
|
1724
|
+
))}
|
|
1725
|
+
</div>
|
|
1726
|
+
);
|
|
1727
|
+
}
|
|
1728
|
+
```
|
|
1729
|
+
|
|
1730
|
+
**Brand detail page — products filtered by brand:**
|
|
1731
|
+
|
|
1732
|
+
```tsx
|
|
1733
|
+
// components/pages/BrandProductsPage.tsx
|
|
1734
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
1735
|
+
import { notFound } from "next/navigation";
|
|
1736
|
+
import Link from "next/link";
|
|
1737
|
+
|
|
1738
|
+
interface Props {
|
|
1739
|
+
params: Promise<{ slug: string }>;
|
|
1740
|
+
searchParams: Promise<{ page?: string; search?: string }>;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
export default async function BrandProductsPage({ params, searchParams }: Props) {
|
|
1744
|
+
const { slug } = await params;
|
|
1745
|
+
const { page = "1", search } = await searchParams;
|
|
1746
|
+
|
|
1747
|
+
const brands = await cms.fetchProductBrands(SITE_ID);
|
|
1748
|
+
const brand = brands.find((b) => b.slug === slug);
|
|
1749
|
+
if (!brand) notFound();
|
|
1750
|
+
|
|
1751
|
+
const { data: products, pagination } = await cms.fetchProducts(SITE_ID, {
|
|
1752
|
+
brand_id: brand.id,
|
|
1753
|
+
page: Number(page),
|
|
1754
|
+
limit: 12,
|
|
1755
|
+
search,
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
return (
|
|
1759
|
+
<div>
|
|
1760
|
+
{brand.logo_url && <img src={brand.logo_url} alt={brand.name} />}
|
|
1761
|
+
<h1>{brand.name}</h1>
|
|
1762
|
+
|
|
1763
|
+
<div className="grid">
|
|
1764
|
+
{products.map((product) => (
|
|
1765
|
+
<Link key={product.id} href={`/products/${product.slug}`}>
|
|
1766
|
+
{product.thumbnail_url && (
|
|
1767
|
+
<img src={product.thumbnail_url} alt={product.name} />
|
|
1768
|
+
)}
|
|
1769
|
+
<h2>{product.name}</h2>
|
|
1770
|
+
</Link>
|
|
1771
|
+
))}
|
|
1772
|
+
</div>
|
|
1773
|
+
</div>
|
|
1774
|
+
);
|
|
1775
|
+
}
|
|
1776
|
+
```
|
|
1777
|
+
|
|
1778
|
+
---
|
|
1779
|
+
|
|
1780
|
+
### Collections Page
|
|
1781
|
+
|
|
1782
|
+
```tsx
|
|
1783
|
+
// components/pages/CollectionsPage.tsx
|
|
1784
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
1785
|
+
import Link from "next/link";
|
|
1786
|
+
|
|
1787
|
+
export default async function CollectionsPage() {
|
|
1788
|
+
const collections = await cms.fetchCollections(SITE_ID);
|
|
1789
|
+
|
|
1790
|
+
return (
|
|
1791
|
+
<div className="grid">
|
|
1792
|
+
{collections.map((col) => (
|
|
1793
|
+
<Link key={col.id} href={`/collections/${col.slug}`}>
|
|
1794
|
+
<h2>{col.name}</h2>
|
|
1795
|
+
{col.description && <p>{col.description}</p>}
|
|
1796
|
+
{col._count && <span>{col._count.items} products</span>}
|
|
1797
|
+
</Link>
|
|
1798
|
+
))}
|
|
1799
|
+
</div>
|
|
1800
|
+
);
|
|
1801
|
+
}
|
|
1802
|
+
```
|
|
1803
|
+
|
|
1804
|
+
**Collection detail — renders the collection's products in order:**
|
|
1805
|
+
|
|
1806
|
+
```tsx
|
|
1807
|
+
// components/pages/CollectionDetailPage.tsx
|
|
1808
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
1809
|
+
import { notFound } from "next/navigation";
|
|
1810
|
+
import Link from "next/link";
|
|
1811
|
+
|
|
1812
|
+
export default async function CollectionDetailPage({
|
|
1813
|
+
params,
|
|
1814
|
+
searchParams,
|
|
1815
|
+
}: {
|
|
1816
|
+
params: Promise<{ slug: string }>;
|
|
1817
|
+
searchParams: Promise<{ category_id?: string; page?: string }>;
|
|
1818
|
+
}) {
|
|
1819
|
+
const { slug } = await params;
|
|
1820
|
+
const { category_id, page = "1" } = await searchParams;
|
|
1821
|
+
const collection = await cms.fetchCollectionDetail(
|
|
1822
|
+
SITE_ID,
|
|
1823
|
+
slug,
|
|
1824
|
+
{
|
|
1825
|
+
category_id,
|
|
1826
|
+
page: Number(page),
|
|
1827
|
+
limit: 20,
|
|
1828
|
+
},
|
|
1829
|
+
);
|
|
1830
|
+
|
|
1831
|
+
if (!collection) notFound();
|
|
1832
|
+
|
|
1833
|
+
// Works for both manual collections and smart collections
|
|
1834
|
+
const items = collection.items ?? [];
|
|
1835
|
+
|
|
1836
|
+
return (
|
|
1837
|
+
<div>
|
|
1838
|
+
<h1>{collection.name}</h1>
|
|
1839
|
+
{collection.description && <p>{collection.description}</p>}
|
|
1840
|
+
{collection.collection_type === "smart" && (
|
|
1841
|
+
<p>This collection is populated automatically.</p>
|
|
1842
|
+
)}
|
|
1843
|
+
|
|
1844
|
+
<div className="grid">
|
|
1845
|
+
{items.map((item) => (
|
|
1846
|
+
<Link key={item.id} href={`/products/${item.product.slug}`}>
|
|
1847
|
+
{item.product.thumbnail_url && (
|
|
1848
|
+
<img src={item.product.thumbnail_url} alt={item.product.name} />
|
|
1849
|
+
)}
|
|
1850
|
+
<h2>{item.product.name}</h2>
|
|
1851
|
+
{/* Show lowest variant price */}
|
|
1852
|
+
{item.product.variants.length > 0 && (
|
|
1853
|
+
<p>
|
|
1854
|
+
From ${Math.min(...item.product.variants.map((v) => v.sale_price ?? v.price))}
|
|
1855
|
+
</p>
|
|
1856
|
+
)}
|
|
1857
|
+
</Link>
|
|
1858
|
+
))}
|
|
1859
|
+
</div>
|
|
1860
|
+
|
|
1861
|
+
{collection.pagination && (
|
|
1862
|
+
<p>
|
|
1863
|
+
Page {collection.pagination.page} • Total matched products:{" "}
|
|
1864
|
+
{collection.pagination.total}
|
|
1865
|
+
</p>
|
|
1866
|
+
)}
|
|
1867
|
+
</div>
|
|
1868
|
+
);
|
|
1869
|
+
}
|
|
1870
|
+
```
|
|
1871
|
+
|
|
1872
|
+
> `collection.items` includes the full `product` object with `variants` for both manual and smart collections. The backend resolves smart collections before returning the public detail payload.
|
|
1873
|
+
|
|
1874
|
+
---
|
|
1875
|
+
|
|
1876
|
+
### Cart State (CartContext)
|
|
1877
|
+
|
|
1878
|
+
The cart is managed client-side using React Context with `localStorage` persistence. Wrap the root layout with the provider.
|
|
1879
|
+
|
|
1880
|
+
```tsx
|
|
1881
|
+
// context/CartContext.tsx
|
|
1882
|
+
"use client";
|
|
1883
|
+
|
|
1884
|
+
import { createContext, useContext, useState, useEffect } from "react";
|
|
1885
|
+
import type { CartItem, Product, ProductVariant } from "@crayons/cms-sdk";
|
|
1886
|
+
|
|
1887
|
+
interface CartContextValue {
|
|
1888
|
+
items: CartItem[];
|
|
1889
|
+
totalItems: number;
|
|
1890
|
+
subtotal: number;
|
|
1891
|
+
isOpen: boolean;
|
|
1892
|
+
addItem: (product: Product, variant: ProductVariant, quantity: number) => void;
|
|
1893
|
+
removeItem: (variantId: string) => void;
|
|
1894
|
+
updateQuantity: (variantId: string, quantity: number) => void;
|
|
1895
|
+
clearCart: () => void;
|
|
1896
|
+
openCart: () => void;
|
|
1897
|
+
closeCart: () => void;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
const CartContext = createContext<CartContextValue | null>(null);
|
|
1901
|
+
|
|
1902
|
+
export function CartProvider({ children }: { children: React.ReactNode }) {
|
|
1903
|
+
const [items, setItems] = useState<CartItem[]>([]);
|
|
1904
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
1905
|
+
|
|
1906
|
+
// Hydrate from localStorage on mount
|
|
1907
|
+
useEffect(() => {
|
|
1908
|
+
const stored = localStorage.getItem("cart");
|
|
1909
|
+
if (stored) setItems(JSON.parse(stored));
|
|
1910
|
+
}, []);
|
|
1911
|
+
|
|
1912
|
+
// Persist to localStorage on change
|
|
1913
|
+
useEffect(() => {
|
|
1914
|
+
localStorage.setItem("cart", JSON.stringify(items));
|
|
1915
|
+
}, [items]);
|
|
1916
|
+
|
|
1917
|
+
function addItem(product: Product, variant: ProductVariant, quantity: number) {
|
|
1918
|
+
setItems((prev) => {
|
|
1919
|
+
const existing = prev.find((i) => i.variant.id === variant.id);
|
|
1920
|
+
if (existing) {
|
|
1921
|
+
return prev.map((i) =>
|
|
1922
|
+
i.variant.id === variant.id
|
|
1923
|
+
? { ...i, quantity: i.quantity + quantity }
|
|
1924
|
+
: i
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
return [...prev, { product, variant, quantity }];
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
function removeItem(variantId: string) {
|
|
1932
|
+
setItems((prev) => prev.filter((i) => i.variant.id !== variantId));
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
function updateQuantity(variantId: string, quantity: number) {
|
|
1936
|
+
setItems((prev) =>
|
|
1937
|
+
prev.map((i) => (i.variant.id === variantId ? { ...i, quantity } : i))
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
function clearCart() {
|
|
1942
|
+
setItems([]);
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
const totalItems = items.reduce((sum, i) => sum + i.quantity, 0);
|
|
1946
|
+
const subtotal = items.reduce(
|
|
1947
|
+
(sum, i) => sum + (i.variant.sale_price ?? i.variant.price) * i.quantity,
|
|
1948
|
+
0
|
|
1949
|
+
);
|
|
1950
|
+
|
|
1951
|
+
return (
|
|
1952
|
+
<CartContext.Provider
|
|
1953
|
+
value={{
|
|
1954
|
+
items,
|
|
1955
|
+
totalItems,
|
|
1956
|
+
subtotal,
|
|
1957
|
+
isOpen,
|
|
1958
|
+
addItem,
|
|
1959
|
+
removeItem,
|
|
1960
|
+
updateQuantity,
|
|
1961
|
+
clearCart,
|
|
1962
|
+
openCart: () => setIsOpen(true),
|
|
1963
|
+
closeCart: () => setIsOpen(false),
|
|
1964
|
+
}}
|
|
1965
|
+
>
|
|
1966
|
+
{children}
|
|
1967
|
+
</CartContext.Provider>
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
export function useCart() {
|
|
1972
|
+
const ctx = useContext(CartContext);
|
|
1973
|
+
if (!ctx) throw new Error("useCart must be used inside CartProvider");
|
|
1974
|
+
return ctx;
|
|
1975
|
+
}
|
|
1976
|
+
```
|
|
1977
|
+
|
|
1978
|
+
```tsx
|
|
1979
|
+
// app/layout.tsx — wrap children with CartProvider
|
|
1980
|
+
import { CartProvider } from "@/context/CartContext";
|
|
1981
|
+
|
|
1982
|
+
export default function RootLayout({ children }) {
|
|
1983
|
+
return (
|
|
1984
|
+
<html>
|
|
1985
|
+
<body>
|
|
1986
|
+
<CartProvider>
|
|
1987
|
+
<SiteHeader ... />
|
|
1988
|
+
{children}
|
|
1989
|
+
<SiteFooter ... />
|
|
1990
|
+
<CartDrawer /> {/* slides in when isOpen = true */}
|
|
1991
|
+
</CartProvider>
|
|
1992
|
+
</body>
|
|
1993
|
+
</html>
|
|
1994
|
+
);
|
|
1995
|
+
}
|
|
1996
|
+
```
|
|
1997
|
+
|
|
1998
|
+
> `CartProvider` and `useCart` are client-only. Never call `useCart` inside a server component.
|
|
1999
|
+
|
|
2000
|
+
---
|
|
2001
|
+
|
|
2002
|
+
### Order Placement
|
|
2003
|
+
|
|
2004
|
+
`placeOrder` must be called server-side (keep `SITE_ID` out of the client bundle). Use an API route.
|
|
2005
|
+
|
|
2006
|
+
```ts
|
|
2007
|
+
// app/api/store/orders/route.ts
|
|
2008
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
2009
|
+
import type { PlaceOrderPayload } from "@crayons/cms-sdk";
|
|
2010
|
+
|
|
2011
|
+
export async function POST(req: Request) {
|
|
2012
|
+
const payload: PlaceOrderPayload = await req.json();
|
|
2013
|
+
const order = await cms.placeOrder(SITE_ID, payload);
|
|
2014
|
+
if (!order) return Response.json({ error: "Order failed" }, { status: 500 });
|
|
2015
|
+
return Response.json(order);
|
|
2016
|
+
}
|
|
2017
|
+
```
|
|
2018
|
+
|
|
2019
|
+
```tsx
|
|
2020
|
+
// components/store/CheckoutForm.tsx (client component)
|
|
2021
|
+
"use client";
|
|
2022
|
+
|
|
2023
|
+
import { useState } from "react";
|
|
2024
|
+
import type { PlaceOrderPayload } from "@crayons/cms-sdk";
|
|
2025
|
+
import { useCart } from "@/context/CartContext";
|
|
2026
|
+
|
|
2027
|
+
export function CheckoutForm() {
|
|
2028
|
+
const { items, subtotal, clearCart } = useCart();
|
|
2029
|
+
const [status, setStatus] = useState<"idle" | "placing" | "success" | "error">("idle");
|
|
2030
|
+
|
|
2031
|
+
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
2032
|
+
e.preventDefault();
|
|
2033
|
+
setStatus("placing");
|
|
2034
|
+
|
|
2035
|
+
const form = e.currentTarget;
|
|
2036
|
+
const payload: PlaceOrderPayload = {
|
|
2037
|
+
customer_name: (form.elements.namedItem("name") as HTMLInputElement).value,
|
|
2038
|
+
customer_email: (form.elements.namedItem("email") as HTMLInputElement).value,
|
|
2039
|
+
customer_phone: (form.elements.namedItem("phone") as HTMLInputElement).value || null,
|
|
2040
|
+
shipping_address: {
|
|
2041
|
+
line1: (form.elements.namedItem("line1") as HTMLInputElement).value,
|
|
2042
|
+
city: (form.elements.namedItem("city") as HTMLInputElement).value,
|
|
2043
|
+
zip: (form.elements.namedItem("zip") as HTMLInputElement).value,
|
|
2044
|
+
country: (form.elements.namedItem("country") as HTMLInputElement).value,
|
|
2045
|
+
},
|
|
2046
|
+
items: items.map((i) => ({
|
|
2047
|
+
product_variant_id: i.variant.id,
|
|
2048
|
+
quantity: i.quantity,
|
|
2049
|
+
})),
|
|
2050
|
+
notes: (form.elements.namedItem("notes") as HTMLTextAreaElement).value || null,
|
|
2051
|
+
};
|
|
2052
|
+
|
|
2053
|
+
const res = await fetch("/api/store/orders", {
|
|
2054
|
+
method: "POST",
|
|
2055
|
+
body: JSON.stringify(payload),
|
|
2056
|
+
headers: { "Content-Type": "application/json" },
|
|
2057
|
+
});
|
|
2058
|
+
|
|
2059
|
+
if (res.ok) {
|
|
2060
|
+
clearCart();
|
|
2061
|
+
setStatus("success");
|
|
2062
|
+
} else {
|
|
2063
|
+
setStatus("error");
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
return (
|
|
2068
|
+
<form onSubmit={handleSubmit}>
|
|
2069
|
+
<input name="name" placeholder="Full name" required />
|
|
2070
|
+
<input name="email" type="email" placeholder="Email" required />
|
|
2071
|
+
<input name="phone" placeholder="Phone (optional)" />
|
|
2072
|
+
<input name="line1" placeholder="Street address" required />
|
|
2073
|
+
<input name="city" placeholder="City" required />
|
|
2074
|
+
<input name="zip" placeholder="ZIP / Postcode" required />
|
|
2075
|
+
<input name="country" placeholder="Country" required />
|
|
2076
|
+
<textarea name="notes" placeholder="Order notes (optional)" />
|
|
2077
|
+
|
|
2078
|
+
<p>Subtotal: ${subtotal.toFixed(2)}</p>
|
|
2079
|
+
|
|
2080
|
+
<button type="submit" disabled={status === "placing" || items.length === 0}>
|
|
2081
|
+
{status === "placing" ? "Placing order…" : "Place Order"}
|
|
2082
|
+
</button>
|
|
2083
|
+
{status === "success" && <p>Order placed successfully!</p>}
|
|
2084
|
+
{status === "error" && <p>Something went wrong. Please try again.</p>}
|
|
2085
|
+
</form>
|
|
2086
|
+
);
|
|
2087
|
+
}
|
|
2088
|
+
```
|
|
2089
|
+
|
|
2090
|
+
---
|
|
2091
|
+
|
|
2092
|
+
## SEO & Metadata
|
|
2093
|
+
|
|
2094
|
+
Every `Page` returned by `fetchPageByUrl` or `fetchPages` includes an `seo` field (`SEO | null`) with `title`, `description`, `tags`, and `image`. Use Next.js `generateMetadata` to apply this per page, falling back to the site-wide defaults from `SiteConfig`.
|
|
2095
|
+
|
|
2096
|
+
```tsx
|
|
2097
|
+
// app/[[...slug]]/page.tsx
|
|
2098
|
+
import type { Metadata } from "next";
|
|
2099
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
2100
|
+
|
|
2101
|
+
interface Props {
|
|
2102
|
+
params: Promise<{ slug?: string[] }>;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
2106
|
+
const { slug } = await params;
|
|
2107
|
+
const urlPath = slug ? `/${slug.join("/")}` : "/";
|
|
2108
|
+
|
|
2109
|
+
const [page, siteConfig] = await Promise.all([
|
|
2110
|
+
cms.fetchPageByUrl(SITE_ID, urlPath),
|
|
2111
|
+
cms.fetchSiteConfig(SITE_ID),
|
|
2112
|
+
]);
|
|
2113
|
+
|
|
2114
|
+
const siteName = siteConfig?.site_name ?? "";
|
|
2115
|
+
const baseUrl = process.env.NEXT_PUBLIC_CMS_BASE_URL ?? "";
|
|
2116
|
+
|
|
2117
|
+
if (!page?.seo) {
|
|
2118
|
+
return { title: siteName };
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
const { title, description, tags, image } = page.seo;
|
|
2122
|
+
|
|
2123
|
+
return {
|
|
2124
|
+
title: title ? `${title} | ${siteName}` : siteName,
|
|
2125
|
+
description: description ?? undefined,
|
|
2126
|
+
keywords: tags.length > 0 ? tags : undefined,
|
|
2127
|
+
openGraph: {
|
|
2128
|
+
title: title ?? siteName,
|
|
2129
|
+
description: description ?? undefined,
|
|
2130
|
+
url: `${baseUrl}${urlPath}`,
|
|
2131
|
+
siteName,
|
|
2132
|
+
images: image ? [{ url: image }] : undefined,
|
|
2133
|
+
},
|
|
2134
|
+
twitter: {
|
|
2135
|
+
card: "summary_large_image",
|
|
2136
|
+
title: title ?? siteName,
|
|
2137
|
+
description: description ?? undefined,
|
|
2138
|
+
images: image ? [image] : undefined,
|
|
2139
|
+
},
|
|
2140
|
+
alternates: {
|
|
2141
|
+
canonical: `${baseUrl}${urlPath}`,
|
|
2142
|
+
},
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
```
|
|
2146
|
+
|
|
2147
|
+
For dedicated pages (blog detail, service detail, etc.) the pattern is the same — use the entity's own SEO fields:
|
|
2148
|
+
|
|
2149
|
+
```tsx
|
|
2150
|
+
// app/blog/[slug]/page.tsx
|
|
2151
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
2152
|
+
const { slug } = await params;
|
|
2153
|
+
const blog = await cms.fetchBlogBySlug(SITE_ID, slug);
|
|
2154
|
+
const siteConfig = await cms.fetchSiteConfig(SITE_ID);
|
|
2155
|
+
const siteName = siteConfig?.site_name ?? "";
|
|
2156
|
+
|
|
2157
|
+
if (!blog) return { title: "Not Found" };
|
|
2158
|
+
|
|
2159
|
+
return {
|
|
2160
|
+
title: blog.seo_title
|
|
2161
|
+
? `${blog.seo_title} | ${siteName}`
|
|
2162
|
+
: `${blog.title} | ${siteName}`,
|
|
2163
|
+
description: blog.seo_description ?? blog.excerpt ?? undefined,
|
|
2164
|
+
openGraph: {
|
|
2165
|
+
title: blog.seo_title ?? blog.title,
|
|
2166
|
+
description: blog.seo_description ?? blog.excerpt ?? undefined,
|
|
2167
|
+
images: blog.seo_image
|
|
2168
|
+
? [{ url: blog.seo_image }]
|
|
2169
|
+
: blog.image_url
|
|
2170
|
+
? [{ url: blog.image_url }]
|
|
2171
|
+
: undefined,
|
|
2172
|
+
},
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
2175
|
+
```
|
|
2176
|
+
|
|
2177
|
+
> `Blog`, `Event`, and `Service` all carry individual `seo_title`, `seo_description`, `seo_keywords`, and `seo_image` fields. Always prefer these over the generic page title when present.
|
|
2178
|
+
|
|
2179
|
+
---
|
|
2180
|
+
|
|
2181
|
+
## Image Setup (`next.config.ts`)
|
|
2182
|
+
|
|
2183
|
+
CMS images are served from an external domain. You must add it to `remotePatterns` in `next.config.ts` or Next.js will refuse to render them with `<Image>`.
|
|
2184
|
+
|
|
2185
|
+
```ts
|
|
2186
|
+
// next.config.ts
|
|
2187
|
+
import type { NextConfig } from "next";
|
|
2188
|
+
|
|
2189
|
+
const nextConfig: NextConfig = {
|
|
2190
|
+
images: {
|
|
2191
|
+
remotePatterns: [
|
|
2192
|
+
{
|
|
2193
|
+
protocol: "https",
|
|
2194
|
+
hostname: "api.cms.deployown.com", // CMS image host
|
|
2195
|
+
},
|
|
2196
|
+
// Add any other image CDN domains your project uses
|
|
2197
|
+
],
|
|
2198
|
+
},
|
|
2199
|
+
};
|
|
2200
|
+
|
|
2201
|
+
export default nextConfig;
|
|
2202
|
+
```
|
|
2203
|
+
|
|
2204
|
+
> Without this, `next/image` will throw a runtime error for any image URL returned from the CMS. Plain `<img>` tags work without this config but lose Next.js image optimization.
|
|
2205
|
+
|
|
2206
|
+
---
|
|
2207
|
+
|
|
2208
|
+
## Recommended Project Structure
|
|
2209
|
+
|
|
2210
|
+
Align your project structure to handle CMS data efficiently using Server Components and the declarative registry pattern.
|
|
2211
|
+
|
|
2212
|
+
```
|
|
2213
|
+
app/
|
|
2214
|
+
├── [[...slug]]/
|
|
2215
|
+
│ └── page.tsx # Catch-all — handles CMS pages + store routes
|
|
2216
|
+
├── layout.tsx # Root layout — fetches header, footer, siteConfig
|
|
2217
|
+
├── api/
|
|
2218
|
+
│ ├── contact/
|
|
2219
|
+
│ │ └── route.ts # CMS contact form submission
|
|
2220
|
+
│ └── store/
|
|
2221
|
+
│ └── orders/
|
|
2222
|
+
│ └── route.ts # Store order placement (placeOrder)
|
|
2223
|
+
components/
|
|
2224
|
+
├── pages/ # CMS page layouts (registry-mapped)
|
|
2225
|
+
│ ├── HomePage.tsx
|
|
2226
|
+
│ ├── AboutPage.tsx
|
|
2227
|
+
│ ├── BlogPage.tsx
|
|
2228
|
+
│ ├── BlogDetailPage.tsx
|
|
2229
|
+
│ ├── ServicesPage.tsx
|
|
2230
|
+
│ ├── ServiceDetailPage.tsx
|
|
2231
|
+
│ ├── EventPage.tsx
|
|
2232
|
+
│ ├── EventDetailPage.tsx
|
|
2233
|
+
│ ├── GalleryPage.tsx
|
|
2234
|
+
│ ├── GalleryDetailPage.tsx
|
|
2235
|
+
│ ├── TeamPage.tsx
|
|
2236
|
+
│ ├── TeamCategoryPage.tsx
|
|
2237
|
+
│ ├── TeamMemberDetailPage.tsx
|
|
2238
|
+
│ ├── ContactPage.tsx
|
|
2239
|
+
│ ├── CustomPage.tsx
|
|
2240
|
+
│ │
|
|
2241
|
+
│ │ # Store pages (hardcoded routes, not CMS-driven)
|
|
2242
|
+
│ ├── ProductsPage.tsx # /products
|
|
2243
|
+
│ ├── ProductDetailPage.tsx # /products/[slug]
|
|
2244
|
+
│ ├── ProductCategoriesPage.tsx # /categories
|
|
2245
|
+
│ ├── CategoryProductsPage.tsx # /categories/[slug]
|
|
2246
|
+
│ ├── BrandsPage.tsx # /brands
|
|
2247
|
+
│ ├── BrandProductsPage.tsx # /brands/[slug]
|
|
2248
|
+
│ ├── CollectionsPage.tsx # /collections
|
|
2249
|
+
│ └── CollectionDetailPage.tsx # /collections/[slug]
|
|
2250
|
+
├── store/ # Store UI components (client-side interactivity)
|
|
2251
|
+
│ ├── ProductDetailClient.tsx # Client component — variant selection + cart
|
|
2252
|
+
│ ├── CartDrawer.tsx # Slide-in cart panel
|
|
2253
|
+
│ └── CheckoutForm.tsx # Client component — order form
|
|
2254
|
+
├── sections/ # CMS section components
|
|
2255
|
+
│ ├── hero.tsx
|
|
2256
|
+
│ ├── custom.tsx
|
|
2257
|
+
│ ├── cta.tsx
|
|
2258
|
+
│ ├── service.tsx
|
|
2259
|
+
│ ├── testimonial.tsx
|
|
2260
|
+
│ ├── team.tsx
|
|
2261
|
+
│ ├── faq.tsx
|
|
2262
|
+
│ ├── clients.tsx
|
|
2263
|
+
│ ├── gallery.tsx
|
|
2264
|
+
│ ├── event.tsx
|
|
2265
|
+
│ ├── blog.tsx
|
|
2266
|
+
│ ├── rich-content.tsx
|
|
2267
|
+
│ ├── about.tsx
|
|
2268
|
+
│ └── multi-value.tsx
|
|
2269
|
+
└── render-sections.tsx # The section dispatcher
|
|
2270
|
+
context/
|
|
2271
|
+
└── CartContext.tsx # localStorage cart state + useCart hook
|
|
2272
|
+
config/
|
|
2273
|
+
└── store.ts # STORE_ENABLED feature flag
|
|
2274
|
+
lib/
|
|
2275
|
+
├── cms.ts # SDK client singleton (CMS + Store)
|
|
2276
|
+
├── cms-registry.ts # CMS page component map
|
|
2277
|
+
└── cms-router.ts # CMS route resolution
|
|
2278
|
+
```
|
|
2279
|
+
|
|
2280
|
+
> **Note**: This structure eliminates the need for hardcoded folders for `/blog`, `/services`, etc.
|
|
2281
|
+
|
|
2282
|
+
> All `sections/` components that need live data are **async server components**. The `contact-form.tsx` is the only client component (`"use client"`).
|
|
2283
|
+
|
|
2284
|
+
> Store pages are resolved first in `[[...slug]]/page.tsx` before CMS page lookup. `ProductDetailClient.tsx`, `CartDrawer.tsx`, `CheckoutForm.tsx`, and `CartContext.tsx` are the only store-side client components (`"use client"`).
|
|
2285
|
+
|
|
2286
|
+
## Core Concepts
|
|
2287
|
+
|
|
2288
|
+
### Pagination
|
|
2289
|
+
|
|
2290
|
+
List endpoints return a `PaginatedResponse<T>` object:
|
|
2291
|
+
|
|
2292
|
+
```typescript
|
|
2293
|
+
export interface PaginatedResponse<T> {
|
|
2294
|
+
data: T[];
|
|
2295
|
+
pagination: {
|
|
2296
|
+
page: number;
|
|
2297
|
+
limit: number;
|
|
2298
|
+
total: number;
|
|
2299
|
+
};
|
|
2300
|
+
}
|
|
2301
|
+
```
|
|
2302
|
+
|
|
2303
|
+
### Caching (Next.js)
|
|
2304
|
+
|
|
2305
|
+
All fetch methods accept `FetchOptions`:
|
|
2306
|
+
|
|
2307
|
+
```typescript
|
|
2308
|
+
export interface FetchOptions extends RequestInit {
|
|
2309
|
+
revalidate?: number; // Seconds to cache
|
|
2310
|
+
tags?: string[]; // Cache tags for on-demand revalidation
|
|
2311
|
+
}
|
|
2312
|
+
```
|
|
2313
|
+
|
|
2314
|
+
## API Reference
|
|
2315
|
+
|
|
2316
|
+
### Global Configuration
|
|
2317
|
+
|
|
2318
|
+
- `fetchHeader(siteId, options?)`: Fetches the site header navigation and CTAs.
|
|
2319
|
+
- `fetchFooter(siteId, options?)`: Fetches the site footer configuration.
|
|
2320
|
+
- `fetchSiteConfig(siteId, options?)`: Fetches site-wide settings (logo, name, etc.).
|
|
2321
|
+
|
|
2322
|
+
### Pages
|
|
2323
|
+
|
|
2324
|
+
- `fetchPages(siteId, params?, options?)`: Returns paginated page summaries (`10` items per backend page, no `sections`). Params: `{ page }`.
|
|
2325
|
+
- `fetchPageByUrl(siteId, urlPath, options?)`: Fetches a specific page directly by URL path.
|
|
2326
|
+
|
|
2327
|
+
### Blogs & Categories
|
|
2328
|
+
|
|
2329
|
+
- `fetchBlogs(siteId, params?, options?)`: Returns paginated blogs. Params: `{ page, limit, search }`.
|
|
2330
|
+
- `fetchBlogBySlug(siteId, slug, options?)`: Returns a single blog post by slug.
|
|
2331
|
+
- `fetchBlogById(siteId, idOrSlug, options?)`: Backwards-compatible alias (internally resolves via slug route).
|
|
2332
|
+
- `fetchCategories(siteId, options?)`: Returns all blog categories.
|
|
2333
|
+
|
|
2334
|
+
### Other Entities
|
|
2335
|
+
|
|
2336
|
+
- `fetchServices(siteId, options?)`: Returns all services.
|
|
2337
|
+
- `fetchServiceById(siteId, id, options?)`: Returns a single service by ID.
|
|
2338
|
+
- `fetchTeamMembers(siteId, options?)`: Returns all team members.
|
|
2339
|
+
- `fetchTeamMembersByCategory(siteId, categoryId, options?)`: Returns team members filtered by category.
|
|
2340
|
+
- `fetchTestimonials(siteId, params?, options?)`: Returns testimonials. Params: `{ type: 'testimonial' | 'review' }`.
|
|
2341
|
+
- `fetchEvents(siteId, params?, options?)`: Returns paginated events.
|
|
2342
|
+
- `fetchEventById(siteId, id, options?)`: Returns a single event by ID.
|
|
2343
|
+
- `fetchAlbums(siteId, params?, options?)`: Returns paginated albums.
|
|
2344
|
+
- `fetchAlbumItems(siteId, params, options?)`: Returns items for an album. Params: `{ album, album_id }`.
|
|
2345
|
+
|
|
2346
|
+
### FAQ & Help
|
|
2347
|
+
|
|
2348
|
+
- `fetchFaqGroups(siteId, options?)`: Returns FAQ groups with their nested FAQs.
|
|
2349
|
+
- `fetchFaqs(siteId, params?, options?)`: Returns flat list of FAQs. Params: `{ group_id }`.
|
|
2350
|
+
|
|
2351
|
+
### Forms & Submissions
|
|
2352
|
+
|
|
2353
|
+
- `submitContactForm(siteId, payload, options?)`: Submits a contact form.
|
|
2354
|
+
- **Payload Structure**:
|
|
2355
|
+
```typescript
|
|
2356
|
+
{
|
|
2357
|
+
name: string; // Required
|
|
2358
|
+
message: string; // Required
|
|
2359
|
+
email?: string; // Optional
|
|
2360
|
+
subject?: string; // Optional
|
|
2361
|
+
type?: string; // Default: "contact"
|
|
2362
|
+
}
|
|
2363
|
+
```
|
|
2364
|
+
|
|
2365
|
+
### Store
|
|
2366
|
+
|
|
2367
|
+
> All store methods use the `/api/public/store/` API prefix, not `/api/public/cms/`.
|
|
2368
|
+
|
|
2369
|
+
- `fetchProductCategories(siteId, options?)`: Returns all product categories.
|
|
2370
|
+
- `fetchProductBrands(siteId, options?)`: Returns all product brands.
|
|
2371
|
+
- `fetchProducts(siteId, params?, options?)`: Returns paginated products. Params: `{ page, limit, search, category_id, tag_id, brand_id, is_featured }`.
|
|
2372
|
+
- `category_id` is hierarchy-aware on the backend and includes child/grandchild categories.
|
|
2373
|
+
- `fetchProductDetail(siteId, slug, options?)`: Returns a single product by slug, **including `variants`**.
|
|
2374
|
+
- `fetchCollections(siteId, params?, options?)`: Returns collections (with `_count.items`, no products). Params: `{ page, limit, search, id }`. The `id` parameter accepts a comma-separated string of collection IDs (e.g., `"id1,id2"`) to filter the results.
|
|
2375
|
+
- `fetchCollectionDetail(siteId, slug, params?, options?)`: Returns a single collection **with full `items` array** (products + variants included).
|
|
2376
|
+
- Params: `{ page, limit, category_id }`
|
|
2377
|
+
- Works for both manual and smart collections
|
|
2378
|
+
- `fetchCollectionDetailById(siteId, id, params?, options?)`: Same as `fetchCollectionDetail`, but keyed by collection ID for CMS-driven product sections.
|
|
2379
|
+
- `placeOrder(siteId, payload, options?)`: Places an order. Call from a server API route — never client-side.
|
|
2380
|
+
|
|
2381
|
+
## Type System
|
|
2382
|
+
|
|
2383
|
+
All types are exported from the main package and are located in the `src/types/` directory.
|
|
2384
|
+
|
|
2385
|
+
### Importing Types
|
|
2386
|
+
|
|
2387
|
+
```typescript
|
|
2388
|
+
import type {
|
|
2389
|
+
Header,
|
|
2390
|
+
Footer,
|
|
2391
|
+
Blog,
|
|
2392
|
+
Page,
|
|
2393
|
+
Section, // Use for map logic
|
|
2394
|
+
ContactPayload, // Use for form submission
|
|
2395
|
+
SiteConfig,
|
|
2396
|
+
PaginatedResponse,
|
|
2397
|
+
} from "@crayons/cms-sdk";
|
|
2398
|
+
```
|
|
2399
|
+
|
|
2400
|
+
### Browsing All Types After Installation
|
|
2401
|
+
|
|
2402
|
+
After installing the package, the source files are not included. All exported types are compiled into a single declaration file:
|
|
2403
|
+
|
|
2404
|
+
```
|
|
2405
|
+
node_modules/@crayons/cms-sdk/dist/index.d.ts
|
|
2406
|
+
```
|
|
2407
|
+
|
|
2408
|
+
Open that file to see every type, interface, and method signature the package exports. Your editor's "Go to Definition" (`F12` / `Cmd+Click`) on any imported type will also jump straight to it.
|
|
2409
|
+
|
|
2410
|
+
### Key Type Locations (source)
|
|
2411
|
+
|
|
2412
|
+
> These paths are in the SDK repository itself, not in your project's `node_modules`.
|
|
2413
|
+
|
|
2414
|
+
- **Entities**: `src/types/[entity].ts` (e.g., `src/types/blog.ts`)
|
|
2415
|
+
- **API Wrappers**: `src/types/api-response.ts`
|
|
2416
|
+
- **Pagination**: `src/types/pagination.ts`
|
|
2417
|
+
- **Forms**: `src/types/contact.ts`
|
|
2418
|
+
|
|
2419
|
+
> **Browse all source types on GitHub:** [`src/types/`](https://github.com/CrayonsCodeTech/cms-sdk/tree/main/src/types)
|
|
2420
|
+
|
|
2421
|
+
### Rich Text / HTML Fields
|
|
2422
|
+
|
|
2423
|
+
Several fields in the type definitions contain **HTML markup** produced by the CMS rich-text editor. These fields must be rendered with `dangerouslySetInnerHTML` (or a sanitizer such as DOMPurify) — never as plain text.
|
|
2424
|
+
|
|
2425
|
+
Each such field is annotated with `@remarks Rendered as HTML` in its type definition. Hover over the field in your IDE to see the annotation, or browse the source on GitHub (link above).
|
|
2426
|
+
|
|
2427
|
+
**Fields that contain HTML:**
|
|
2428
|
+
|
|
2429
|
+
| Type | Field | Notes |
|
|
2430
|
+
| -------------------- | -------------- | ---------------------------------- |
|
|
2431
|
+
| `HeroContent` | `description` | Hero slide body copy |
|
|
2432
|
+
| `CustomContent` | `card_content` | Main body of a custom card |
|
|
2433
|
+
| `CustomContent` | `subtitle` | Secondary copy line (optional) |
|
|
2434
|
+
| `CTAContent` | `description` | CTA section body copy |
|
|
2435
|
+
| `MultiValueSection` | `description` | Section-level intro text |
|
|
2436
|
+
| `MultiValueItem` | `description` | Per-item description |
|
|
2437
|
+
| `RichContentSection` | `content` | Full rich-text article body |
|
|
2438
|
+
| `Faq` | `answer` | FAQ answer (supports lists, links) |
|
|
2439
|
+
| `Blog` | `description` | Full blog post body |
|
|
2440
|
+
| `Service` | `description` | Full service detail body |
|
|
2441
|
+
| `Event` | `description` | Full event detail body |
|
|
2442
|
+
|
|
2443
|
+
## Error Handling
|
|
2444
|
+
|
|
2445
|
+
The SDK is designed to be "fail-safe" for UI components:
|
|
2446
|
+
|
|
2447
|
+
- **Single Assets**: Return `null` on failure.
|
|
2448
|
+
- **Lists**: Return `[]` (empty array) on failure.
|
|
2449
|
+
- **Paginated Lists**: Return an empty `PaginatedResponse` structure.
|
|
2450
|
+
|
|
2451
|
+
Specific errors are logged to the console with the URL and status code. Transient server errors (502, 503, 504) are automatically retried up to 2 times with exponential backoff.
|
|
2452
|
+
|
|
2453
|
+
### Custom Error Class
|
|
2454
|
+
|
|
2455
|
+
```typescript
|
|
2456
|
+
import { CmsError } from "@crayons/cms-sdk";
|
|
2457
|
+
```
|
|
2458
|
+
|
|
2459
|
+
The `CmsError` class provides `status` and `url` properties for debugging.
|
|
2460
|
+
|
|
2461
|
+
## FAQ & Common Issues
|
|
2462
|
+
|
|
2463
|
+
This section addresses common questions and issues reported by developers.
|
|
2464
|
+
|
|
2465
|
+
### 1. Rendering Rich Text / "HTML Tags in Response" (#6)
|
|
2466
|
+
|
|
2467
|
+
Many CMS fields (like `description`, `content`, `vision`, `mission`) contain HTML markup. If you render them as plain text, you will see raw tags.
|
|
2468
|
+
|
|
2469
|
+
**Solution**: Use `dangerouslySetInnerHTML`.
|
|
2470
|
+
|
|
2471
|
+
```tsx
|
|
2472
|
+
// Simple rendering
|
|
2473
|
+
<div
|
|
2474
|
+
dangerouslySetInnerHTML={{ __html: blog.description }}
|
|
2475
|
+
className="prose max-w-none"
|
|
2476
|
+
/>;
|
|
2477
|
+
|
|
2478
|
+
// Recommended with Sanitization
|
|
2479
|
+
import DOMPurify from "isomorphic-dompurify";
|
|
2480
|
+
|
|
2481
|
+
export function RichText({ content }: { content: string }) {
|
|
2482
|
+
const cleanHtml = DOMPurify.sanitize(content);
|
|
2483
|
+
return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
|
|
2484
|
+
}
|
|
2485
|
+
```
|
|
2486
|
+
|
|
2487
|
+
### 2. Social Links as Raw URLs in Team Section (#8)
|
|
2488
|
+
|
|
2489
|
+
The `socials` field in `TeamMember` returns an array of objects.
|
|
2490
|
+
|
|
2491
|
+
**Solution**: Use the built-in `Icon` component to map platforms to icons.
|
|
2492
|
+
|
|
2493
|
+
```tsx
|
|
2494
|
+
import { Icon } from "@crayons/cms-sdk";
|
|
2495
|
+
|
|
2496
|
+
export function SocialLinks({ socials }: { socials: TeamMember["socials"] }) {
|
|
2497
|
+
if (!socials) return null;
|
|
2498
|
+
|
|
2499
|
+
return (
|
|
2500
|
+
<div className="flex gap-4">
|
|
2501
|
+
{socials.map((social, i) => (
|
|
2502
|
+
<a key={i} href={social.url} target="_blank" rel="noopener noreferrer">
|
|
2503
|
+
{/* Automatically handles "Facebook", "Twitter", "Linkedin", etc. */}
|
|
2504
|
+
<Icon name={social.platform || "Link"} size={20} />
|
|
2505
|
+
</a>
|
|
2506
|
+
))}
|
|
2507
|
+
</div>
|
|
2508
|
+
);
|
|
2509
|
+
}
|
|
2510
|
+
```
|
|
2511
|
+
|
|
2512
|
+
### 3. How to Render the "About" Page Section (#7, #5)
|
|
2513
|
+
|
|
2514
|
+
The `about` section type in the CMS refers to global "About Us" data (mission, vision, values, stats). When this section appears in a page's `sections` array, it only contains small heading/subtitles. You must fetch the actual company data using `fetchAboutUs`.
|
|
2515
|
+
|
|
2516
|
+
**Solution**: Fetch the data within your `AboutSection` component.
|
|
2517
|
+
|
|
2518
|
+
```tsx
|
|
2519
|
+
// components/sections/about.tsx
|
|
2520
|
+
import { cms, SITE_ID } from "@/lib/cms";
|
|
2521
|
+
import { Icon } from "@crayons/cms-sdk";
|
|
2522
|
+
import type { AboutSection as AboutSectionType } from "@crayons/cms-sdk";
|
|
2523
|
+
|
|
2524
|
+
export async function AboutSection({ content }: { content: AboutSectionType }) {
|
|
2525
|
+
const about = await cms.fetchAboutUs(SITE_ID);
|
|
2526
|
+
|
|
2527
|
+
if (!about) return null;
|
|
2528
|
+
|
|
2529
|
+
return (
|
|
2530
|
+
<section>
|
|
2531
|
+
<h2>{content.title || "About Us"}</h2>
|
|
2532
|
+
{content.subtitle && <p>{content.subtitle}</p>}
|
|
2533
|
+
|
|
2534
|
+
<div dangerouslySetInnerHTML={{ __html: about.company_profile }} />
|
|
2535
|
+
|
|
2536
|
+
<h3>Our Values</h3>
|
|
2537
|
+
<div className="grid">
|
|
2538
|
+
{about.values.map((v, i) => (
|
|
2539
|
+
<div key={i}>
|
|
2540
|
+
<Icon name={v.icon} />
|
|
2541
|
+
<h4>{v.title}</h4>
|
|
2542
|
+
<p>{v.description}</p>
|
|
2543
|
+
</div>
|
|
2544
|
+
))}
|
|
2545
|
+
</div>
|
|
2546
|
+
</section>
|
|
2547
|
+
);
|
|
2548
|
+
}
|
|
2549
|
+
```
|
|
2550
|
+
|
|
2551
|
+
### 4. How to Navigate Between Pages (#3)
|
|
2552
|
+
|
|
2553
|
+
The `Page` object returns a `url` property. Use the Next.js `<Link>` component for navigation. CMS URLs are relative to the root.
|
|
2554
|
+
|
|
2555
|
+
**Solution**:
|
|
2556
|
+
|
|
2557
|
+
```tsx
|
|
2558
|
+
import Link from "next/link";
|
|
2559
|
+
|
|
2560
|
+
// In your Header or Page
|
|
2561
|
+
{
|
|
2562
|
+
pages.map((page) => (
|
|
2563
|
+
<Link key={page.id} href={page.url === "/" ? "/" : `/${page.url}`}>
|
|
2564
|
+
{page.title}
|
|
2565
|
+
</Link>
|
|
2566
|
+
));
|
|
2567
|
+
}
|
|
2568
|
+
```
|
|
2569
|
+
|
|
2570
|
+
### 5. "How to see Types" (#4)
|
|
2571
|
+
|
|
2572
|
+
You can view all available types by looking at the `index.d.ts` file in `node_modules/@crayons/cms-sdk/dist/index.d.ts`. Alternatively, you can browse the source types in the GitHub repository's `src/types` folder.
|
|
2573
|
+
|
|
2574
|
+
**Solution**:
|
|
2575
|
+
|
|
2576
|
+
1. **Console Logging**: Since these are Server Components, logs will appear in your **terminal**, not the browser console.
|
|
2577
|
+
```ts
|
|
2578
|
+
const data = await fetchBlogs(siteId);
|
|
2579
|
+
console.log("DEBUG BLOGS:", JSON.stringify(data, null, 2));
|
|
2580
|
+
```
|
|
2581
|
+
2. **Type Inspection**: Hover over any variable in VS Code to see its structure, or CMD+Click on the fetch method to jump to the `index.d.ts` definition.
|
|
2582
|
+
|
|
2583
|
+
### 6. My Images are broken (#4)
|
|
2584
|
+
|
|
2585
|
+
If you see images in your data but they don't render with `<Image />`, you likely missed the `remotePatterns` config.
|
|
2586
|
+
|
|
2587
|
+
**Solution**: Ensure `next.config.ts` includes the CMS domain:
|
|
2588
|
+
|
|
2589
|
+
```ts
|
|
2590
|
+
remotePatterns: [{ protocol: "https", hostname: "api.cms.deployown.com" }];
|
|
2591
|
+
```
|
|
2592
|
+
|
|
2593
|
+
### 7. Environmental Variables not working
|
|
2594
|
+
|
|
2595
|
+
If `cms.fetch...` is failing with "Invalid URL", your `NEXT_PUBLIC_CMS_BASE_URL` might be missing or incorrectly formatted.
|
|
2596
|
+
|
|
2597
|
+
**Solution**:
|
|
2598
|
+
|
|
2599
|
+
- Ensure `.env.local` has `NEXT_PUBLIC_CMS_BASE_URL=https://api.cms.deployown.com` (no trailing slash).
|
|
2600
|
+
- If calling from a **Client Component**, the variable _must_ start with `NEXT_PUBLIC_`.
|
|
2601
|
+
|
|
2602
|
+
### 8. Handling Empty States
|
|
2603
|
+
|
|
2604
|
+
The SDK returns `[]` for lists and `null` for single objects if data is missing or an error occurs.
|
|
2605
|
+
|
|
2606
|
+
**Solution**: Always guard your components.
|
|
2607
|
+
|
|
2608
|
+
```tsx
|
|
2609
|
+
const services = await cms.fetchServices(SITE_ID);
|
|
2610
|
+
if (!services || services.length === 0) return <p>No services found.</p>;
|
|
2611
|
+
```
|
|
2612
|
+
|
|
2613
|
+
---
|
|
2614
|
+
|
|
2615
|
+
## Developer Tips
|
|
2616
|
+
|
|
2617
|
+
- **Site ID**: Always ensure your `SITE_ID` is valid, as most methods require it.
|
|
2618
|
+
- **Async Components**: Always use `await` when calling SDK methods inside Server Components.
|
|
2619
|
+
- **Section Variants**: All CMS sections have an optional `variant` field (e.g., `"home-1"`, `"about-2"`) for conditional styling. Check `section.variant` in your `RenderSections` component to render different visual styles of the same section type.
|
|
2620
|
+
- **Section IDs**: Each section has an auto-generated `id` in format `{type}-{count}` (e.g., `"hero-1"`, `"cta-2"`). Use this for targeting specific sections when needed.
|
|
2621
|
+
|
|
2622
|
+
---
|
|
2623
|
+
|
|
2624
|
+
## Full Data Flow — How Everything Connects
|
|
2625
|
+
|
|
2626
|
+
This is the complete picture of how a page request travels through the system from the browser to the screen.
|
|
2627
|
+
|
|
2628
|
+
### Step 1 — Browser makes a request
|
|
2629
|
+
|
|
2630
|
+
A user visits any URL, e.g. `/services` or `/blog/my-post`. Next.js routes every request to the single catch-all file: `app/[[...slug]]/page.tsx`.
|
|
2631
|
+
|
|
2632
|
+
### Step 2 — Catch-all fetches the pages list
|
|
2633
|
+
|
|
2634
|
+
The catch-all should call `fetchPageByUrl(siteId, urlPath)` for the current request path first. This means pages are fetched on-demand only when users navigate to them (SSR-friendly dynamic routing).
|
|
2635
|
+
|
|
2636
|
+
### Step 3 — URL is matched to a page
|
|
2637
|
+
|
|
2638
|
+
- **Exact match**: `fetchPageByUrl(siteId, urlPath)` returns a page (`/services`, `/blog`, `/news`, etc.).
|
|
2639
|
+
- **Parent match**: if exact lookup fails, walk parent paths (`/blog/my-post` -> check `/blog`) and treat remaining segments as the entity slug.
|
|
2640
|
+
- **No match**: `notFound()`.
|
|
2641
|
+
|
|
2642
|
+
### Step 4 — Page data is passed to the right component
|
|
2643
|
+
|
|
2644
|
+
Once a match is found, the catch-all looks up the correct page component from a registry (`PAGE_COMPONENT_MAP`) using `page_type`. It then renders that component, passing the matched `page` object plus route params/search params.
|
|
2645
|
+
|
|
2646
|
+
Detail pages (e.g. a single blog post) skip the `page` prop and receive `params` (containing the item slug) and `parentUrl` instead.
|
|
2647
|
+
|
|
2648
|
+
### Step 5 — Page component fetches its own entity data
|
|
2649
|
+
|
|
2650
|
+
The page component receives the `page` object which contains the **section chrome** (headings, subtitles, labels) for that page. It then calls its own API to get the actual content:
|
|
2651
|
+
|
|
2652
|
+
- `ServicesPage` calls `fetchServices(siteId)` to get the list of services.
|
|
2653
|
+
- `BlogsPage` calls `fetchBlogs(siteId, { page, limit })` with pagination from `searchParams`.
|
|
2654
|
+
- `EventsPage` calls `fetchEvents(siteId, { page, limit })`.
|
|
2655
|
+
- `GalleryPage` calls `fetchAlbums(siteId)`.
|
|
2656
|
+
- `AboutPage` calls `fetchAboutUs(siteId)` for mission, vision, values.
|
|
2657
|
+
- `HomePage` and `CustomPage` can render directly from the fetched page object.
|
|
2658
|
+
|
|
2659
|
+
Detail pages (e.g. `BlogDetailPage`) should call slug-based fetches directly (e.g. `fetchBlogBySlug(siteId, slug)`).
|
|
2660
|
+
|
|
2661
|
+
### Step 6 — Sections are rendered
|
|
2662
|
+
|
|
2663
|
+
The page component passes `page.sections` to `RenderSections` (or `SectionRenderer`). This maps each section's `type` to its component:
|
|
2664
|
+
|
|
2665
|
+
- Sections like `hero`, `cta`, `custom`, `rich-content`, `multi-value`, and `about` render **inline** — all their content is already inside `section.content`, no extra fetch needed.
|
|
2666
|
+
- Sections like `service`, `testimonial`, `team`, `faq`, `clients`, `gallery`, `event`, and `blog` only have heading/subtitle text in `section.content`. The section component fetches its own data (e.g. `TeamSection` calls `fetchTeamMembers`).
|
|
2667
|
+
|
|
2668
|
+
### Step 7 — HTML fields are sanitized and rendered
|
|
2669
|
+
|
|
2670
|
+
Some fields (`description`, `content`, `answer`, etc.) contain **HTML markup** from the CMS rich-text editor. These must be passed to `dangerouslySetInnerHTML` — always sanitize them with DOMPurify first. Fields that contain HTML are marked with a comment in their type definitions.
|
|
2671
|
+
|
|
2672
|
+
### Step 8 — Layout wraps everything
|
|
2673
|
+
|
|
2674
|
+
The root `layout.tsx` runs on every request independently of the catch-all. It calls `fetchHeader`, `fetchFooter`, and `fetchSiteConfig` once and wraps the rendered page in the site's navigation and footer.
|
|
2675
|
+
|
|
2676
|
+
---
|
|
2677
|
+
|
|
2678
|
+
### Summary in one line per step
|
|
2679
|
+
|
|
2680
|
+
| Step | What happens |
|
|
2681
|
+
|------|-------------|
|
|
2682
|
+
| 1 | Browser hits any URL → Next.js sends it to `[[...slug]]/page.tsx` |
|
|
2683
|
+
| 2 | Catch-all fetches the full pages list from the CMS |
|
|
2684
|
+
| 3 | URL is matched to a CMS page (exact) or its parent (detail) |
|
|
2685
|
+
| 4 | Matched page data is passed to the right page component via a registry |
|
|
2686
|
+
| 5 | Page component fetches its own entity data (services, blogs, events, etc.) |
|
|
2687
|
+
| 6 | `page.sections` is passed to `RenderSections`; data-driven sections fetch their own data |
|
|
2688
|
+
| 7 | Rich-text HTML fields are sanitized (DOMPurify) before rendering |
|
|
2689
|
+
| 8 | Root layout independently fetches header, footer, and site config |
|