@sprintup-cms/sdk 1.8.59 → 1.8.62

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
@@ -17,7 +17,7 @@ Official SDK for **SprintUp Forge CMS** — typed API client, Next.js App Router
17
17
  4. [Verify your connection](#verify-your-connection)
18
18
  5. [Adding new pages](#adding-new-pages)
19
19
  6. [Custom page layouts](#custom-page-layouts)
20
- 7. [Navigation and footer](#navigation-and-footer)
20
+ 7. [Navigation and footer](#navigation-and-footer) — automatically rendered by the SDK via CMS Globals
21
21
  8. [Extending with custom blocks](#extending-with-custom-blocks)
22
22
  9. [ISR and caching strategy](#isr-and-caching-strategy)
23
23
  10. [API reference](#api-reference)
@@ -288,48 +288,60 @@ export default async function Page({ params }: { params: Promise<{ slug: string[
288
288
 
289
289
  ## Navigation and footer
290
290
 
291
- Pull live nav and footer data from the CMS site structure:
291
+ > **As of v1.8.62**, navigation and footer are managed as **CMS Globals** — configured entirely in the CMS Admin under **Globals → Navigation** and **Globals → Footer**. You do not need to write any header or footer code. The SDK renders them automatically.
292
292
 
293
- ```tsx
294
- // components/layout/header.tsx
295
- import { cmsClient } from '@sprintup-cms/sdk'
296
- import type { CMSMenuItem } from '@sprintup-cms/sdk'
297
- import Link from 'next/link'
293
+ ### How it works
298
294
 
299
- export async function Header() {
300
- const structure = await cmsClient.getSiteStructure()
301
- const navItems: CMSMenuItem[] = structure?.menus.header ?? []
295
+ `CMSCatchAllPage` (the SDK's catch-all page handler) automatically fetches the active globals for your app and renders:
296
+ - `<CMSHeader>` a sticky top nav bar with your logo, nav links, and CTA buttons
297
+ - `<CMSFooter>` a configurable section grid with optional footer bottom bar
302
298
 
303
- return (
304
- <header className="border-b">
305
- <nav className="max-w-5xl mx-auto px-6 py-4 flex gap-6">
306
- {navItems.map(item => (
307
- <Link key={item.id} href={item.href} target={item.openInNewTab ? '_blank' : undefined}>
308
- {item.label}
309
- </Link>
310
- ))}
311
- </nav>
312
- </header>
313
- )
314
- }
315
- ```
299
+ **You do not need to create `components/layout/header.tsx` or `components/layout/footer.tsx`.**
300
+
301
+ ### Configuring navigation in the CMS
302
+
303
+ Go to **CMS Admin Globals → Navigation** and add items:
304
+
305
+ | Item type | Rendered as | Options |
306
+ |---|---|---|
307
+ | `link` | Nav link in the centre area | Label, URL, open in new tab |
308
+ | `button` | CTA button, right-aligned | Label, URL, variant: `primary` / `outline` / `ghost` |
309
+ | `dropdown` | Link with children | Label, URL, child links |
310
+
311
+ ### Configuring footer in the CMS
312
+
313
+ Go to **CMS Admin → Globals → Footer** and add sections in any order:
314
+
315
+ | Section type | Description |
316
+ |---|---|
317
+ | `brand` | Logo image and tagline |
318
+ | `links` | A column of links with a custom title — add as many as you need |
319
+ | `contact` | Email, phone, address with a custom section title |
320
+ | `social` | Facebook, X, Instagram, LinkedIn, YouTube icons |
321
+
322
+ Optionally enable **Footer Bottom** (a full-width legal bar):
323
+ - Copyright text
324
+ - Legal links (Privacy Policy, Terms, etc.)
325
+
326
+ ### Links: internal vs external
327
+
328
+ When adding links inside footer sections, the link input auto-searches your published CMS pages as you type. Select a page to create an internal link, or type a full URL (`https://...`) for an external link. External links open in a new tab automatically.
329
+
330
+ ### Custom layout: accessing globals manually
331
+
332
+ If you are building a custom page outside `CMSCatchAllPage`, you can access globals directly:
316
333
 
317
334
  ```tsx
318
- // app/layout.tsx
319
- import { Header } from '@/components/layout/header'
335
+ // Only needed for custom layouts — CMSCatchAllPage handles this automatically
336
+ import { cmsClient } from '@sprintup-cms/sdk'
320
337
 
321
- export default function RootLayout({ children }: { children: React.ReactNode }) {
322
- return (
323
- <html lang="en">
324
- <body>
325
- <Header />
326
- {children}
327
- </body>
328
- </html>
329
- )
330
- }
338
+ const globals = await cmsClient.getGlobals()
339
+ const nav = globals?.nav // CMSPage with sectionData.items[]
340
+ const footer = globals?.footer // CMSPage with sectionData.sections[]
331
341
  ```
332
342
 
343
+ > **Deprecated:** `cmsClient.getSiteStructure()` and `menus.header` / `menus.footer` are the old approach and should not be used for navigation or footer.
344
+
333
345
  ---
334
346
 
335
347
  ## Extending with custom blocks
@@ -370,9 +382,10 @@ The `custom` prop is a `Record<blockType, (block: CMSBlock) => React.ReactNode>`
370
382
  | Individual page | 60 seconds | `cms-page-{slug}` |
371
383
  | All pages list | 60 seconds | `cms-pages-{appId}` |
372
384
  | Page type schema | 3600 seconds | `cms-page-type-{id}` |
373
- | Site structure (nav/footer) | 300 seconds | `site-structure-{appId}` |
385
+ | Globals (nav + footer) | 60 seconds | `cms-globals-{appId}` |
374
386
  | Sitemap | 3600 seconds | — |
375
387
  | Status check | No cache | — |
388
+ | ~~Site structure~~ | ~~300 seconds~~ | Deprecated — use Globals |
376
389
 
377
390
  The revalidation webhook calls `revalidateTag('cms-page-{slug}')` and `revalidatePath('/{slug}')` when the CMS publishes a page, giving you **instant updates** without a full rebuild.
378
391
 
@@ -390,11 +403,12 @@ The revalidation webhook calls `revalidateTag('cms-page-{slug}')` and `revalidat
390
403
  | `getEvents()` | Shorthand for `getPages({ type: 'event-page' })` |
391
404
  | `getAnnouncements()` | Shorthand for `getPages({ type: 'announcement-page' })` |
392
405
  | `getPageType(id)` | Fetch a page type schema by ID |
393
- | `getSiteStructure()` | Fetch nav, footer, and page tree |
406
+ | `getGlobals()` | Fetch navigation and footer globals — returns `{ nav, footer }`. CMSCatchAllPage calls this automatically. |
394
407
  | `getPreviewPage(token)` | Fetch a draft page for preview mode |
395
408
  | `getPageWithPreview(slug, token?)` | Fetch page — preview if token present, live otherwise |
396
409
  | `getSitemap()` | Fetch all published slugs with sitemap metadata |
397
410
  | `getStatus()` | Connectivity check — returns counts and page list |
411
+ | ~~`getSiteStructure()`~~ | **Deprecated.** Used the old Site Structure editor. Navigation and footer are now CMS Globals — use `getGlobals()` or rely on CMSCatchAllPage. |
398
412
 
399
413
  ---
400
414
 
@@ -439,6 +453,13 @@ The revalidation webhook calls `revalidateTag('cms-page-{slug}')` and `revalidat
439
453
  - `CMS_WEBHOOK_SECRET` on both sides must match exactly.
440
454
  - Check your deployment logs for `[sprintup-cms] revalidate error:` messages.
441
455
 
456
+ **Navigation or footer not showing**
457
+
458
+ - Go to **CMS Admin → Globals → Navigation** (or Footer) and check that content is published (status = Published).
459
+ - Ensure at least one item/section is added. An empty globals document renders nothing.
460
+ - Changes are cached for 60 seconds. Hard-refresh or wait a moment after publishing.
461
+ - Do not use `getSiteStructure()` — it does not return globals data.
462
+
442
463
  **Preview mode not working**
443
464
 
444
465
  - The `/api/cms-preview/exit` route must exist.
@@ -1203,60 +1203,97 @@ var SOCIAL_ICONS = [
1203
1203
  { key: "linkedin", label: "LinkedIn", d: "M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-4 0v7h-4v-7a6 6 0 0 1 6-6zM2 9h4v12H2zm2-3a2 2 0 1 1 0-4 2 2 0 0 1 0 4z" },
1204
1204
  { key: "youtube", label: "YouTube", d: "M22.54 6.42a2.78 2.78 0 0 0-1.94-1.96C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 1.96A29 29 0 0 0 1 12a29 29 0 0 0 .46 5.58 2.78 2.78 0 0 0 1.94 1.96C5.12 20 12 20 12 20s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-1.96A29 29 0 0 0 23 12a29 29 0 0 0-.46-5.58zM9.75 15.02V8.98L15.5 12l-5.75 3.02z" }
1205
1205
  ];
1206
+ function renderFooterSection(sec, mutedLink) {
1207
+ if (sec.type === "brand") {
1208
+ if (!sec.logo_url && !sec.tagline) return null;
1209
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "0.75rem" }, children: [
1210
+ sec.logo_url && /* @__PURE__ */ jsxRuntime.jsx("img", { src: sec.logo_url, alt: "Logo", style: { height: "32px", width: "auto", objectFit: "contain" } }),
1211
+ sec.tagline && /* @__PURE__ */ jsxRuntime.jsx("p", { style: { fontSize: "0.875rem", color: "var(--muted-foreground, #6b7280)", lineHeight: 1.6, margin: 0 }, children: sec.tagline })
1212
+ ] });
1213
+ }
1214
+ if (sec.type === "links") {
1215
+ const links = (sec.links || []).filter((l) => l.label);
1216
+ if (!links.length && !sec.title) return null;
1217
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
1218
+ sec.title && /* @__PURE__ */ jsxRuntime.jsx("h4", { style: { fontWeight: 600, fontSize: "0.875rem", margin: "0 0 0.875rem", color: "var(--foreground, #111)" }, children: sec.title }),
1219
+ /* @__PURE__ */ jsxRuntime.jsx("ul", { style: { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: "0.5rem" }, children: links.map((link) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: /* @__PURE__ */ jsxRuntime.jsx(
1220
+ "a",
1221
+ {
1222
+ href: link.url || "#",
1223
+ target: link.external ? "_blank" : void 0,
1224
+ rel: link.external ? "noopener noreferrer" : void 0,
1225
+ style: mutedLink,
1226
+ children: link.label
1227
+ }
1228
+ ) }, link.id || link.label)) })
1229
+ ] });
1230
+ }
1231
+ if (sec.type === "contact") {
1232
+ if (!sec.email && !sec.phone && !sec.address) return null;
1233
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
1234
+ sec.title && /* @__PURE__ */ jsxRuntime.jsx("h4", { style: { fontWeight: 600, fontSize: "0.875rem", margin: "0 0 0.875rem", color: "var(--foreground, #111)" }, children: sec.title }),
1235
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "0.4rem" }, children: [
1236
+ sec.email && /* @__PURE__ */ jsxRuntime.jsx("a", { href: `mailto:${sec.email}`, style: mutedLink, children: sec.email }),
1237
+ sec.phone && /* @__PURE__ */ jsxRuntime.jsx("a", { href: `tel:${sec.phone}`, style: mutedLink, children: sec.phone }),
1238
+ sec.address && /* @__PURE__ */ jsxRuntime.jsx("p", { style: { ...mutedLink, whiteSpace: "pre-line", margin: 0 }, children: sec.address })
1239
+ ] })
1240
+ ] });
1241
+ }
1242
+ if (sec.type === "social") {
1243
+ const active = SOCIAL_ICONS.filter((s) => sec[s.key]?.trim());
1244
+ if (!active.length) return null;
1245
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
1246
+ sec.title && /* @__PURE__ */ jsxRuntime.jsx("h4", { style: { fontWeight: 600, fontSize: "0.875rem", margin: "0 0 0.875rem", color: "var(--foreground, #111)" }, children: sec.title }),
1247
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: "0.625rem" }, children: active.map((s) => /* @__PURE__ */ jsxRuntime.jsx(
1248
+ "a",
1249
+ {
1250
+ href: sec[s.key],
1251
+ target: "_blank",
1252
+ rel: "noopener noreferrer",
1253
+ "aria-label": s.label,
1254
+ style: { display: "flex", alignItems: "center", justifyContent: "center", width: "34px", height: "34px", borderRadius: "8px", border: "1px solid var(--border, #e5e7eb)", color: "var(--muted-foreground, #6b7280)", textDecoration: "none" },
1255
+ children: /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "15", height: "15", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: s.d }) })
1256
+ },
1257
+ s.key
1258
+ )) })
1259
+ ] });
1260
+ }
1261
+ return null;
1262
+ }
1206
1263
  function CMSFooter({ footer }) {
1207
1264
  const sd = getSD(footer);
1208
1265
  if (!sd) return null;
1209
- const brand = sd.brand || {};
1210
- const columns = Array.isArray(sd.columns) ? sd.columns : [];
1211
- const social = sd.social || {};
1212
- const contact = sd.contact || {};
1213
- const legal = sd.legal || {};
1214
- const hasBrand = brand.logo_url || brand.tagline;
1215
- const hasContact = contact.email || contact.phone || contact.address;
1216
- const hasSocial = SOCIAL_ICONS.some((s) => social[s.key]?.trim());
1217
- const hasBottom = legal.copyright || Array.isArray(legal.links) && legal.links.length > 0;
1218
- const hasContent = hasBrand || columns.length > 0 || hasContact;
1219
- if (!hasContent && !hasSocial && !hasBottom) return null;
1220
- const totalCols = [hasBrand, ...columns.map(() => true), hasContact].filter(Boolean).length;
1221
- const gridCols = totalCols <= 1 ? "1fr" : totalCols === 2 ? "repeat(2,1fr)" : totalCols === 3 ? "repeat(3,1fr)" : "repeat(4,1fr)";
1266
+ const sections = Array.isArray(sd.sections) ? sd.sections : [];
1267
+ let renderSections = sections;
1268
+ if (renderSections.length === 0 && (sd.brand || sd.columns || sd.contact || sd.social || sd.legal)) {
1269
+ renderSections = [];
1270
+ if (sd.brand?.logo_url || sd.brand?.tagline) renderSections.push({ id: "b", type: "brand", ...sd.brand });
1271
+ (sd.columns || []).forEach((col) => renderSections.push({ id: col.id || col.title, type: "links", title: col.title, links: col.links }));
1272
+ if (sd.contact?.email || sd.contact?.phone) renderSections.push({ id: "c", type: "contact", ...sd.contact });
1273
+ if (Object.values(sd.social || {}).some(Boolean)) renderSections.push({ id: "s", type: "social", ...sd.social });
1274
+ }
1275
+ const legalSec = sd.sections ? sd.sections.find((s) => s.type === "legal") : sd.legal;
1276
+ const mainSections = renderSections.filter((s) => s.type !== "legal");
1277
+ if (mainSections.length === 0 && !legalSec) return null;
1278
+ const gridCount = mainSections.length;
1279
+ const gridCols = gridCount <= 1 ? "1fr" : gridCount === 2 ? "repeat(2,1fr)" : gridCount === 3 ? "repeat(3,1fr)" : "repeat(4,1fr)";
1222
1280
  const mutedLink = { fontSize: "0.875rem", color: "var(--muted-foreground, #6b7280)", textDecoration: "none" };
1281
+ const copyright = legalSec?.copyright || "";
1282
+ const legalLinks = legalSec?.legal_links || legalSec?.links || [];
1283
+ const hasBottom = copyright || legalLinks.filter((l) => l.label).length > 0;
1223
1284
  return /* @__PURE__ */ jsxRuntime.jsx("footer", { style: { borderTop: "1px solid var(--border, #e5e7eb)", background: "var(--muted, #f9fafb)", marginTop: "4rem" }, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { maxWidth: "1200px", margin: "0 auto", padding: "3rem 1.5rem 1.5rem" }, children: [
1224
- hasContent && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "grid", gridTemplateColumns: gridCols, gap: "2.5rem", marginBottom: hasSocial || hasBottom ? "2.5rem" : 0 }, children: [
1225
- hasBrand && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "0.75rem" }, children: [
1226
- brand.logo_url && /* @__PURE__ */ jsxRuntime.jsx("img", { src: brand.logo_url, alt: "Logo", style: { height: "32px", width: "auto", objectFit: "contain" } }),
1227
- brand.tagline && /* @__PURE__ */ jsxRuntime.jsx("p", { style: { fontSize: "0.875rem", color: "var(--muted-foreground, #6b7280)", lineHeight: 1.6, margin: 0 }, children: brand.tagline })
1228
- ] }),
1229
- columns.map((col) => /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
1230
- col.title && /* @__PURE__ */ jsxRuntime.jsx("h4", { style: { fontWeight: 600, fontSize: "0.875rem", margin: "0 0 0.875rem", color: "var(--foreground, #111)" }, children: col.title }),
1231
- /* @__PURE__ */ jsxRuntime.jsx("ul", { style: { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: "0.5rem" }, children: (col.links || []).filter((l) => l.label).map((link) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: /* @__PURE__ */ jsxRuntime.jsx("a", { href: link.url || "#", style: mutedLink, children: link.label }) }, link.id || link.label)) })
1232
- ] }, col.id || col.title)),
1233
- hasContact && /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
1234
- /* @__PURE__ */ jsxRuntime.jsx("h4", { style: { fontWeight: 600, fontSize: "0.875rem", margin: "0 0 0.875rem", color: "var(--foreground, #111)" }, children: "Contact" }),
1235
- /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "0.4rem" }, children: [
1236
- contact.email && /* @__PURE__ */ jsxRuntime.jsx("a", { href: `mailto:${contact.email}`, style: mutedLink, children: contact.email }),
1237
- contact.phone && /* @__PURE__ */ jsxRuntime.jsx("a", { href: `tel:${contact.phone}`, style: mutedLink, children: contact.phone }),
1238
- contact.address && /* @__PURE__ */ jsxRuntime.jsx("p", { style: { ...mutedLink, whiteSpace: "pre-line", margin: 0 }, children: contact.address })
1239
- ] })
1240
- ] })
1241
- ] }),
1242
- hasSocial && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", gap: "0.75rem", marginBottom: hasBottom ? "1.5rem" : 0 }, children: SOCIAL_ICONS.filter((s) => social[s.key]?.trim()).map((s) => /* @__PURE__ */ jsxRuntime.jsx(
1243
- "a",
1244
- {
1245
- href: social[s.key],
1246
- target: "_blank",
1247
- rel: "noopener noreferrer",
1248
- "aria-label": s.label,
1249
- style: { display: "flex", alignItems: "center", justifyContent: "center", width: "36px", height: "36px", borderRadius: "8px", border: "1px solid var(--border, #e5e7eb)", color: "var(--muted-foreground, #6b7280)", textDecoration: "none", flexShrink: 0 },
1250
- children: /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: s.d }) })
1251
- },
1252
- s.key
1253
- )) }),
1285
+ mainSections.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "grid", gridTemplateColumns: gridCols, gap: "2.5rem", marginBottom: hasBottom ? "2.5rem" : 0 }, children: mainSections.map((sec) => {
1286
+ const rendered = renderFooterSection(sec, mutedLink);
1287
+ return rendered ? /* @__PURE__ */ jsxRuntime.jsx("div", { children: rendered }, sec.id) : null;
1288
+ }) }),
1254
1289
  hasBottom && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { borderTop: "1px solid var(--border, #e5e7eb)", paddingTop: "1.25rem", display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "space-between", gap: "1rem" }, children: [
1255
- legal.copyright && /* @__PURE__ */ jsxRuntime.jsx("p", { style: { fontSize: "0.8rem", color: "var(--muted-foreground, #6b7280)", margin: 0 }, children: legal.copyright }),
1256
- Array.isArray(legal.links) && legal.links.filter((l) => l.label).length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: "1.25rem" }, children: legal.links.filter((l) => l.label).map((link) => /* @__PURE__ */ jsxRuntime.jsx(
1290
+ copyright && /* @__PURE__ */ jsxRuntime.jsx("p", { style: { fontSize: "0.8rem", color: "var(--muted-foreground, #6b7280)", margin: 0 }, children: copyright }),
1291
+ legalLinks.filter((l) => l.label).length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: "1.25rem" }, children: legalLinks.filter((l) => l.label).map((link) => /* @__PURE__ */ jsxRuntime.jsx(
1257
1292
  "a",
1258
1293
  {
1259
1294
  href: link.url || "#",
1295
+ target: link.external ? "_blank" : void 0,
1296
+ rel: link.external ? "noopener noreferrer" : void 0,
1260
1297
  style: { fontSize: "0.8rem", color: "var(--muted-foreground, #6b7280)", textDecoration: "none" },
1261
1298
  children: link.label
1262
1299
  },