@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 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 |