@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.0

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.
Files changed (88) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +23 -1
  3. package/docs/GOOGLE-SHEETS.md +145 -0
  4. package/docs/LAYOUT-BLOCKS.md +125 -0
  5. package/docs/MEDIA-FOLDERS.md +90 -0
  6. package/docs/NEW-SITE.md +35 -0
  7. package/docs/PAGES.md +452 -0
  8. package/docs/SCHEDULING.md +130 -0
  9. package/docs/TYPOGRAPHY.md +86 -0
  10. package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
  11. package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
  12. package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
  13. package/package.json +5 -1
  14. package/src/backoffice/DomainsView.tsx +152 -25
  15. package/src/backoffice/FormEditorView.tsx +10 -0
  16. package/src/backoffice/FormSettingsView.tsx +8 -0
  17. package/src/backoffice/MediaLibraryView.tsx +239 -21
  18. package/src/backoffice/NavigationView.tsx +43 -0
  19. package/src/backoffice/PageBuilderView.tsx +193 -36
  20. package/src/backoffice/PageEditorView.tsx +293 -57
  21. package/src/backoffice/PagesListView.tsx +294 -19
  22. package/src/backoffice/ScheduleFields.tsx +151 -0
  23. package/src/backoffice/SectionFormView.tsx +58 -0
  24. package/src/backoffice/StatusBadge.tsx +174 -0
  25. package/src/backoffice/TrackingView.tsx +247 -208
  26. package/src/backoffice/TypographyPanel.tsx +308 -0
  27. package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
  28. package/src/backoffice/inspectors/blocks.tsx +149 -0
  29. package/src/backoffice/inspectors/fields.tsx +81 -0
  30. package/src/backoffice/inspectors/index.tsx +3 -0
  31. package/src/backoffice/media/ConfirmDialog.tsx +64 -4
  32. package/src/backoffice/routes/host.ts +31 -0
  33. package/src/backoffice/routes/index.tsx +140 -15
  34. package/src/backoffice/routes/media-forms.tsx +5 -0
  35. package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
  36. package/src/backoffice/seo/page-seo-form.ts +11 -1
  37. package/src/backoffice/shell/domain-guard.ts +27 -14
  38. package/src/backoffice/shell/index.ts +5 -0
  39. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  40. package/src/backoffice/tracking-drafts.ts +150 -0
  41. package/src/blocks/block-children.tsx +40 -0
  42. package/src/blocks/block-tree.ts +378 -0
  43. package/src/blocks/components/IconGridBlock.tsx +76 -51
  44. package/src/blocks/components/LayoutBlock.tsx +100 -0
  45. package/src/blocks/countdown-time.ts +17 -87
  46. package/src/blocks/registry.tsx +21 -0
  47. package/src/blocks/types.ts +40 -0
  48. package/src/cms/page-publication.ts +377 -0
  49. package/src/cms/page-status.ts +152 -0
  50. package/src/cms/timezone.ts +181 -0
  51. package/src/cms/use-publication-boundary.ts +84 -0
  52. package/src/cms/use-schedule-clock.ts +43 -0
  53. package/src/cms/visibility-schedule.ts +267 -0
  54. package/src/config/modules.ts +34 -10
  55. package/src/config/site-config.ts +32 -0
  56. package/src/config/typography.ts +143 -0
  57. package/src/http/cache-policy.ts +49 -2
  58. package/src/media/folders.ts +183 -0
  59. package/src/media/use-media-upload.ts +21 -4
  60. package/src/navigation/index.ts +34 -2
  61. package/src/platform/contract.ts +52 -1
  62. package/src/runtime/server-entry.ts +8 -2
  63. package/src/server/content-sections-core.ts +16 -0
  64. package/src/server/domains-core.ts +124 -107
  65. package/src/server/forms-delivery-core.ts +61 -10
  66. package/src/server/forms-submissions-core.ts +47 -0
  67. package/src/server/google-service-account.ts +325 -0
  68. package/src/server/google-sheets-core.ts +463 -0
  69. package/src/server/http/sitemap.ts +32 -8
  70. package/src/server/injections-core.ts +230 -49
  71. package/src/server/media-library-core.ts +27 -6
  72. package/src/server/pages-core.ts +680 -184
  73. package/src/server/published-cache.ts +25 -4
  74. package/src/server/resolve-globals.ts +6 -1
  75. package/src/server/schema-drift.ts +4 -0
  76. package/src/server/schemas.ts +15 -0
  77. package/src/site/PageRenderer.tsx +45 -12
  78. package/src/site/SiteFooter.tsx +44 -3
  79. package/src/site/SiteHeader.tsx +23 -2
  80. package/src/site/google-fonts.ts +191 -0
  81. package/src/site/host.ts +172 -0
  82. package/src/site/locale-domains.ts +500 -0
  83. package/src/site/routes/public-page.tsx +12 -1
  84. package/src/site/routes.ts +206 -38
  85. package/src/site/site-url.ts +80 -17
  86. package/src/site/typography.tsx +75 -0
  87. package/src/tracking/injections.ts +52 -9
  88. package/src/ui/MobileCarousel.tsx +289 -0
@@ -1,13 +1,59 @@
1
1
  import { useState, type ReactNode } from "react";
2
2
  import { toast } from "sonner";
3
- import { Plus, Trash2, Pencil, Globe as GlobeIcon, ExternalLink, Undo2 } from "lucide-react";
3
+ import {
4
+ Plus,
5
+ Trash2,
6
+ Pencil,
7
+ Globe as GlobeIcon,
8
+ ExternalLink,
9
+ Undo2,
10
+ Copy,
11
+ Eye,
12
+ EyeOff,
13
+ } from "lucide-react";
14
+ import { ConfirmDialog } from "./media/ConfirmDialog";
15
+ import { PageStatusBadge, PublicationBadge } from "./StatusBadge";
16
+ import {
17
+ duplicateNameFor,
18
+ isValidPageKey,
19
+ normalizePageStatus,
20
+ slugifyPageKey,
21
+ statusChangeConfirmation,
22
+ uniquePageKey,
23
+ type PageStatus,
24
+ } from "../cms/page-status";
25
+ import {
26
+ isPubliclyVisible,
27
+ type PublicationState,
28
+ type PublicationTransitionKind,
29
+ } from "../cms/page-publication";
30
+ import { usePublicationBoundaryRefresh } from "../cms/use-publication-boundary";
4
31
 
5
32
  /** A page row as listed by the host. */
6
33
  export type PageListRow = {
7
34
  id: string;
8
35
  key: string;
9
36
  name: string;
37
+ /**
38
+ * The editorial status an editor last set. Shown, but never used to decide
39
+ * what the screen says about visitors or which action to offer — see
40
+ * `publication` for that.
41
+ */
10
42
  status: string;
43
+ /**
44
+ * What a visitor actually gets, derived from the page's active publication
45
+ * rows by `listPagesCore`. A draft inside an open window is public; a
46
+ * published page past its end date is not.
47
+ */
48
+ publication: PublicationState;
49
+ /** When `publication` changes next, ISO-8601, or null when nothing is due. */
50
+ publication_at?: string | null;
51
+ /**
52
+ * What happens then: a first publish, one version replacing another, or the
53
+ * page going away. A live page with an update queued behind it is still live,
54
+ * and a list that shows it as merely "Scheduled" is wrong about today.
55
+ */
56
+ publication_transition?: PublicationTransitionKind | null;
11
57
  updated_at: string;
12
58
  is_system?: boolean | null;
13
59
  };
@@ -35,6 +81,17 @@ export type PagesListDeps = {
35
81
  hrefFor: (key: string) => string;
36
82
  createPage: (input: { key: string; name: string }) => Promise<unknown>;
37
83
  renamePage: (input: { pageId: string; name: string }) => Promise<unknown>;
84
+ /**
85
+ * Publish or unpublish, always behind a confirmation. Optional: a host that
86
+ * has not wired it up simply does not show the action.
87
+ */
88
+ setPageStatus?: (input: { pageId: string; status: PageStatus }) => Promise<unknown>;
89
+ /** Copy a page into a new draft. Optional for the same reason. */
90
+ duplicatePage?: (input: {
91
+ pageId: string;
92
+ name: string;
93
+ key?: string;
94
+ }) => Promise<{ id: string; key: string; name: string }>;
38
95
  deletePage: (pageId: string) => Promise<unknown>;
39
96
  restorePage: (pageId: string) => Promise<unknown>;
40
97
  purgePage: (pageId: string) => Promise<unknown>;
@@ -56,6 +113,8 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
56
113
  hrefFor,
57
114
  createPage,
58
115
  renamePage,
116
+ setPageStatus,
117
+ duplicatePage,
59
118
  deletePage,
60
119
  restorePage,
61
120
  purgePage,
@@ -69,9 +128,101 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
69
128
  const [newName, setNewName] = useState("");
70
129
  const [busy, setBusy] = useState(false);
71
130
  const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null);
131
+ const [statusChange, setStatusChange] = useState<{
132
+ page: PageListRow;
133
+ next: PageStatus;
134
+ } | null>(null);
135
+ const [duplicating, setDuplicating] = useState<{
136
+ page: PageListRow;
137
+ name: string;
138
+ key: string;
139
+ keyTouched: boolean;
140
+ } | null>(null);
72
141
 
73
142
  const fail = (e: unknown) => toast.error(e instanceof Error ? e.message : "Failed");
74
143
 
144
+ /**
145
+ * Re-read the list when the soonest scheduled transition falls due.
146
+ *
147
+ * This screen is a dashboard; it is left open. One timer for the earliest
148
+ * boundary is enough, because reaching it refetches every row and the next
149
+ * earliest becomes the new deadline. The waiting itself — including a
150
+ * boundary whose fresh data has not arrived yet — belongs to the hook.
151
+ */
152
+ const nextBoundary = (pages ?? []).reduce<number | null>((soonest, page) => {
153
+ const at = page.publication_at ? Date.parse(page.publication_at) : NaN;
154
+ if (Number.isNaN(at)) return soonest;
155
+ return soonest === null || at < soonest ? at : soonest;
156
+ }, null);
157
+ usePublicationBoundaryRefresh(nextBoundary, refetchPages);
158
+
159
+ const takenKeys = (pages ?? []).map((p) => p.key);
160
+ const duplicateKey = duplicating
161
+ ? duplicating.keyTouched
162
+ ? slugifyPageKey(duplicating.key)
163
+ : uniquePageKey(duplicating.name || duplicating.page.name, takenKeys)
164
+ : "";
165
+ /*
166
+ * The two fields are validated separately so the message can be attached to
167
+ * the field it is actually about: a screen reader hears the problem on the
168
+ * control that has to change, not on whichever one comes first in the DOM.
169
+ */
170
+ const duplicateNameProblem =
171
+ duplicating && !duplicating.name.trim() ? "A name is required." : null;
172
+ const duplicateKeyProblem =
173
+ !duplicating || duplicateNameProblem
174
+ ? null
175
+ : !duplicateKey || !isValidPageKey(duplicateKey)
176
+ ? "The address may only contain lowercase letters, numbers, dashes and underscores."
177
+ : takenKeys.includes(duplicateKey)
178
+ ? `Another page already uses the address "${duplicateKey}".`
179
+ : null;
180
+ const duplicateProblem = duplicateNameProblem ?? duplicateKeyProblem;
181
+
182
+ /**
183
+ * Publishing and unpublishing are never one click: both directions open the
184
+ * dialog below and are only applied once it is confirmed.
185
+ */
186
+ const onConfirmStatus = async () => {
187
+ if (!statusChange || !setPageStatus) return;
188
+ setBusy(true);
189
+ try {
190
+ await setPageStatus({ pageId: statusChange.page.id, status: statusChange.next });
191
+ toast.success(
192
+ statusChange.next === "published"
193
+ ? `"${statusChange.page.name}" is now visible on the public website`
194
+ : `"${statusChange.page.name}" is no longer visible on the public website`,
195
+ );
196
+ setStatusChange(null);
197
+ refetchPages();
198
+ } catch (e) {
199
+ fail(e);
200
+ } finally {
201
+ setBusy(false);
202
+ }
203
+ };
204
+
205
+ const onConfirmDuplicate = async () => {
206
+ if (!duplicating || !duplicatePage || duplicateProblem) return;
207
+ setBusy(true);
208
+ try {
209
+ const copy = await duplicatePage({
210
+ pageId: duplicating.page.id,
211
+ name: duplicating.name.trim(),
212
+ key: duplicateKey,
213
+ });
214
+ setDuplicating(null);
215
+ refetchPages();
216
+ toast.success(`Copied as the draft "${copy?.name ?? duplicating.name}"`, {
217
+ action: { label: "Edit copy", onClick: () => openEditor(copy?.key ?? duplicateKey) },
218
+ });
219
+ } catch (e) {
220
+ fail(e);
221
+ } finally {
222
+ setBusy(false);
223
+ }
224
+ };
225
+
75
226
  const onRename = async () => {
76
227
  if (!renaming || !renaming.name.trim()) return;
77
228
  try {
@@ -166,8 +317,10 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
166
317
  </div>
167
318
 
168
319
  <p className="mt-4 max-w-2xl text-sm text-ink/60">
169
- Compose each page from blocks and global blocks. Every save creates a new version — publish
170
- when ready.
320
+ Compose each page from blocks and global blocks. Every save creates a new version. The
321
+ status column is what a visitor gets right now: a page can also be scheduled to appear or
322
+ disappear on its own, so it does not always match the editorial status underneath. Anything
323
+ that is not public stays editable and previewable by signed-in staff.
171
324
  </p>
172
325
 
173
326
  <div className="mt-8 border border-ink/10 bg-white">
@@ -177,9 +330,9 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
177
330
  <th className="px-4 py-3">Name</th>
178
331
  <th className="px-4 py-3">Key</th>
179
332
  <th className="px-4 py-3">URL</th>
180
- <th className="px-4 py-3">Status</th>
333
+ <th className="px-4 py-3">Public status</th>
181
334
  <th className="px-4 py-3">Updated</th>
182
- <th className="px-4 py-3 w-40"></th>
335
+ <th className="px-4 py-3 w-64 text-right">Actions</th>
183
336
  </tr>
184
337
  </thead>
185
338
  <tbody>
@@ -236,23 +389,66 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
236
389
  <ExternalLink className="size-3 text-ink/40" />
237
390
  </a>
238
391
  </td>
239
- <td className="px-4 py-3 text-ink/70">{p.status}</td>
392
+ <td className="px-4 py-3">
393
+ <div className="flex flex-col items-start gap-1">
394
+ <PublicationBadge
395
+ state={p.publication}
396
+ at={p.publication_at}
397
+ transition={p.publication_transition}
398
+ />
399
+ <PageStatusBadge status={normalizePageStatus(p.status)} />
400
+ </div>
401
+ </td>
240
402
  <td className="px-4 py-3 text-ink/50">{new Date(p.updated_at).toLocaleString()}</td>
241
- <td className="px-4 py-3 text-right">
242
- <button
243
- onClick={() => openEditor(p.key)}
244
- className="inline-flex items-center gap-1 text-sea hover:underline mr-3"
245
- >
246
- <Pencil className="size-3.5" /> Edit
247
- </button>
248
- {!p.is_system && (
403
+ <td className="px-4 py-3">
404
+ <div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1">
249
405
  <button
250
- onClick={() => onDelete(p.id, p.name)}
251
- className="inline-flex items-center gap-1 text-red-600 hover:underline"
406
+ onClick={() => openEditor(p.key)}
407
+ className="inline-flex items-center gap-1 text-sea hover:underline"
252
408
  >
253
- <Trash2 className="size-3.5" /> Delete
409
+ <Pencil className="size-3.5" /> Edit
254
410
  </button>
255
- )}
411
+ {setPageStatus ? (
412
+ isPubliclyVisible(p.publication) ? (
413
+ <button
414
+ onClick={() => setStatusChange({ page: p, next: "draft" })}
415
+ className="inline-flex items-center gap-1 text-ink/60 hover:text-ink hover:underline"
416
+ >
417
+ <EyeOff className="size-3.5" /> Unpublish
418
+ </button>
419
+ ) : (
420
+ <button
421
+ onClick={() => setStatusChange({ page: p, next: "published" })}
422
+ className="inline-flex items-center gap-1 text-ink/60 hover:text-ink hover:underline"
423
+ >
424
+ <Eye className="size-3.5" /> Publish
425
+ </button>
426
+ )
427
+ ) : null}
428
+ {duplicatePage ? (
429
+ <button
430
+ onClick={() =>
431
+ setDuplicating({
432
+ page: p,
433
+ name: duplicateNameFor(p.name),
434
+ key: "",
435
+ keyTouched: false,
436
+ })
437
+ }
438
+ className="inline-flex items-center gap-1 text-ink/60 hover:text-ink hover:underline"
439
+ >
440
+ <Copy className="size-3.5" /> Duplicate
441
+ </button>
442
+ ) : null}
443
+ {!p.is_system && (
444
+ <button
445
+ onClick={() => onDelete(p.id, p.name)}
446
+ className="inline-flex items-center gap-1 text-red-600 hover:underline"
447
+ >
448
+ <Trash2 className="size-3.5" /> Delete
449
+ </button>
450
+ )}
451
+ </div>
256
452
  </td>
257
453
  </tr>
258
454
  ))}
@@ -333,7 +529,10 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
333
529
 
334
530
  <div className="mt-10 border border-ink/10 bg-white p-6">
335
531
  <div className="font-serif text-lg text-ink">New page</div>
336
- <p className="text-sm text-ink/60 mt-1">Create a blank page — you can add blocks after.</p>
532
+ <p className="text-sm text-ink/60 mt-1">
533
+ Create a blank page — you can add blocks after. A new page starts as a draft and is not
534
+ visible on the public website until you publish it.
535
+ </p>
337
536
  <div className="mt-4 grid gap-3 md:grid-cols-[1fr_1fr_auto]">
338
537
  <label className="block">
339
538
  <span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">Name</span>
@@ -364,6 +563,82 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
364
563
  </button>
365
564
  </div>
366
565
  </div>
566
+
567
+ {statusChange ? (
568
+ <ConfirmDialog
569
+ title={statusChange.next === "published" ? "Publish page" : "Unpublish page"}
570
+ description={statusChangeConfirmation(
571
+ statusChange.next,
572
+ statusChange.page.publication,
573
+ statusChange.page.publication_transition,
574
+ )}
575
+ details={[`${statusChange.page.name} — ${pathFor(statusChange.page.key)}`]}
576
+ confirmLabel={statusChange.next === "published" ? "Publish" : "Unpublish"}
577
+ danger={statusChange.next !== "published"}
578
+ busy={busy}
579
+ onConfirm={onConfirmStatus}
580
+ onCancel={() => setStatusChange(null)}
581
+ />
582
+ ) : null}
583
+
584
+ {duplicating ? (
585
+ <ConfirmDialog
586
+ title="Duplicate page"
587
+ description={`A copy of "${duplicating.page.name}" is created as a new draft. The original is not changed.`}
588
+ confirmLabel="Create copy"
589
+ busy={busy}
590
+ onConfirm={onConfirmDuplicate}
591
+ onCancel={() => setDuplicating(null)}
592
+ >
593
+ <div className="space-y-3">
594
+ <label className="block">
595
+ <span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">
596
+ Name of the copy
597
+ </span>
598
+ <input
599
+ autoFocus
600
+ value={duplicating.name}
601
+ onChange={(e) => setDuplicating({ ...duplicating, name: e.target.value })}
602
+ aria-invalid={duplicateNameProblem ? true : undefined}
603
+ aria-describedby={duplicateNameProblem ? "duplicate-page-problem" : undefined}
604
+ className="w-full border border-ink/20 px-3 py-2 text-sm"
605
+ />
606
+ </label>
607
+ <div>
608
+ <label className="block">
609
+ <span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">
610
+ Address
611
+ </span>
612
+ <input
613
+ value={duplicateKey}
614
+ onChange={(e) =>
615
+ setDuplicating({ ...duplicating, key: e.target.value, keyTouched: true })
616
+ }
617
+ aria-invalid={duplicateKeyProblem ? true : undefined}
618
+ aria-describedby={`duplicate-page-hint${
619
+ duplicateKeyProblem ? " duplicate-page-problem" : ""
620
+ }`}
621
+ className="w-full border border-ink/20 px-3 py-2 text-sm font-mono"
622
+ />
623
+ </label>
624
+ {/* Outside the label: inside it, the hint would be read out as
625
+ part of the field's name every time the field is reached. */}
626
+ <span id="duplicate-page-hint" className="mt-1 block text-[0.7rem] text-ink/50">
627
+ Suggested from the name, and made unique automatically.
628
+ </span>
629
+ </div>
630
+ {duplicateProblem ? (
631
+ <p id="duplicate-page-problem" className="text-xs text-red-600">
632
+ {duplicateProblem}
633
+ </p>
634
+ ) : (
635
+ <p className="text-xs text-ink/50">
636
+ The copy is a draft, so nothing appears on the public website until you publish it.
637
+ </p>
638
+ )}
639
+ </div>
640
+ </ConfirmDialog>
641
+ ) : null}
367
642
  </div>
368
643
  );
369
644
  }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The one editor for scheduled visibility.
3
+ *
4
+ * Menu items, footer columns and footer links all use this control, so the
5
+ * vocabulary, the timezone handling and the validation are identical wherever
6
+ * scheduling appears. Editors type wall-clock time in the site's timezone; what
7
+ * is stored is always a UTC instant, converted here and nowhere else.
8
+ */
9
+
10
+ import { useId, useState } from "react";
11
+ import {
12
+ EMPTY_VISIBILITY_SCHEDULE,
13
+ isoToLocalInput,
14
+ localInputToIso,
15
+ normalizeSchedule,
16
+ scheduleProblems,
17
+ visibilityStateAt,
18
+ type VisibilitySchedule,
19
+ } from "../cms/visibility-schedule";
20
+ import { zoneAbbreviation, zonedLocalExists } from "../cms/timezone";
21
+ import { siteTimezone } from "../config/site-config";
22
+ import { VisibilityBadge } from "./StatusBadge";
23
+
24
+ export type ScheduleFieldProps = {
25
+ value: VisibilitySchedule | null | undefined;
26
+ onChange: (next: VisibilitySchedule) => void;
27
+ /** IANA zone the wall-clock inputs are read in; the site's zone by default. */
28
+ timezone?: string | null;
29
+ /** Evaluation instant; passed in so previews and tests are deterministic. */
30
+ now?: number;
31
+ /** Heading shown above the controls. */
32
+ label?: string;
33
+ };
34
+
35
+ /**
36
+ * Visible/hidden switch plus an optional publish window.
37
+ *
38
+ * Nonsensical ranges are reported as they are typed: the end field is marked
39
+ * invalid and the problem is spelled out under the fields, associated with them
40
+ * through `aria-describedby`. A wall-clock time the site's timezone never
41
+ * reaches — the hour a spring-forward transition skips — is refused outright
42
+ * rather than stored as the shifted instant it would resolve to.
43
+ */
44
+ export function ScheduleField({
45
+ value,
46
+ onChange,
47
+ timezone: timezoneProp,
48
+ now = Date.now(),
49
+ label = "Visibility",
50
+ }: ScheduleFieldProps) {
51
+ const id = useId();
52
+ const timezone = timezoneProp || siteTimezone();
53
+ const schedule = normalizeSchedule(value) ?? EMPTY_VISIBILITY_SCHEDULE;
54
+ const enabled = schedule.enabled !== false;
55
+ const problems = scheduleProblems(schedule);
56
+ const [skipped, setSkipped] = useState<Partial<Record<"publishAt" | "unpublishAt", string>>>({});
57
+ const state = visibilityStateAt(schedule, now);
58
+ const zone = zoneAbbreviation(timezone);
59
+
60
+ const patch = (next: Partial<VisibilitySchedule>) => onChange({ ...schedule, ...next });
61
+
62
+ /*
63
+ * A time in the hour a spring-forward transition skips resolves to a real
64
+ * instant an hour away from what was typed, which would schedule the item at
65
+ * a time nobody chose. Refuse it instead: the field keeps its previous value
66
+ * and says why, so nothing is stored silently.
67
+ */
68
+ const setInstant = (key: "publishAt" | "unpublishAt", local: string) => {
69
+ if (local && !zonedLocalExists(local, timezone)) {
70
+ setSkipped((prev) => ({ ...prev, [key]: local }));
71
+ return;
72
+ }
73
+ setSkipped((prev) => (prev[key] ? { ...prev, [key]: undefined } : prev));
74
+ patch({ [key]: local ? localInputToIso(local, timezone) : null });
75
+ };
76
+
77
+ const skippedProblems = (["publishAt", "unpublishAt"] as const)
78
+ .filter((key) => skipped[key])
79
+ .map(
80
+ (key) =>
81
+ `${key === "publishAt" ? "Start" : "End"} time ${skipped[key]?.replace("T", " ")} does not exist in ${zone} — the clocks jump forward that night. Pick another time.`,
82
+ );
83
+ const allProblems = [...problems, ...skippedProblems];
84
+
85
+ return (
86
+ <fieldset className="border border-ink/10 bg-ice/20 px-3 py-3">
87
+ <legend className="px-1 text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">{label}</legend>
88
+
89
+ <div className="flex flex-wrap items-center gap-3">
90
+ <label className="flex items-center gap-2 text-sm text-ink/75">
91
+ <input
92
+ type="checkbox"
93
+ checked={enabled}
94
+ onChange={(e) => patch({ enabled: e.target.checked })}
95
+ className="size-4 accent-sea"
96
+ />
97
+ Show this item
98
+ </label>
99
+ <VisibilityBadge state={state} />
100
+ </div>
101
+
102
+ <div
103
+ className={[
104
+ "mt-3 grid gap-3 sm:grid-cols-2",
105
+ enabled ? "" : "opacity-50",
106
+ ].join(" ")}
107
+ >
108
+ <label className="block">
109
+ <span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">
110
+ Show from (optional)
111
+ </span>
112
+ <input
113
+ type="datetime-local"
114
+ disabled={!enabled}
115
+ value={isoToLocalInput(schedule.publishAt, timezone)}
116
+ onChange={(e) => setInstant("publishAt", e.target.value)}
117
+ aria-invalid={skipped.publishAt ? true : undefined}
118
+ aria-describedby={`${id}-zone${allProblems.length ? ` ${id}-problems` : ""}`}
119
+ className="w-full border border-ink/20 bg-white px-3 py-2 text-sm disabled:bg-ink/[0.03]"
120
+ />
121
+ </label>
122
+ <label className="block">
123
+ <span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">
124
+ Hide again at (optional)
125
+ </span>
126
+ <input
127
+ type="datetime-local"
128
+ disabled={!enabled}
129
+ value={isoToLocalInput(schedule.unpublishAt, timezone)}
130
+ onChange={(e) => setInstant("unpublishAt", e.target.value)}
131
+ aria-invalid={problems.length || skipped.unpublishAt ? true : undefined}
132
+ aria-describedby={`${id}-zone${allProblems.length ? ` ${id}-problems` : ""}`}
133
+ className="w-full border border-ink/20 bg-white px-3 py-2 text-sm disabled:bg-ink/[0.03]"
134
+ />
135
+ </label>
136
+ </div>
137
+
138
+ <p id={`${id}-zone`} className="mt-2 text-xs text-ink/50">
139
+ Times are in the site&rsquo;s timezone ({zone}). Leave both empty to show the item all the
140
+ time.
141
+ </p>
142
+ {allProblems.length ? (
143
+ <ul id={`${id}-problems`} className="mt-1 space-y-0.5 text-xs text-red-600">
144
+ {allProblems.map((problem) => (
145
+ <li key={problem}>{problem}</li>
146
+ ))}
147
+ </ul>
148
+ ) : null}
149
+ </fieldset>
150
+ );
151
+ }
@@ -37,6 +37,15 @@ import {
37
37
  import { CSS } from "@dnd-kit/utilities";
38
38
 
39
39
  import { MediaPickerDialog } from "./inspectors/fields";
40
+ import { ScheduleField } from "./ScheduleFields";
41
+ import { VisibilityBadge } from "./StatusBadge";
42
+ import {
43
+ isEmptySchedule,
44
+ normalizeSchedule,
45
+ visibilityStateAt,
46
+ type VisibilitySchedule,
47
+ type VisibilityState,
48
+ } from "../cms/visibility-schedule";
40
49
 
41
50
  /**
42
51
  * Generic recursive editor for CMS section values, shared by every site.
@@ -95,6 +104,11 @@ export type SectionFormDeps = {
95
104
  FocusPickerComponent: ComponentType<SectionFormFocusPickerProps>;
96
105
  /** Managed forms offered by the `formKey` picker. */
97
106
  forms: SectionFormManagedForm[];
107
+ /**
108
+ * IANA zone the scheduling fields read wall-clock time in. Defaults to the
109
+ * platform's own default when a host does not pass one.
110
+ */
111
+ timezone?: string;
98
112
  };
99
113
 
100
114
  const DepsContext = createContext<SectionFormDeps | null>(null);
@@ -184,6 +198,8 @@ function elementShape(...lists: (readonly unknown[] | undefined)[]): unknown {
184
198
 
185
199
  /** Keys under which a nested list holds items of the parent's own shape. */
186
200
  const TREE_KEYS = ["children", "items", "links"] as const;
201
+ /** Field holding a {@link VisibilitySchedule}; see `cms/visibility-schedule`. */
202
+ const SCHEDULE_KEY = "schedule";
187
203
 
188
204
  /**
189
205
  * Whether an object is a menu entry rather than a content row.
@@ -257,6 +273,24 @@ function Node({
257
273
  const deps = useDeps();
258
274
  const isLocalizedString = useIsLocalizedString();
259
275
 
276
+ // Scheduled visibility has one editor for the whole platform, so a menu item
277
+ // and a footer link cannot drift apart. Matched before anything else: the
278
+ // generic object field would show raw ISO strings, and the sibling-shape
279
+ // rule below would blank it to `enabled: false`, which is not what an
280
+ // unscheduled item means.
281
+ if (path.endsWith(`.${SCHEDULE_KEY}`) || path === SCHEDULE_KEY) {
282
+ if (value == null || isPlainObject(value)) {
283
+ return (
284
+ <ScheduleField
285
+ value={value as VisibilitySchedule | null}
286
+ onChange={(next) => onChange(next as unknown as AnyVal)}
287
+ timezone={deps.timezone}
288
+ label={fieldLabel ?? "Visibility"}
289
+ />
290
+ );
291
+ }
292
+ }
293
+
260
294
  // A field a particular item is missing, or that was saved as an empty
261
295
  // object, still has to be editable — and has to be edited with the right
262
296
  // control. The sibling example says which one, without writing anything:
@@ -657,6 +691,7 @@ function ArrayField({
657
691
  lastIndex={value.length - 1}
658
692
  open={openIdx === i}
659
693
  summary={itemSummary(item, deps)}
694
+ scheduleState={rowScheduleState(item)}
660
695
  onToggle={() => setOpenIdx(openIdx === i ? null : i)}
661
696
  onMoveUp={() => move(i, -1)}
662
697
  onMoveDown={() => move(i, 1)}
@@ -695,12 +730,24 @@ function ArrayField({
695
730
  );
696
731
  }
697
732
 
733
+ /**
734
+ * The badge state for a collapsed row: only rows that actually carry a
735
+ * schedule get one, so an unscheduled menu stays as quiet as it is today.
736
+ */
737
+ function rowScheduleState(item: unknown): VisibilityState | null {
738
+ if (!isPlainObject(item)) return null;
739
+ const schedule = normalizeSchedule(item[SCHEDULE_KEY]);
740
+ if (isEmptySchedule(schedule)) return null;
741
+ return visibilityStateAt(schedule, Date.now());
742
+ }
743
+
698
744
  function SortableArrayRow({
699
745
  id,
700
746
  index,
701
747
  lastIndex,
702
748
  open,
703
749
  summary,
750
+ scheduleState,
704
751
  onToggle,
705
752
  onMoveUp,
706
753
  onMoveDown,
@@ -712,6 +759,8 @@ function SortableArrayRow({
712
759
  lastIndex: number;
713
760
  open: boolean;
714
761
  summary: string;
762
+ /** Set only when this row carries a schedule; `null` renders no badge. */
763
+ scheduleState: VisibilityState | null;
715
764
  onToggle: () => void;
716
765
  onMoveUp: () => void;
717
766
  onMoveDown: () => void;
@@ -751,6 +800,11 @@ function SortableArrayRow({
751
800
  )}
752
801
  <span className="text-ink/50 shrink-0">#{index + 1}</span>
753
802
  <span className="text-ink truncate">{summary}</span>
803
+ {scheduleState ? (
804
+ <span className="shrink-0">
805
+ <VisibilityBadge state={scheduleState} />
806
+ </span>
807
+ ) : null}
754
808
  </button>
755
809
 
756
810
  <button
@@ -853,6 +907,10 @@ function ObjectField({
853
907
  const isTreeKey = (k: string) => (TREE_KEYS as readonly string[]).includes(k);
854
908
  const isLink = Boolean(inList) && isLinkNode(value, deps);
855
909
  if (isLink && !keys.some(isTreeKey)) keys.push(TREE_KEYS[0]);
910
+ // Every menu item and footer link can be scheduled, so the control sits with
911
+ // that item's own controls whether or not anything has been scheduled before.
912
+ // Like the submenu above, showing it writes nothing.
913
+ if (isLink && !keys.includes(SCHEDULE_KEY)) keys.push(SCHEDULE_KEY);
856
914
 
857
915
  const content = (
858
916
  <div className={depth === 0 ? "space-y-5" : "space-y-4"}>