@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 +483 -111
- package/dist/index.cjs +36 -0
- package/dist/index.d.cts +38 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +38 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +36 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @
|
|
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
|
|
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
|
|
528
|
+
return (
|
|
529
|
+
<ServiceSection key={section.id} content={section.content} />
|
|
530
|
+
);
|
|
525
531
|
|
|
526
532
|
case "testimonial":
|
|
527
|
-
return
|
|
533
|
+
return (
|
|
534
|
+
<TestimonialSection key={section.id} content={section.content} />
|
|
535
|
+
);
|
|
528
536
|
|
|
529
537
|
case "multi-value":
|
|
530
|
-
return
|
|
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
|
|
546
|
+
return (
|
|
547
|
+
<ClientsSection key={section.id} content={section.content} />
|
|
548
|
+
);
|
|
537
549
|
|
|
538
550
|
case "gallery":
|
|
539
|
-
return
|
|
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
|
|
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
|
|
573
|
+
return (
|
|
574
|
+
<MarqueeSection key={section.id} content={section.content} />
|
|
575
|
+
);
|
|
558
576
|
|
|
559
577
|
case "history":
|
|
560
|
-
return
|
|
578
|
+
return (
|
|
579
|
+
<HistorySection key={section.id} content={section.content} />
|
|
580
|
+
);
|
|
561
581
|
|
|
562
582
|
case "products":
|
|
563
|
-
return
|
|
583
|
+
return (
|
|
584
|
+
<ProductsSection key={section.id} content={section.content} />
|
|
585
|
+
);
|
|
564
586
|
|
|
565
587
|
case "collection-group":
|
|
566
588
|
return (
|
|
567
|
-
<CollectionGroupSection
|
|
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
|
|
591
|
-
| ---------------- |
|
|
592
|
-
| Hero | `"hero"`
|
|
593
|
-
| Custom | `"custom"`
|
|
594
|
-
| Call to Action | `"cta"`
|
|
595
|
-
| Rich Content | `"rich-content"`
|
|
596
|
-
| About | `"about"`
|
|
597
|
-
| Multi Value | `"multi-value"`
|
|
598
|
-
| Services | `"service"`
|
|
599
|
-
| Testimonials | `"testimonial"`
|
|
600
|
-
| Team | `"team"`
|
|
601
|
-
| FAQ | `"faq"`
|
|
602
|
-
| Clients / Brands | `"clients"`
|
|
603
|
-
| Gallery | `"gallery"`
|
|
604
|
-
| Events | `"event"`
|
|
605
|
-
| Blog | `"blog"`
|
|
606
|
-
| Products | `"products"`
|
|
607
|
-
| Collection Group | `"collection-group"` | `CollectionGroupSection` | `collections`
|
|
608
|
-
| Marquee | `"marquee"`
|
|
609
|
-
| History | `"history"` | `HistorySection`
|
|
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
|
|
1341
|
-
|
|
1342
|
-
| API prefix
|
|
1343
|
-
| Route management | CMS dashboard (page_type)
|
|
1344
|
-
| Content editing
|
|
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
|
|
1358
|
-
|
|
1359
|
-
| `product.ts`
|
|
1360
|
-
| `product-category.ts` | `ProductCategory`
|
|
1361
|
-
| `product-brand.ts`
|
|
1362
|
-
| `collection.ts`
|
|
1363
|
-
| `order.ts`
|
|
1364
|
-
| `seo.ts`
|
|
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 (
|
|
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
|
|
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
|
|
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<{
|
|
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>
|
|
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))}
|
|
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}
|
|
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({
|
|
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({
|
|
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
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
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 $
|
|
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: (
|
|
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(
|
|
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<
|
|
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)
|
|
2038
|
-
|
|
2039
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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: `${
|
|
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
|
-
|
|
2141
|
-
|
|
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
|
|
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
|
|
3004
|
+
### Step 2 — Catch-all fetches the page by URL
|
|
2633
3005
|
|
|
2634
|
-
The catch-all
|
|
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
|
|
2683
|
-
| 2
|
|
2684
|
-
| 3
|
|
2685
|
-
| 4
|
|
2686
|
-
| 5
|
|
2687
|
-
| 6
|
|
2688
|
-
| 7
|
|
2689
|
-
| 8
|
|
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 |
|