@crayonscodetech/cms-sdk 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @crayons/cms-sdk
1
+ # @crayonscodetech/cms-sdk
2
2
 
3
3
  A robust, type-safe SDK/Package for fetching data from the Crayons CMS. Designed for Next.js.
4
4
 
@@ -28,6 +28,8 @@ Content updates and management are handled through the CMS dashboard:
28
28
  You can install the SDK directly from private GitHub repository. Ensure you have access before proceeding.
29
29
 
30
30
  ```bash
31
+ npm install @crayonscodetech/cms-sdk
32
+ # or
31
33
  npm install git+ssh://git@github.com/CrayonsCodeTech/cms-sdk.git
32
34
  # or
33
35
  pnpm add git+ssh://git@github.com/CrayonsCodeTech/cms-sdk.git --allow-build=@crayons/cms-sdk
@@ -505,7 +507,9 @@ export function RenderSections({ sections }: { sections: Section[] }) {
505
507
  return <HeroDark key={section.id} content={section.content} />;
506
508
  }
507
509
  if (section.variant === "home-2") {
508
- return <HeroCentered key={section.id} content={section.content} />;
510
+ return (
511
+ <HeroCentered key={section.id} content={section.content} />
512
+ );
509
513
  }
510
514
  // Default fallback when variant is undefined/null
511
515
  return <HeroSection key={section.id} content={section.content} />;
@@ -521,22 +525,32 @@ export function RenderSections({ sections }: { sections: Section[] }) {
521
525
  return <CtaSection key={section.id} content={section.content} />;
522
526
 
523
527
  case "service":
524
- return <ServiceSection key={section.id} content={section.content} />;
528
+ return (
529
+ <ServiceSection key={section.id} content={section.content} />
530
+ );
525
531
 
526
532
  case "testimonial":
527
- return <TestimonialSection key={section.id} content={section.content} />;
533
+ return (
534
+ <TestimonialSection key={section.id} content={section.content} />
535
+ );
528
536
 
529
537
  case "multi-value":
530
- return <MultiValueSection key={section.id} content={section.content} />;
538
+ return (
539
+ <MultiValueSection key={section.id} content={section.content} />
540
+ );
531
541
 
532
542
  case "team":
533
543
  return <TeamSection key={section.id} content={section.content} />;
534
544
 
535
545
  case "clients":
536
- return <ClientsSection key={section.id} content={section.content} />;
546
+ return (
547
+ <ClientsSection key={section.id} content={section.content} />
548
+ );
537
549
 
538
550
  case "gallery":
539
- return <GallerySection key={section.id} content={section.content} />;
551
+ return (
552
+ <GallerySection key={section.id} content={section.content} />
553
+ );
540
554
 
541
555
  case "event":
542
556
  return <EventSection key={section.id} content={section.content} />;
@@ -545,7 +559,9 @@ export function RenderSections({ sections }: { sections: Section[] }) {
545
559
  return <BlogSection key={section.id} content={section.content} />;
546
560
 
547
561
  case "rich-content":
548
- return <RichContentSection key={section.id} content={section.content} />;
562
+ return (
563
+ <RichContentSection key={section.id} content={section.content} />
564
+ );
549
565
 
550
566
  case "about":
551
567
  return <AboutSection key={section.id} content={section.content} />;
@@ -554,17 +570,26 @@ export function RenderSections({ sections }: { sections: Section[] }) {
554
570
  return <FaqSection key={section.id} content={section.content} />;
555
571
 
556
572
  case "marquee":
557
- return <MarqueeSection key={section.id} content={section.content} />;
573
+ return (
574
+ <MarqueeSection key={section.id} content={section.content} />
575
+ );
558
576
 
559
577
  case "history":
560
- return <HistorySection key={section.id} content={section.content} />;
578
+ return (
579
+ <HistorySection key={section.id} content={section.content} />
580
+ );
561
581
 
562
582
  case "products":
563
- return <ProductsSection key={section.id} content={section.content} />;
583
+ return (
584
+ <ProductsSection key={section.id} content={section.content} />
585
+ );
564
586
 
565
587
  case "collection-group":
566
588
  return (
567
- <CollectionGroupSection key={section.id} content={section.content} />
589
+ <CollectionGroupSection
590
+ key={section.id}
591
+ content={section.content}
592
+ />
568
593
  );
569
594
 
570
595
  default:
@@ -578,6 +603,7 @@ export function RenderSections({ sections }: { sections: Section[] }) {
578
603
  ```
579
604
 
580
605
  > **Note:** Each section includes:
606
+ >
581
607
  > - **`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
608
  > - **`variant`**: Optional style variant (e.g., `"home-1"`, `"home-2"`, `"about-1"`) for conditional styling.
583
609
  >
@@ -587,26 +613,26 @@ export function RenderSections({ sections }: { sections: Section[] }) {
587
613
 
588
614
  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
615
 
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 |
616
+ | Section name | `type` discriminant | Content type | Primary table/entity | API call(s) needed |
617
+ | ---------------- | -------------------- | ------------------------ | ----------------------------- | ------------------------------------------------------------------------------------------- |
618
+ | Hero | `"hero"` | `HeroContent[]` | `page.sections` (from `page`) | None — content is inline |
619
+ | Custom | `"custom"` | `CustomContent` | `page.sections` (from `page`) | None — content is inline |
620
+ | Call to Action | `"cta"` | `CTAContent` | `page.sections` (from `page`) | None — content is inline |
621
+ | Rich Content | `"rich-content"` | `RichContentSection` | `page.sections` (from `page`) | None — content is inline |
622
+ | About | `"about"` | `AboutSection` | `page.sections` + `about-us` | `fetchAboutUs(siteId)` for profile/vision/mission/stats |
623
+ | Multi Value | `"multi-value"` | `MultiValueSection` | `page.sections` (from `page`) | None — content is inline |
624
+ | Services | `"service"` | `ServicesSection` | `services` | `fetchServices(siteId)` |
625
+ | Testimonials | `"testimonial"` | `TestimonialsSection` | `testimonials` | `fetchTestimonials(siteId, { type })` — use `content.type` to filter |
626
+ | Team | `"team"` | `TeamSection` | `team-members` | `fetchTeamMembers(siteId)` / `fetchTeamMembersByCategory(siteId, content.team_category_id)` |
627
+ | FAQ | `"faq"` | `FaqSection` | `faq-groups` + `faqs` | `fetchFaqGroups(siteId)` or `fetchFaqs(siteId, { group_id: content.group_id })` |
628
+ | Clients / Brands | `"clients"` | `ClientsSection` | `brand-groups` + `brands` | `fetchBrandGroups(siteId)` + `fetchBrands(siteId, { group_id: content.brand_group_id })` |
629
+ | Gallery | `"gallery"` | `GallerySection` | `albums` + `album-items` | `fetchAlbums(siteId)` + `fetchAlbumItems(siteId, { album_id })` as needed |
630
+ | Events | `"event"` | `GenericSection` | `events` | `fetchEvents(siteId, { page, limit, search })` |
631
+ | Blog | `"blog"` | `GenericSection` | `blog` | `fetchBlogs(siteId, { page, limit, search })` |
632
+ | Products | `"products"` | `ProductsSection` | `products` / `collections` | `fetchProducts(...)` or `fetchCollectionDetailById(...)`, depending on `content.filter` |
633
+ | Collection Group | `"collection-group"` | `CollectionGroupSection` | `collections` | `fetchCollections(siteId, { id: content.collection_groups.join(',') })` |
634
+ | Marquee | `"marquee"` | `MarqueeSection` | `page.sections` (from `page`) | None — content is inline |
635
+ | History | `"history"` | `HistorySection` | `page.sections` (from `page`) | None — content is inline |
610
636
 
611
637
  **How to handle this in section components:**
612
638
 
@@ -745,21 +771,21 @@ Different page types follow different rendering strategies. Understanding these
745
771
 
746
772
  ### Page Fetch Map (Route -> Table/Entity -> SDK Fetch)
747
773
 
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)` |
774
+ | Route | Primary tables/entities | Required fetch call(s) |
775
+ | ----------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------- |
776
+ | `/` (home) | `page` (+ inline `page.sections`) | `fetchPageByUrl(siteId, "/")` |
777
+ | `[[...slug]]` CMS pages | `page` (+ inline `page.sections`) | `fetchPageByUrl(siteId, urlPath)` |
778
+ | `/about` | `page` + `about-us` | `fetchPageByUrl(siteId, "/about")` + `fetchAboutUs(siteId)` |
779
+ | `/services` | `page` + `services` | `fetchPageByUrl(siteId, "/services")` + `fetchServices(siteId)` |
780
+ | `/services/[slug]` | `services` | `fetchServices(siteId)` (slug lookup) or `fetchServiceById(siteId, id)` |
755
781
  | `/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)` |
782
+ | `/blog/[slug]` | `blog` | `fetchBlogBySlug(siteId, slug)` |
783
+ | `/events` | `page` + `events` | `fetchPageByUrl(siteId, "/events")` + `fetchEvents(siteId, params)` |
784
+ | `/events/[slug]` | `events` | `fetchEvents(siteId, { limit })` (slug lookup) or `fetchEventById(siteId, id)` |
785
+ | `/gallery` | `page` + `albums` | `fetchPageByUrl(siteId, "/gallery")` + `fetchAlbums(siteId, params)` |
786
+ | `/gallery/[slug]` | `albums` + `album-items` | `fetchAlbums(siteId, { limit })` + `fetchAlbumItems(siteId, { album: slug })` |
787
+ | `/team/[slug]` | `team-members` | `fetchTeamMembers(siteId)` (slug lookup) or custom `fetch` |
788
+ | `/contact` | `contact` (form submissions) | `submitContactForm(siteId, payload)` |
763
789
 
764
790
  ### Home Page — Section Rendering with Targeting
765
791
 
@@ -1337,11 +1363,11 @@ The store is a separate product/e-commerce layer built on top of the CMS. It use
1337
1363
 
1338
1364
  ### Overview
1339
1365
 
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 |
1366
+ | Concern | CMS | Store |
1367
+ | ---------------- | --------------------------- | ----------------------------- |
1368
+ | API prefix | `/api/public/cms/{siteId}/` | `/api/public/store/{siteId}/` |
1369
+ | Route management | CMS dashboard (page_type) | Hardcoded in `[[...slug]]` |
1370
+ | Content editing | Via CMS | Via store admin |
1345
1371
 
1346
1372
  **Feature flag** — gate all store UI behind a constant so it can be disabled per project:
1347
1373
 
@@ -1354,14 +1380,14 @@ export const STORE_ENABLED = true;
1354
1380
 
1355
1381
  ### Store Types
1356
1382
 
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` |
1383
+ | File | Exports |
1384
+ | --------------------- | --------------------------------------------------------------------------------------- |
1385
+ | `product.ts` | `Product`, `ProductVariant`, `ProductImage`, `ProductStatus` |
1386
+ | `product-category.ts` | `ProductCategory` |
1387
+ | `product-brand.ts` | `ProductBrand` |
1388
+ | `collection.ts` | `Collection`, `CollectionDetail`, `CollectionItem` |
1389
+ | `order.ts` | `Order`, `OrderItem`, `ShippingAddress`, `PlaceOrderPayload`, `CartItem`, `OrderStatus` |
1390
+ | `seo.ts` | `ProductSEO`, `ProductExtraData` |
1365
1391
 
1366
1392
  ```ts
1367
1393
  import type {
@@ -1380,15 +1406,17 @@ import type {
1380
1406
  ```
1381
1407
 
1382
1408
  > **SEO and Extra Fields**
1383
- >
1409
+ >
1384
1410
  > The following types include `seo` and `extra` fields:
1411
+ >
1385
1412
  > - `Product`
1386
1413
  > - `ProductListItem`
1387
1414
  > - `ProductCategory`
1388
1415
  > - `ProductBrand`
1389
1416
  > - `Collection` / `CollectionListItem`
1390
- >
1417
+ >
1391
1418
  > The `ProductSEO` interface contains:
1419
+ >
1392
1420
  > ```ts
1393
1421
  > interface ProductSEO {
1394
1422
  > title?: string | null;
@@ -1396,7 +1424,7 @@ import type {
1396
1424
  > tags?: string[] | null;
1397
1425
  > }
1398
1426
  > ```
1399
- >
1427
+ >
1400
1428
  > `ProductExtraData` is a flexible `Record<string, unknown>` for custom data.
1401
1429
 
1402
1430
  > `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`.
@@ -1436,7 +1464,10 @@ export default async function CatchAll({ params, searchParams }) {
1436
1464
  const { slug = [] } = await params;
1437
1465
 
1438
1466
  // ── Store routes (resolved before CMS pages) ──────────────────────────────
1439
- if (!STORE_ENABLED && ["products","categories","brands","collections"].includes(slug[0])) {
1467
+ if (
1468
+ !STORE_ENABLED &&
1469
+ ["products", "categories", "brands", "collections"].includes(slug[0])
1470
+ ) {
1440
1471
  notFound();
1441
1472
  }
1442
1473
 
@@ -1447,12 +1478,22 @@ export default async function CatchAll({ params, searchParams }) {
1447
1478
 
1448
1479
  if (slug[0] === "categories") {
1449
1480
  if (slug.length === 1) return <ProductCategoriesPage />;
1450
- return <CategoryProductsPage params={Promise.resolve({ slug: slug[1] })} searchParams={searchParams} />;
1481
+ return (
1482
+ <CategoryProductsPage
1483
+ params={Promise.resolve({ slug: slug[1] })}
1484
+ searchParams={searchParams}
1485
+ />
1486
+ );
1451
1487
  }
1452
1488
 
1453
1489
  if (slug[0] === "brands") {
1454
1490
  if (slug.length === 1) return <BrandsPage />;
1455
- return <BrandProductsPage params={Promise.resolve({ slug: slug[1] })} searchParams={searchParams} />;
1491
+ return (
1492
+ <BrandProductsPage
1493
+ params={Promise.resolve({ slug: slug[1] })}
1494
+ searchParams={searchParams}
1495
+ />
1496
+ );
1456
1497
  }
1457
1498
 
1458
1499
  if (slug[0] === "collections") {
@@ -1476,7 +1517,11 @@ import type { Product, ProductCategory } from "@crayons/cms-sdk";
1476
1517
  import Link from "next/link";
1477
1518
 
1478
1519
  interface Props {
1479
- searchParams: Promise<{ page?: string; search?: string; category_id?: string }>;
1520
+ searchParams: Promise<{
1521
+ page?: string;
1522
+ search?: string;
1523
+ category_id?: string;
1524
+ }>;
1480
1525
  }
1481
1526
 
1482
1527
  export default async function ProductsPage({ searchParams }: Props) {
@@ -1519,7 +1564,9 @@ export default async function ProductsPage({ searchParams }: Props) {
1519
1564
  </div>
1520
1565
 
1521
1566
  {/* Pagination */}
1522
- <p>Page {pagination.page} • Total products: {pagination.total}</p>
1567
+ <p>
1568
+ Page {pagination.page} • Total products: {pagination.total}
1569
+ </p>
1523
1570
  </div>
1524
1571
  );
1525
1572
  }
@@ -1562,7 +1609,7 @@ import { useCart } from "@/context/CartContext";
1562
1609
  export default function ProductDetailClient({ product }: { product: Product }) {
1563
1610
  const { addItem } = useCart();
1564
1611
  const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
1565
- product.variants?.[0] ?? null
1612
+ product.variants?.[0] ?? null,
1566
1613
  );
1567
1614
  const [quantity, setQuantity] = useState(1);
1568
1615
 
@@ -1597,7 +1644,9 @@ export default function ProductDetailClient({ product }: { product: Product }) {
1597
1644
 
1598
1645
  {/* Quantity + add to cart */}
1599
1646
  <div>
1600
- <button onClick={() => setQuantity((q) => Math.max(1, q - 1))}>-</button>
1647
+ <button onClick={() => setQuantity((q) => Math.max(1, q - 1))}>
1648
+ -
1649
+ </button>
1601
1650
  <span>{quantity}</span>
1602
1651
  <button onClick={() => setQuantity((q) => q + 1)}>+</button>
1603
1652
  </div>
@@ -1617,7 +1666,9 @@ export default function ProductDetailClient({ product }: { product: Product }) {
1617
1666
  <div key={group}>
1618
1667
  <h3>{group}</h3>
1619
1668
  {Object.entries(specs).map(([k, v]) => (
1620
- <p key={k}><strong>{k}:</strong> {v}</p>
1669
+ <p key={k}>
1670
+ <strong>{k}:</strong> {v}
1671
+ </p>
1621
1672
  ))}
1622
1673
  </div>
1623
1674
  ))}
@@ -1665,7 +1716,10 @@ interface Props {
1665
1716
  searchParams: Promise<{ page?: string; search?: string }>;
1666
1717
  }
1667
1718
 
1668
- export default async function CategoryProductsPage({ params, searchParams }: Props) {
1719
+ export default async function CategoryProductsPage({
1720
+ params,
1721
+ searchParams,
1722
+ }: Props) {
1669
1723
  const { slug } = await params;
1670
1724
  const { page = "1", search } = await searchParams;
1671
1725
 
@@ -1740,7 +1794,10 @@ interface Props {
1740
1794
  searchParams: Promise<{ page?: string; search?: string }>;
1741
1795
  }
1742
1796
 
1743
- export default async function BrandProductsPage({ params, searchParams }: Props) {
1797
+ export default async function BrandProductsPage({
1798
+ params,
1799
+ searchParams,
1800
+ }: Props) {
1744
1801
  const { slug } = await params;
1745
1802
  const { page = "1", search } = await searchParams;
1746
1803
 
@@ -1818,15 +1875,11 @@ export default async function CollectionDetailPage({
1818
1875
  }) {
1819
1876
  const { slug } = await params;
1820
1877
  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
- );
1878
+ const collection = await cms.fetchCollectionDetail(SITE_ID, slug, {
1879
+ category_id,
1880
+ page: Number(page),
1881
+ limit: 20,
1882
+ });
1830
1883
 
1831
1884
  if (!collection) notFound();
1832
1885
 
@@ -1851,7 +1904,10 @@ export default async function CollectionDetailPage({
1851
1904
  {/* Show lowest variant price */}
1852
1905
  {item.product.variants.length > 0 && (
1853
1906
  <p>
1854
- From ${Math.min(...item.product.variants.map((v) => v.sale_price ?? v.price))}
1907
+ From $
1908
+ {Math.min(
1909
+ ...item.product.variants.map((v) => v.sale_price ?? v.price),
1910
+ )}
1855
1911
  </p>
1856
1912
  )}
1857
1913
  </Link>
@@ -1889,7 +1945,11 @@ interface CartContextValue {
1889
1945
  totalItems: number;
1890
1946
  subtotal: number;
1891
1947
  isOpen: boolean;
1892
- addItem: (product: Product, variant: ProductVariant, quantity: number) => void;
1948
+ addItem: (
1949
+ product: Product,
1950
+ variant: ProductVariant,
1951
+ quantity: number,
1952
+ ) => void;
1893
1953
  removeItem: (variantId: string) => void;
1894
1954
  updateQuantity: (variantId: string, quantity: number) => void;
1895
1955
  clearCart: () => void;
@@ -1914,14 +1974,18 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
1914
1974
  localStorage.setItem("cart", JSON.stringify(items));
1915
1975
  }, [items]);
1916
1976
 
1917
- function addItem(product: Product, variant: ProductVariant, quantity: number) {
1977
+ function addItem(
1978
+ product: Product,
1979
+ variant: ProductVariant,
1980
+ quantity: number,
1981
+ ) {
1918
1982
  setItems((prev) => {
1919
1983
  const existing = prev.find((i) => i.variant.id === variant.id);
1920
1984
  if (existing) {
1921
1985
  return prev.map((i) =>
1922
1986
  i.variant.id === variant.id
1923
1987
  ? { ...i, quantity: i.quantity + quantity }
1924
- : i
1988
+ : i,
1925
1989
  );
1926
1990
  }
1927
1991
  return [...prev, { product, variant, quantity }];
@@ -1934,7 +1998,7 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
1934
1998
 
1935
1999
  function updateQuantity(variantId: string, quantity: number) {
1936
2000
  setItems((prev) =>
1937
- prev.map((i) => (i.variant.id === variantId ? { ...i, quantity } : i))
2001
+ prev.map((i) => (i.variant.id === variantId ? { ...i, quantity } : i)),
1938
2002
  );
1939
2003
  }
1940
2004
 
@@ -1945,7 +2009,7 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
1945
2009
  const totalItems = items.reduce((sum, i) => sum + i.quantity, 0);
1946
2010
  const subtotal = items.reduce(
1947
2011
  (sum, i) => sum + (i.variant.sale_price ?? i.variant.price) * i.quantity,
1948
- 0
2012
+ 0,
1949
2013
  );
1950
2014
 
1951
2015
  return (
@@ -2026,7 +2090,9 @@ import { useCart } from "@/context/CartContext";
2026
2090
 
2027
2091
  export function CheckoutForm() {
2028
2092
  const { items, subtotal, clearCart } = useCart();
2029
- const [status, setStatus] = useState<"idle" | "placing" | "success" | "error">("idle");
2093
+ const [status, setStatus] = useState<
2094
+ "idle" | "placing" | "success" | "error"
2095
+ >("idle");
2030
2096
 
2031
2097
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
2032
2098
  e.preventDefault();
@@ -2034,9 +2100,12 @@ export function CheckoutForm() {
2034
2100
 
2035
2101
  const form = e.currentTarget;
2036
2102
  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,
2103
+ customer_name: (form.elements.namedItem("name") as HTMLInputElement)
2104
+ .value,
2105
+ customer_email: (form.elements.namedItem("email") as HTMLInputElement)
2106
+ .value,
2107
+ customer_phone:
2108
+ (form.elements.namedItem("phone") as HTMLInputElement).value || null,
2040
2109
  shipping_address: {
2041
2110
  line1: (form.elements.namedItem("line1") as HTMLInputElement).value,
2042
2111
  city: (form.elements.namedItem("city") as HTMLInputElement).value,
@@ -2047,7 +2116,8 @@ export function CheckoutForm() {
2047
2116
  product_variant_id: i.variant.id,
2048
2117
  quantity: i.quantity,
2049
2118
  })),
2050
- notes: (form.elements.namedItem("notes") as HTMLTextAreaElement).value || null,
2119
+ notes:
2120
+ (form.elements.namedItem("notes") as HTMLTextAreaElement).value || null,
2051
2121
  };
2052
2122
 
2053
2123
  const res = await fetch("/api/store/orders", {
@@ -2077,7 +2147,10 @@ export function CheckoutForm() {
2077
2147
 
2078
2148
  <p>Subtotal: ${subtotal.toFixed(2)}</p>
2079
2149
 
2080
- <button type="submit" disabled={status === "placing" || items.length === 0}>
2150
+ <button
2151
+ type="submit"
2152
+ disabled={status === "placing" || items.length === 0}
2153
+ >
2081
2154
  {status === "placing" ? "Placing order…" : "Place Order"}
2082
2155
  </button>
2083
2156
  {status === "success" && <p>Order placed successfully!</p>}
@@ -2089,10 +2162,196 @@ export function CheckoutForm() {
2089
2162
 
2090
2163
  ---
2091
2164
 
2165
+ ## Redirects
2166
+
2167
+ The CMS supports managed redirects (301/302/307/308) configured through the dashboard. The SDK provides three methods:
2168
+
2169
+ | Method | Use |
2170
+ | -------------------------------------------------- | ----------------------------------------------------------------------- |
2171
+ | `resolveRedirect(siteId, sourcePath)` | Resolve a single path — use this in middleware |
2172
+ | `fetchRedirects(siteId)` | Fetch all redirects — use this in `next.config.ts` for static redirects |
2173
+ | `reportRedirect404(siteId, sourcePath, referrer?)` | Log a 404 hit so the CMS can suggest redirect candidates |
2174
+
2175
+ `resolveRedirect` supports both **manual** redirects (exact path match) and **pattern** redirects (e.g. `/blog/:slug → /news/:slug`). When a pattern matches, `ResolvedRedirect.params` contains the captured values and `destinationPath` already has them substituted in.
2176
+
2177
+ ---
2178
+
2179
+ ### Option A — Middleware (Recommended)
2180
+
2181
+ Handle redirects at the edge before any page renders. This is the correct approach for SSR/ISR apps deployed to Vercel, Cloudflare Workers, or any edge runtime.
2182
+
2183
+ Use the shared CMS client singleton from `@/lib/cms` — do **not** create a new client inside middleware.
2184
+
2185
+ ```ts
2186
+ // middleware.ts
2187
+ import { NextResponse } from "next/server";
2188
+ import type { NextRequest } from "next/server";
2189
+ import { cms, SITE_ID } from "@/lib/cms";
2190
+
2191
+ export async function middleware(request: NextRequest) {
2192
+ const { pathname, search } = request.nextUrl;
2193
+
2194
+ // Skip internal Next.js paths, API routes, static assets, and home page
2195
+ if (
2196
+ pathname.startsWith("/_next") ||
2197
+ pathname.startsWith("/api") ||
2198
+ pathname.includes(".") ||
2199
+ pathname === "/"
2200
+ ) {
2201
+ return NextResponse.next();
2202
+ }
2203
+
2204
+ // Normalize trailing slash for consistent CMS lookup
2205
+ const normalizedPath =
2206
+ pathname.length > 1 && pathname.endsWith("/")
2207
+ ? pathname.slice(0, -1)
2208
+ : pathname;
2209
+
2210
+ try {
2211
+ const resolution = await cms.resolveRedirect(SITE_ID, normalizedPath);
2212
+
2213
+ if (resolution && resolution.redirect.enabled) {
2214
+ const { destinationPath, redirect } = resolution;
2215
+ const destinationUrl = new URL(destinationPath, request.url);
2216
+
2217
+ // Forward original query string if destination has none
2218
+ if (search && !destinationUrl.search) {
2219
+ destinationUrl.search = search;
2220
+ }
2221
+
2222
+ return NextResponse.redirect(destinationUrl, {
2223
+ status: redirect.status_code || 301,
2224
+ });
2225
+ }
2226
+ } catch (error) {
2227
+ // Silently continue — never let redirect errors break page rendering
2228
+ console.error("Middleware redirect resolution error:", error);
2229
+ }
2230
+
2231
+ return NextResponse.next();
2232
+ }
2233
+
2234
+ export const config = {
2235
+ matcher: [
2236
+ "/((?!api|_next/static|_next/image|favicon.ico|assets|sitemap.xml|robots.txt).*)",
2237
+ ],
2238
+ };
2239
+ ```
2240
+
2241
+ > **Edge Runtime note**: `cms.resolveRedirect` uses `fetch` internally, which is available in both Node.js and Edge runtimes. This middleware is safe to deploy to Cloudflare Workers and Vercel Edge.
2242
+
2243
+ ---
2244
+
2245
+ ### Option B — `next.config.ts` (Static only)
2246
+
2247
+ Use this when you want redirects baked in at build time (no runtime latency). Suitable for a small, infrequently-changing redirect list.
2248
+
2249
+ ```ts
2250
+ // next.config.ts
2251
+ import type { NextConfig } from "next";
2252
+ import { createCmsClient } from "@crayons/cms-sdk";
2253
+
2254
+ const cms = createCmsClient({
2255
+ baseUrl: process.env.NEXT_PUBLIC_CMS_BASE_URL || "",
2256
+ });
2257
+
2258
+ const SITE_ID = process.env.NEXT_PUBLIC_CMS_SITE_ID || "";
2259
+
2260
+ const nextConfig: NextConfig = {
2261
+ async redirects() {
2262
+ const redirects = await cms.fetchRedirects(SITE_ID);
2263
+
2264
+ return redirects
2265
+ .filter((r) => r.enabled)
2266
+ .map((r) => ({
2267
+ source: r.source_path,
2268
+ destination: r.destination_path,
2269
+ permanent: r.status_code === 301 || r.status_code === 308,
2270
+ }));
2271
+ },
2272
+ };
2273
+
2274
+ export default nextConfig;
2275
+ ```
2276
+
2277
+ > **Limitation**: These are resolved once at build time. New redirects added in the CMS dashboard won't take effect until the next deployment. Use middleware (Option A) if redirects need to update without a redeploy.
2278
+
2279
+ ---
2280
+
2281
+ ### Logging 404s as Redirect Candidates
2282
+
2283
+ `reportRedirect404` must be called from a **client component** — `not-found.tsx` runs before the URL is known server-side, so `window.location.pathname` is the reliable source. Create a small `NotFoundLogger` client component and drop it into your `not-found.tsx`.
2284
+
2285
+ ```tsx
2286
+ // components/shared/NotFoundLogger.tsx
2287
+ "use client";
2288
+
2289
+ import { useEffect } from "react";
2290
+ import { cms, SITE_ID } from "@/lib/cms";
2291
+
2292
+ // Module-level deduplication flag (prevents React Strict Mode double-fire in dev)
2293
+ declare global {
2294
+ interface Window {
2295
+ __lastRedirect404Log?: { path: string; timestamp: number };
2296
+ }
2297
+ }
2298
+
2299
+ export default function NotFoundLogger() {
2300
+ useEffect(() => {
2301
+ if (!SITE_ID || typeof window === "undefined") return;
2302
+
2303
+ const pathname = window.location.pathname;
2304
+ if (!pathname || pathname === "/") return;
2305
+
2306
+ const now = Date.now();
2307
+ const previous = window.__lastRedirect404Log;
2308
+
2309
+ // Skip duplicate reports within 1 second (Strict Mode remounts)
2310
+ if (previous?.path === pathname && now - previous.timestamp < 1000) return;
2311
+
2312
+ window.__lastRedirect404Log = { path: pathname, timestamp: now };
2313
+
2314
+ void cms.reportRedirect404(
2315
+ SITE_ID,
2316
+ pathname,
2317
+ document.referrer || undefined,
2318
+ {
2319
+ cache: "no-store",
2320
+ },
2321
+ );
2322
+ }, []);
2323
+
2324
+ return null;
2325
+ }
2326
+ ```
2327
+
2328
+ ```tsx
2329
+ // app/not-found.tsx
2330
+ import NotFoundLogger from "@/components/shared/NotFoundLogger";
2331
+
2332
+ export default function NotFoundPage() {
2333
+ return (
2334
+ <>
2335
+ <NotFoundLogger />
2336
+ <main>
2337
+ <h1>Page not found</h1>
2338
+ <p>The page you are looking for does not exist.</p>
2339
+ </main>
2340
+ </>
2341
+ );
2342
+ }
2343
+ ```
2344
+
2345
+ > The logger renders nothing — it only fires the `reportRedirect404` call on mount. The CMS dashboard accumulates these hits and surfaces high-frequency 404 paths as redirect candidates.
2346
+
2347
+ ---
2348
+
2092
2349
  ## SEO & Metadata
2093
2350
 
2094
2351
  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
2352
 
2353
+ > **Required env var for canonical URLs**: add `NEXT_PUBLIC_SITE_URL=https://www.yoursite.com` (your website's own domain — not the CMS API URL) to `.env.local`. Without it, `canonical` and `og:url` tags are omitted.
2354
+
2096
2355
  ```tsx
2097
2356
  // app/[[...slug]]/page.tsx
2098
2357
  import type { Metadata } from "next";
@@ -2112,7 +2371,9 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
2112
2371
  ]);
2113
2372
 
2114
2373
  const siteName = siteConfig?.site_name ?? "";
2115
- const baseUrl = process.env.NEXT_PUBLIC_CMS_BASE_URL ?? "";
2374
+ // NEXT_PUBLIC_SITE_URL is your website's own domain (e.g. https://www.example.com),
2375
+ // NOT the CMS API URL.
2376
+ const siteUrl = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, "");
2116
2377
 
2117
2378
  if (!page?.seo) {
2118
2379
  return { title: siteName };
@@ -2127,7 +2388,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
2127
2388
  openGraph: {
2128
2389
  title: title ?? siteName,
2129
2390
  description: description ?? undefined,
2130
- url: `${baseUrl}${urlPath}`,
2391
+ url: siteUrl ? `${siteUrl}${urlPath}` : undefined,
2131
2392
  siteName,
2132
2393
  images: image ? [{ url: image }] : undefined,
2133
2394
  },
@@ -2137,9 +2398,11 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
2137
2398
  description: description ?? undefined,
2138
2399
  images: image ? [image] : undefined,
2139
2400
  },
2140
- alternates: {
2141
- canonical: `${baseUrl}${urlPath}`,
2142
- },
2401
+ ...(siteUrl && {
2402
+ alternates: {
2403
+ canonical: `${siteUrl}${urlPath}`,
2404
+ },
2405
+ }),
2143
2406
  };
2144
2407
  }
2145
2408
  ```
@@ -2343,6 +2606,12 @@ export interface FetchOptions extends RequestInit {
2343
2606
  - `fetchAlbums(siteId, params?, options?)`: Returns paginated albums.
2344
2607
  - `fetchAlbumItems(siteId, params, options?)`: Returns items for an album. Params: `{ album, album_id }`.
2345
2608
 
2609
+ ### Redirects
2610
+
2611
+ - `fetchRedirects(siteId, options?)`: Returns all enabled and disabled redirects. Use in `next.config.ts` for build-time static redirects.
2612
+ - `resolveRedirect(siteId, sourcePath, options?)`: Resolves a single path against CMS redirects. Returns `ResolvedRedirect` (with `destinationPath`, `params`, `type`) or `null`. Supports pattern redirects with captured params. Use in middleware.
2613
+ - `reportRedirect404(siteId, sourcePath, referrer?, options?)`: Logs a 404 hit to the CMS for redirect candidate tracking. Call fire-and-forget from `not-found.tsx`.
2614
+
2346
2615
  ### FAQ & Help
2347
2616
 
2348
2617
  - `fetchFaqGroups(siteId, options?)`: Returns FAQ groups with their nested FAQs.
@@ -2378,6 +2647,106 @@ export interface FetchOptions extends RequestInit {
2378
2647
  - `fetchCollectionDetailById(siteId, id, params?, options?)`: Same as `fetchCollectionDetail`, but keyed by collection ID for CMS-driven product sections.
2379
2648
  - `placeOrder(siteId, payload, options?)`: Places an order. Call from a server API route — never client-side.
2380
2649
 
2650
+ ---
2651
+
2652
+ ## Sitemap
2653
+
2654
+ The SDK exposes four lightweight sitemap endpoints that return only the fields needed to build an XML sitemap (slug/URL, image, title/name). Each endpoint filters to published content only and defaults to up to **5,000 items per request** — enough for most sites without needing to paginate.
2655
+
2656
+ > **These endpoints must only be called once per day.** Place them inside Next.js's `app/sitemap.ts` file and export `revalidate = 86400`. Never call them at request time.
2657
+
2658
+ ### Methods
2659
+
2660
+ | Method | Returns |
2661
+ | -------------------------------------------------------- | ---------------------------------------------- |
2662
+ | `fetchSitemapBlogs(siteId, params?, options?)` | `PaginatedResponse<SitemapBlogItem>` |
2663
+ | `fetchSitemapPages(siteId, params?, options?)` | `PaginatedResponse<SitemapPageItem>` |
2664
+ | `fetchSitemapProducts(siteId, params?, options?)` | `PaginatedResponse<SitemapProductItem>` |
2665
+ | `fetchSitemapCollections(siteId, params?, options?)` | `PaginatedResponse<SitemapCollectionItem>` |
2666
+
2667
+ All four accept optional `{ page?: number; limit?: number }` params.
2668
+
2669
+ ### Types
2670
+
2671
+ ```typescript
2672
+ interface SitemapBlogItem {
2673
+ slug: string;
2674
+ image: string | null;
2675
+ title: string;
2676
+ }
2677
+
2678
+ interface SitemapPageItem {
2679
+ url: string; // e.g. "/about", "/services"
2680
+ title: string;
2681
+ }
2682
+
2683
+ interface SitemapProductItem {
2684
+ slug: string;
2685
+ image: string | null;
2686
+ name: string;
2687
+ }
2688
+
2689
+ interface SitemapCollectionItem {
2690
+ slug: string;
2691
+ image: string | null;
2692
+ name: string;
2693
+ }
2694
+ ```
2695
+
2696
+ ### Next.js `app/sitemap.ts` example
2697
+
2698
+ Place this file at `app/sitemap.ts`. Next.js calls it at build time and regenerates it every 24 hours via ISR.
2699
+
2700
+ ```typescript
2701
+ import type { MetadataRoute } from "next";
2702
+ import { createCmsClient } from "@crayonscodetech/cms-sdk";
2703
+
2704
+ // Regenerate the sitemap at most once per day — do NOT remove this export.
2705
+ export const revalidate = 86400;
2706
+
2707
+ const cms = createCmsClient({ baseUrl: process.env.NEXT_PUBLIC_CMS_API_URL! });
2708
+ const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID!;
2709
+ const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL!; // e.g. "https://example.com"
2710
+
2711
+ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
2712
+ const [blogs, pages, products, collections] = await Promise.all([
2713
+ cms.fetchSitemapBlogs(SITE_ID),
2714
+ cms.fetchSitemapPages(SITE_ID),
2715
+ cms.fetchSitemapProducts(SITE_ID),
2716
+ cms.fetchSitemapCollections(SITE_ID),
2717
+ ]);
2718
+
2719
+ const blogEntries: MetadataRoute.Sitemap = blogs.data.map((b) => ({
2720
+ url: `${BASE_URL}/blog/${b.slug}`,
2721
+ images: b.image ? [b.image] : undefined,
2722
+ }));
2723
+
2724
+ const pageEntries: MetadataRoute.Sitemap = pages.data.map((p) => ({
2725
+ url: `${BASE_URL}${p.url}`,
2726
+ }));
2727
+
2728
+ const productEntries: MetadataRoute.Sitemap = products.data.map((p) => ({
2729
+ url: `${BASE_URL}/products/${p.slug}`,
2730
+ images: p.image ? [p.image] : undefined,
2731
+ }));
2732
+
2733
+ const collectionEntries: MetadataRoute.Sitemap = collections.data.map((c) => ({
2734
+ url: `${BASE_URL}/collections/${c.slug}`,
2735
+ images: c.image ? [c.image] : undefined,
2736
+ }));
2737
+
2738
+ return [
2739
+ { url: BASE_URL }, // homepage
2740
+ ...pageEntries,
2741
+ ...blogEntries,
2742
+ ...productEntries,
2743
+ ...collectionEntries,
2744
+ ];
2745
+ }
2746
+ ```
2747
+
2748
+ > **Note:** If your site has more than 5,000 entries for any content type, use the `limit` param together with Next.js's [`generateSitemaps`](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap#generating-multiple-sitemaps) to split the output across multiple sitemap files.
2749
+
2381
2750
  ## Type System
2382
2751
 
2383
2752
  All types are exported from the main package and are located in the `src/types/` directory.
@@ -2394,6 +2763,9 @@ import type {
2394
2763
  ContactPayload, // Use for form submission
2395
2764
  SiteConfig,
2396
2765
  PaginatedResponse,
2766
+ Redirect,
2767
+ ResolvedRedirect,
2768
+ RedirectStatusCode,
2397
2769
  } from "@crayons/cms-sdk";
2398
2770
  ```
2399
2771
 
@@ -2560,7 +2932,7 @@ import Link from "next/link";
2560
2932
  // In your Header or Page
2561
2933
  {
2562
2934
  pages.map((page) => (
2563
- <Link key={page.id} href={page.url === "/" ? "/" : `/${page.url}`}>
2935
+ <Link key={page.id} href={page.url}>
2564
2936
  {page.title}
2565
2937
  </Link>
2566
2938
  ));
@@ -2629,9 +3001,9 @@ This is the complete picture of how a page request travels through the system fr
2629
3001
 
2630
3002
  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
3003
 
2632
- ### Step 2 — Catch-all fetches the pages list
3004
+ ### Step 2 — Catch-all fetches the page by URL
2633
3005
 
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).
3006
+ The catch-all calls `fetchPageByUrl(siteId, urlPath)` for the current request path. Pages are fetched on-demand only when users navigate to them (SSR-friendly dynamic routing).
2635
3007
 
2636
3008
  ### Step 3 — URL is matched to a page
2637
3009
 
@@ -2677,13 +3049,13 @@ The root `layout.tsx` runs on every request independently of the catch-all. It c
2677
3049
 
2678
3050
  ### Summary in one line per step
2679
3051
 
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 |
3052
+ | Step | What happens |
3053
+ | ---- | ---------------------------------------------------------------------------------------- |
3054
+ | 1 | Browser hits any URL → Next.js sends it to `[[...slug]]/page.tsx` |
3055
+ | 2 | Catch-all calls `fetchPageByUrl` for the current URL path |
3056
+ | 3 | URL is matched to a CMS page (exact) or its parent (detail) |
3057
+ | 4 | Matched page data is passed to the right page component via a registry |
3058
+ | 5 | Page component fetches its own entity data (services, blogs, events, etc.) |
3059
+ | 6 | `page.sections` is passed to `RenderSections`; data-driven sections fetch their own data |
3060
+ | 7 | Rich-text HTML fields are sanitized (DOMPurify) before rendering |
3061
+ | 8 | Root layout independently fetches header, footer, and site config |