@crayonscodetech/cms-sdk 1.0.8 → 1.0.9

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
@@ -785,7 +785,7 @@ Different page types follow different rendering strategies. Understanding these
785
785
  | `/gallery` | `page` + `albums` | `fetchPageByUrl(siteId, "/gallery")` + `fetchAlbums(siteId, params)` |
786
786
  | `/gallery/[slug]` | `albums` + `album-items` | `fetchAlbums(siteId, { limit })` + `fetchAlbumItems(siteId, { album: slug })` |
787
787
  | `/team/[slug]` | `team-members` | `fetchTeamMembers(siteId)` (slug lookup) or custom `fetch` |
788
- | `/contact` | `contact` (form submissions) | `submitContactForm(siteId, payload)` |
788
+ | `/contact` | `contact` (form submissions) | `fetchContactConfig(siteId)` + `submitContactForm(siteId, payload, attachments?)` |
789
789
 
790
790
  ### Home Page — Section Rendering with Targeting
791
791
 
@@ -1264,22 +1264,42 @@ export default async function AlbumDetailPage({
1264
1264
 
1265
1265
  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.
1266
1266
 
1267
+ **If the site has Turnstile enabled, the form will not work without it.** Fetch the config
1268
+ on the server, render the widget with the site key, and forward the token through your own
1269
+ API route along with the rest of the payload.
1270
+
1267
1271
  ```tsx
1268
- // components/pages/ContactPage.tsx
1272
+ // components/pages/ContactPage.tsx (server component)
1269
1273
  import { ContactForm } from "@/components/contact-form";
1270
- import type { Page, SiteConfig } from "@crayons/cms-sdk";
1274
+ import { cms, SITE_ID } from "@/lib/cms";
1275
+ import type { Page, SiteConfig } from "@crayonscodetech/cms-sdk";
1271
1276
 
1272
- export default function ContactPage({
1277
+ export default async function ContactPage({
1273
1278
  page,
1274
1279
  site,
1275
1280
  }: {
1276
1281
  page: Page;
1277
1282
  site?: SiteConfig | null;
1278
1283
  }) {
1284
+ // Public site key only — safe to hand to the client.
1285
+ const contactConfig = await cms.fetchContactConfig(SITE_ID);
1286
+ const turnstile = contactConfig?.turnstile;
1287
+
1288
+ // enabled with no site key = misconfigured; the widget cannot render and every
1289
+ // submission would be rejected, so do not show a form that cannot succeed.
1290
+ if (turnstile?.enabled && !turnstile.site_key) {
1291
+ return (
1292
+ <main>
1293
+ <h1>Contact Us</h1>
1294
+ <p>The contact form is temporarily unavailable.</p>
1295
+ </main>
1296
+ );
1297
+ }
1298
+
1279
1299
  return (
1280
1300
  <main>
1281
1301
  <h1>Contact Us</h1>
1282
- <ContactForm />
1302
+ <ContactForm turnstileSiteKey={turnstile?.enabled ? turnstile.site_key : null} />
1283
1303
  </main>
1284
1304
  );
1285
1305
  }
@@ -1289,39 +1309,63 @@ export default function ContactPage({
1289
1309
  // components/contact-form.tsx (client component — handles submission)
1290
1310
  "use client";
1291
1311
 
1292
- import { useState } from "react";
1293
- import type { ContactPayload } from "@crayons/cms-sdk";
1312
+ import { useRef, useState } from "react";
1313
+ import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile";
1294
1314
 
1295
- export function ContactForm() {
1315
+ export function ContactForm({
1316
+ turnstileSiteKey,
1317
+ }: {
1318
+ turnstileSiteKey: string | null;
1319
+ }) {
1296
1320
  const [status, setStatus] = useState<
1297
1321
  "idle" | "sending" | "success" | "error"
1298
1322
  >("idle");
1323
+ const [error, setError] = useState("");
1324
+ const [token, setToken] = useState("");
1325
+ const widget = useRef<TurnstileInstance>(null);
1299
1326
 
1300
1327
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
1301
1328
  e.preventDefault();
1329
+ if (turnstileSiteKey && !token) {
1330
+ setError("Please complete the verification.");
1331
+ return;
1332
+ }
1302
1333
  setStatus("sending");
1334
+ setError("");
1303
1335
 
1304
1336
  const form = e.currentTarget;
1305
- const payload: ContactPayload = {
1337
+ const payload = {
1306
1338
  name: (form.elements.namedItem("name") as HTMLInputElement).value,
1307
1339
  email: (form.elements.namedItem("email") as HTMLInputElement).value,
1308
1340
  subject: (form.elements.namedItem("subject") as HTMLInputElement).value,
1309
- message: (form.elements.namedItem("message") as HTMLTextAreaElement)
1310
- .value,
1341
+ message: (form.elements.namedItem("message") as HTMLTextAreaElement).value,
1311
1342
  type: "contact",
1343
+ turnstile_token: token,
1312
1344
  };
1313
1345
 
1314
1346
  try {
1315
- // submitContactForm is called from a server action or API route to keep SITE_ID server-side
1347
+ // Proxied through your own route so SITE_ID stays server-side.
1316
1348
  const res = await fetch("/api/contact", {
1317
1349
  method: "POST",
1318
1350
  body: JSON.stringify(payload),
1319
1351
  headers: { "Content-Type": "application/json" },
1320
1352
  });
1321
1353
 
1322
- setStatus(res.ok ? "success" : "error");
1354
+ if (res.ok) {
1355
+ setStatus("success");
1356
+ } else {
1357
+ const body = await res.json().catch(() => ({}));
1358
+ setError(body.error ?? "Something went wrong. Please try again.");
1359
+ setStatus("error");
1360
+ }
1323
1361
  } catch {
1362
+ setError("Something went wrong. Please try again.");
1324
1363
  setStatus("error");
1364
+ } finally {
1365
+ // Turnstile tokens are single-use — always reset, success or failure, or
1366
+ // the next submit replays a spent token and is rejected.
1367
+ widget.current?.reset();
1368
+ setToken("");
1325
1369
  }
1326
1370
  }
1327
1371
 
@@ -1331,11 +1375,14 @@ export function ContactForm() {
1331
1375
  <input name="email" type="email" placeholder="Email" />
1332
1376
  <input name="subject" placeholder="Subject" />
1333
1377
  <textarea name="message" placeholder="Message" required />
1378
+ {turnstileSiteKey && (
1379
+ <Turnstile ref={widget} siteKey={turnstileSiteKey} onSuccess={setToken} />
1380
+ )}
1334
1381
  <button type="submit" disabled={status === "sending"}>
1335
1382
  {status === "sending" ? "Sending…" : "Send"}
1336
1383
  </button>
1337
1384
  {status === "success" && <p>Message sent!</p>}
1338
- {status === "error" && <p>Something went wrong. Please try again.</p>}
1385
+ {status === "error" && <p>{error}</p>}
1339
1386
  </form>
1340
1387
  );
1341
1388
  }
@@ -1344,12 +1391,30 @@ export function ContactForm() {
1344
1391
  ```ts
1345
1392
  // app/api/contact/route.ts (server — keeps SITE_ID out of the client bundle)
1346
1393
  import { cms, SITE_ID } from "@/lib/cms";
1347
- import type { ContactPayload } from "@crayons/cms-sdk";
1394
+ import { CmsError, type ContactPayload } from "@crayonscodetech/cms-sdk";
1395
+
1396
+ export const dynamic = "force-dynamic";
1348
1397
 
1349
1398
  export async function POST(req: Request) {
1350
1399
  const payload: ContactPayload = await req.json();
1351
- const result = await cms.submitContactForm(SITE_ID, payload);
1352
- return Response.json(result);
1400
+
1401
+ try {
1402
+ const contact = await cms.submitContactForm(SITE_ID, payload);
1403
+ return Response.json({ ok: true, contact });
1404
+ } catch (e) {
1405
+ // submitContactForm throws rather than returning null, so the visitor can be
1406
+ // told what actually went wrong.
1407
+ if (e instanceof CmsError) {
1408
+ const message =
1409
+ e.status === 403
1410
+ ? "Verification failed. Please try again."
1411
+ : e.status === 429
1412
+ ? "Too many attempts. Please wait a moment."
1413
+ : e.message;
1414
+ return Response.json({ ok: false, error: message }, { status: e.status ?? 502 });
1415
+ }
1416
+ return Response.json({ ok: false, error: "Submission failed" }, { status: 502 });
1417
+ }
1353
1418
  }
1354
1419
  ```
1355
1420
 
@@ -2623,15 +2688,44 @@ export interface FetchOptions extends RequestInit {
2623
2688
 
2624
2689
  ### Forms & Submissions
2625
2690
 
2626
- - `submitContactForm(siteId, payload, options?)`: Submits a contact form.
2691
+ - `fetchContactConfig(siteId, options?)`: Public Turnstile settings for the contact form.
2692
+ Call it before rendering the form.
2693
+ - **Returns**: `{ turnstile: { enabled: boolean; site_key: string | null } }`
2694
+ - When `enabled` is true you MUST render the Turnstile widget with `site_key` and pass the
2695
+ resulting token as `turnstile_token`, or every submission is rejected with 403.
2696
+ - `enabled: true` with a null `site_key` means the site is misconfigured — hide the form
2697
+ rather than submitting into a guaranteed rejection.
2698
+
2699
+ - `submitContactForm(siteId, payload, attachments?, options?)`: Submits a contact form.
2627
2700
  - **Payload Structure**:
2628
2701
  ```typescript
2629
2702
  {
2630
- name: string; // Required
2631
- message: string; // Required
2632
- email?: string; // Optional
2633
- subject?: string; // Optional
2634
- type?: string; // Default: "contact"
2703
+ name: string; // Required
2704
+ message: string; // Required
2705
+ email?: string; // Optional
2706
+ subject?: string; // Optional
2707
+ type?: string; // Default: "contact"
2708
+ turnstile_token?: string; // Required when Turnstile is enabled
2709
+ }
2710
+ ```
2711
+ - **Attachments** (optional): `File[]`. Sending them switches the request to multipart.
2712
+ Caps, enforced server-side and pre-checked client-side: max 3 files, 4 MiB total, and a
2713
+ MIME allowlist (PDF, PNG, JPEG, WebP, GIF, plain text, DOC, DOCX).
2714
+ - **Throws `CmsError` instead of returning `null`.** Unlike the read methods, a form needs
2715
+ to distinguish rejection kinds, so failures throw with `error.status`:
2716
+ `403` captcha failed (reset the widget — tokens are single-use), `429` rate limited,
2717
+ `400` validation, `503` Turnstile misconfigured server-side.
2718
+ - Never retried: a resend would duplicate the submission.
2719
+
2720
+ ```typescript
2721
+ import { CmsError } from "@crayonscodetech/cms-sdk";
2722
+
2723
+ try {
2724
+ const contact = await cms.submitContactForm(SITE_ID, payload, files);
2725
+ } catch (e) {
2726
+ if (e instanceof CmsError && e.status === 403) {
2727
+ // captcha rejected — reset the widget and let the visitor retry
2728
+ }
2635
2729
  }
2636
2730
  ```
2637
2731
 
@@ -2655,12 +2749,39 @@ export interface FetchOptions extends RequestInit {
2655
2749
 
2656
2750
  ## Sitemap
2657
2751
 
2658
- 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.
2752
+ The SDK exposes twelve lightweight sitemap endpoints that return only the fields needed to build an XML sitemap (slug/URL, image, title/name, plus `updatedAt` for `lastModified`). Each endpoint filters to published content only and defaults to up to **2,000 items per request** — enough for most sites without needing to paginate.
2659
2753
 
2660
2754
  > **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.
2661
2755
 
2662
2756
  ### Methods
2663
2757
 
2758
+ Use `fetchSitemap(siteId, resource, params?, options?)` for any resource. The
2759
+ return type narrows automatically from the resource key:
2760
+
2761
+ ```typescript
2762
+ const pages = await cms.fetchSitemap(siteId, "pages");
2763
+ pages.data[0].url; // typed — pages are addressed by url
2764
+ const blogs = await cms.fetchSitemap(siteId, "blogs");
2765
+ blogs.data[0].slug; // typed — everything else is addressed by slug
2766
+ ```
2767
+
2768
+ | `resource` | Namespace | Item type |
2769
+ | --------------------- | --------- | ----------------------------- |
2770
+ | `"blogs"` | cms | `SitemapBlogItem` |
2771
+ | `"pages"` | cms | `SitemapPageItem` |
2772
+ | `"services"` | cms | `SitemapServiceItem` |
2773
+ | `"events"` | cms | `SitemapEventItem` |
2774
+ | `"albums"` | cms | `SitemapAlbumItem` |
2775
+ | `"team-members"` | cms | `SitemapTeamMemberItem` |
2776
+ | `"team-categories"` | cms | `SitemapTeamCategoryItem` |
2777
+ | `"brand-groups"` | cms | `SitemapBrandGroupItem` |
2778
+ | `"products"` | store | `SitemapProductItem` |
2779
+ | `"collections"` | store | `SitemapCollectionItem` |
2780
+ | `"product-categories"`| store | `SitemapProductCategoryItem` |
2781
+ | `"product-brands"` | store | `SitemapProductBrandItem` |
2782
+
2783
+ These four remain available as named shortcuts:
2784
+
2664
2785
  | Method | Returns |
2665
2786
  | -------------------------------------------------------- | ---------------------------------------------- |
2666
2787
  | `fetchSitemapBlogs(siteId, params?, options?)` | `PaginatedResponse<SitemapBlogItem>` |
@@ -2668,32 +2789,42 @@ The SDK exposes four lightweight sitemap endpoints that return only the fields n
2668
2789
  | `fetchSitemapProducts(siteId, params?, options?)` | `PaginatedResponse<SitemapProductItem>` |
2669
2790
  | `fetchSitemapCollections(siteId, params?, options?)` | `PaginatedResponse<SitemapCollectionItem>` |
2670
2791
 
2671
- All four accept optional `{ page?: number; limit?: number }` params.
2792
+ All accept optional `{ page?: number; limit?: number }` params.
2793
+
2794
+ **Field naming:** most resources return `title`; `products` and `collections`
2795
+ return `name` instead (kept for backwards compatibility). `pages` is addressed
2796
+ by `url` rather than `slug`, and `pages`, `team-categories` and `brand-groups`
2797
+ have no `image` field.
2672
2798
 
2673
2799
  ### Types
2674
2800
 
2675
2801
  ```typescript
2802
+ // Every item type includes `updatedAt` — use it for `lastModified`.
2676
2803
  interface SitemapBlogItem {
2677
2804
  slug: string;
2678
2805
  image: string | null;
2679
2806
  title: string;
2807
+ updatedAt: string;
2680
2808
  }
2681
2809
 
2682
2810
  interface SitemapPageItem {
2683
2811
  url: string; // e.g. "/about", "/services"
2684
2812
  title: string;
2813
+ updatedAt: string;
2685
2814
  }
2686
2815
 
2687
2816
  interface SitemapProductItem {
2688
2817
  slug: string;
2689
2818
  image: string | null;
2690
2819
  name: string;
2820
+ updatedAt: string;
2691
2821
  }
2692
2822
 
2693
2823
  interface SitemapCollectionItem {
2694
2824
  slug: string;
2695
2825
  image: string | null;
2696
2826
  name: string;
2827
+ updatedAt: string;
2697
2828
  }
2698
2829
  ```
2699
2830
 
@@ -2749,7 +2880,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
2749
2880
  }
2750
2881
  ```
2751
2882
 
2752
- > **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.
2883
+ > **Note:** If your site has more than 2,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.
2753
2884
 
2754
2885
  ## Type System
2755
2886