@bygghammar/cms-platform 0.94.0 → 0.95.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.
@@ -20,7 +20,16 @@ import { usePublicationBoundaryRefresh } from "../cms/use-publication-boundary";
20
20
  import { zoneAbbreviation, zonedLocalExists, zonedLocalToInstant } from "../cms/timezone";
21
21
  import { siteTimezone } from "../config/site-config";
22
22
  import { EditLockBanner, EditLockFence, useEditLock } from "./EditLock";
23
- import { PageStatusBadge, PublicationBadge } from "./StatusBadge";
23
+ import { ListingBadge, PageStatusBadge, ProtectedBadge, PublicationBadge } from "./StatusBadge";
24
+ import { ConfirmDialog } from "./media/ConfirmDialog";
25
+ import {
26
+ linkOnlyConfirmation,
27
+ normalizePageListing,
28
+ PROTECTED_PAGE_REASON,
29
+ showOnWebsiteConfirmation,
30
+ unpublishConsequences,
31
+ type PageListing,
32
+ } from "../cms/page-visibility";
24
33
  import {
25
34
  ArrowLeft,
26
35
  Save,
@@ -32,6 +41,9 @@ import {
32
41
  ExternalLink,
33
42
  Copy,
34
43
  Check,
44
+ EyeOff,
45
+ Link2,
46
+ ListChecks,
35
47
  } from "lucide-react";
36
48
 
37
49
  /** A page row as the builder needs it. */
@@ -57,6 +69,12 @@ export type BuilderPage = {
57
69
  publication_transition?: PublicationTransitionKind | null;
58
70
  /** The version showing after that, for a publish or a replacement. */
59
71
  publication_next_version_id?: string | null;
72
+ /** Listed (default) or link-only; independent of publication. */
73
+ listing?: PageListing;
74
+ /** Version token sent back with a listing change; null when never changed. */
75
+ listing_version?: string | null;
76
+ /** The start and contact pages: never unpublished, never unlisted. */
77
+ protected?: boolean;
60
78
  };
61
79
 
62
80
  /** One saved version, with its computed change summary. */
@@ -143,6 +161,22 @@ export type PageBuilderDeps<TBlock> = {
143
161
  cancelPublication: (publicationId: string, editSessionId?: string) => Promise<unknown>;
144
162
  restoreVersion: (versionId: string, editSessionId?: string) => Promise<unknown>;
145
163
  rename: (input: { pageId: string; name: string; editSessionId?: string }) => Promise<unknown>;
164
+ /**
165
+ * Take the page offline, behind a confirmation. Optional: without it the
166
+ * header offers no Unpublish.
167
+ */
168
+ setPageStatus?: (input: {
169
+ pageId: string;
170
+ status: "draft" | "published";
171
+ editSessionId?: string;
172
+ }) => Promise<unknown>;
173
+ /** Make the page link-only or list it again. Optional likewise. */
174
+ setPageListing?: (input: {
175
+ pageId: string;
176
+ listing: PageListing;
177
+ expectedVersion: string | null;
178
+ editSessionId?: string;
179
+ }) => Promise<unknown>;
146
180
  refetchPage: () => void;
147
181
  refetchVersions: () => void;
148
182
  refetchPublications: () => void;
@@ -192,6 +226,9 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
192
226
  const [renameValue, setRenameValue] = useState<string | null>(null);
193
227
  const [locale, setLocale] = useState<string>(defaultLocale);
194
228
  const [copied, setCopied] = useState(false);
229
+ const [visibilityDialog, setVisibilityDialog] = useState<
230
+ null | { kind: "unpublish" } | { kind: "listing"; next: PageListing }
231
+ >(null);
195
232
 
196
233
  // Held for as long as this builder is open. Without a lock provider the
197
234
  // handle reports "not enforced" and nothing below changes.
@@ -391,6 +428,43 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
391
428
  }
392
429
  };
393
430
 
431
+ /** Unpublish and listing changes, once their dialog is confirmed. */
432
+ const onConfirmVisibility = async () => {
433
+ if (!page || !visibilityDialog) return;
434
+ setBusy(true);
435
+ try {
436
+ if (visibilityDialog.kind === "unpublish") {
437
+ if (!deps.setPageStatus) return;
438
+ await deps.setPageStatus({
439
+ pageId: page.id,
440
+ status: "draft",
441
+ editSessionId: lock.sessionId,
442
+ });
443
+ notify.success(`"${page.name}" is offline`);
444
+ } else {
445
+ if (!deps.setPageListing) return;
446
+ await deps.setPageListing({
447
+ pageId: page.id,
448
+ listing: visibilityDialog.next,
449
+ expectedVersion: page.listing_version ?? null,
450
+ editSessionId: lock.sessionId,
451
+ });
452
+ notify.success(
453
+ visibilityDialog.next === "link-only"
454
+ ? `"${page.name}" is now link-only`
455
+ : `"${page.name}" is listed on the website again`,
456
+ );
457
+ }
458
+ setVisibilityDialog(null);
459
+ } catch (e) {
460
+ notify.error(e instanceof Error ? e.message : "Failed");
461
+ } finally {
462
+ setBusy(false);
463
+ deps.refetchPage();
464
+ deps.refetchPublications();
465
+ }
466
+ };
467
+
394
468
  if (isLoading) return <div className="p-12 text-ink/60">Loading…</div>;
395
469
  if (error) return <div className="p-12 text-red-600">Failed to load page.</div>;
396
470
  if (!page) return null;
@@ -505,7 +579,38 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
505
579
  at={page.publication_at}
506
580
  transition={page.publication_transition}
507
581
  />
582
+ <ListingBadge listing={normalizePageListing(page.listing)} publication={page.publication} />
508
583
  <PageStatusBadge status={normalizePageStatus(page.status)} />
584
+ {page.protected ? <ProtectedBadge /> : null}
585
+ {/* What a visitor gets, changed from where it is shown. */}
586
+ {!page.protected && deps.setPageStatus && isPubliclyVisible(page.publication) ? (
587
+ <button
588
+ onClick={() => setVisibilityDialog({ kind: "unpublish" })}
589
+ disabled={busy || lock.blocked}
590
+ className="inline-flex items-center gap-1 border border-ink/20 px-2 py-0.5 text-xs text-ink/70 hover:border-ink hover:text-ink disabled:opacity-40"
591
+ >
592
+ <EyeOff className="size-3" aria-hidden="true" /> Unpublish
593
+ </button>
594
+ ) : null}
595
+ {!page.protected && deps.setPageListing ? (
596
+ normalizePageListing(page.listing) === "link-only" ? (
597
+ <button
598
+ onClick={() => setVisibilityDialog({ kind: "listing", next: "listed" })}
599
+ disabled={busy || lock.blocked}
600
+ className="inline-flex items-center gap-1 border border-ink/20 px-2 py-0.5 text-xs text-ink/70 hover:border-ink hover:text-ink disabled:opacity-40"
601
+ >
602
+ <ListChecks className="size-3" aria-hidden="true" /> Show on website
603
+ </button>
604
+ ) : (
605
+ <button
606
+ onClick={() => setVisibilityDialog({ kind: "listing", next: "link-only" })}
607
+ disabled={busy || lock.blocked}
608
+ className="inline-flex items-center gap-1 border border-ink/20 px-2 py-0.5 text-xs text-ink/70 hover:border-ink hover:text-ink disabled:opacity-40"
609
+ >
610
+ <Link2 className="size-3" aria-hidden="true" /> Make link-only
611
+ </button>
612
+ )
613
+ ) : null}
509
614
  </div>
510
615
  <div className="text-xs text-ink/50 font-mono mt-2">
511
616
  {page.key}
@@ -580,10 +685,11 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
580
685
  </span>
581
686
  <input
582
687
  type="datetime-local"
583
- disabled={lock.blocked}
688
+ disabled={lock.blocked || page.protected === true}
584
689
  value={unpublishAt}
585
690
  onChange={(e) => setUnpublishAt(e.target.value)}
586
- className="w-full border border-ink/20 px-2 py-1.5 text-sm"
691
+ title={page.protected ? PROTECTED_PAGE_REASON : undefined}
692
+ className="w-full border border-ink/20 px-2 py-1.5 text-sm disabled:opacity-40"
587
693
  />
588
694
  </label>
589
695
  <label className="block md:col-span-1">
@@ -774,6 +880,46 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
774
880
  )}
775
881
 
776
882
  {deps.footer && <div className="mt-6 text-xs text-ink/50">{deps.footer}</div>}
883
+
884
+ {visibilityDialog?.kind === "unpublish" ? (
885
+ <ConfirmDialog
886
+ title={`Unpublish “${page.name}”?`}
887
+ description={statusChangeConfirmation("draft", page.publication, page.publication_transition)}
888
+ consequences={unpublishConsequences({
889
+ path: slugPath,
890
+ listing: normalizePageListing(page.listing),
891
+ })}
892
+ confirmLabel="Unpublish"
893
+ danger
894
+ busy={busy}
895
+ onConfirm={onConfirmVisibility}
896
+ onCancel={() => setVisibilityDialog(null)}
897
+ />
898
+ ) : null}
899
+ {visibilityDialog?.kind === "listing"
900
+ ? (() => {
901
+ const facts = {
902
+ name: page.name,
903
+ path: slugPath,
904
+ live: isPubliclyVisible(page.publication),
905
+ };
906
+ const copy =
907
+ visibilityDialog.next === "link-only"
908
+ ? linkOnlyConfirmation(facts)
909
+ : showOnWebsiteConfirmation(facts);
910
+ return (
911
+ <ConfirmDialog
912
+ title={copy.title}
913
+ description={copy.body[0]}
914
+ consequences={copy.body.slice(1)}
915
+ confirmLabel={copy.confirmLabel}
916
+ busy={busy}
917
+ onConfirm={onConfirmVisibility}
918
+ onCancel={() => setVisibilityDialog(null)}
919
+ />
920
+ );
921
+ })()
922
+ : null}
777
923
  </div>
778
924
  );
779
925
  }
@@ -10,9 +10,11 @@ import {
10
10
  Copy,
11
11
  Eye,
12
12
  EyeOff,
13
+ Link2,
14
+ ListChecks,
13
15
  } from "lucide-react";
14
16
  import { ConfirmDialog } from "./media/ConfirmDialog";
15
- import { PageStatusBadge, PublicationBadge } from "./StatusBadge";
17
+ import { ListingBadge, PageStatusBadge, ProtectedBadge, PublicationBadge } from "./StatusBadge";
16
18
  import {
17
19
  duplicateNameFor,
18
20
  isValidPageKey,
@@ -28,6 +30,13 @@ import {
28
30
  type PublicationTransitionKind,
29
31
  } from "../cms/page-publication";
30
32
  import { usePublicationBoundaryRefresh } from "../cms/use-publication-boundary";
33
+ import {
34
+ linkOnlyConfirmation,
35
+ normalizePageListing,
36
+ showOnWebsiteConfirmation,
37
+ unpublishConsequences,
38
+ type PageListing,
39
+ } from "../cms/page-visibility";
31
40
 
32
41
  /** A page row as listed by the host. */
33
42
  export type PageListRow = {
@@ -56,6 +65,12 @@ export type PageListRow = {
56
65
  publication_transition?: PublicationTransitionKind | null;
57
66
  updated_at: string;
58
67
  is_system?: boolean | null;
68
+ /** Listed (default) or link-only; independent of publication. */
69
+ listing?: PageListing;
70
+ /** Version token sent back with a listing change; null when never changed. */
71
+ listing_version?: string | null;
72
+ /** The start and contact pages: never unpublished, never unlisted. */
73
+ protected?: boolean;
59
74
  };
60
75
 
61
76
  /** A soft-deleted page kept in the trash bin. */
@@ -86,6 +101,15 @@ export type PagesListDeps = {
86
101
  * has not wired it up simply does not show the action.
87
102
  */
88
103
  setPageStatus?: (input: { pageId: string; status: PageStatus }) => Promise<unknown>;
104
+ /**
105
+ * Make a page link-only or list it again — listing only, never a publish.
106
+ * Optional: without it the actions are not offered.
107
+ */
108
+ setPageListing?: (input: {
109
+ pageId: string;
110
+ listing: PageListing;
111
+ expectedVersion: string | null;
112
+ }) => Promise<unknown>;
89
113
  /** Copy a page into a new draft. Optional for the same reason. */
90
114
  duplicatePage?: (input: {
91
115
  pageId: string;
@@ -114,6 +138,7 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
114
138
  createPage,
115
139
  renamePage,
116
140
  setPageStatus,
141
+ setPageListing,
117
142
  duplicatePage,
118
143
  deletePage,
119
144
  restorePage,
@@ -132,6 +157,10 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
132
157
  page: PageListRow;
133
158
  next: PageStatus;
134
159
  } | null>(null);
160
+ const [listingChange, setListingChange] = useState<{
161
+ page: PageListRow;
162
+ next: PageListing;
163
+ } | null>(null);
135
164
  const [duplicating, setDuplicating] = useState<{
136
165
  page: PageListRow;
137
166
  name: string;
@@ -202,6 +231,32 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
202
231
  }
203
232
  };
204
233
 
234
+ /** Listing changes are confirmed too, and never touch publication. */
235
+ const onConfirmListing = async () => {
236
+ if (!listingChange || !setPageListing) return;
237
+ setBusy(true);
238
+ try {
239
+ await setPageListing({
240
+ pageId: listingChange.page.id,
241
+ listing: listingChange.next,
242
+ expectedVersion: listingChange.page.listing_version ?? null,
243
+ });
244
+ toast.success(
245
+ listingChange.next === "link-only"
246
+ ? `"${listingChange.page.name}" is now link-only`
247
+ : `"${listingChange.page.name}" is listed on the website again`,
248
+ );
249
+ setListingChange(null);
250
+ refetchPages();
251
+ } catch (e) {
252
+ fail(e);
253
+ // A conflict means the row changed under us: show what is true now.
254
+ refetchPages();
255
+ } finally {
256
+ setBusy(false);
257
+ }
258
+ };
259
+
205
260
  const onConfirmDuplicate = async () => {
206
261
  if (!duplicating || !duplicatePage || duplicateProblem) return;
207
262
  setBusy(true);
@@ -323,8 +378,10 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
323
378
  that is not public stays editable and previewable by signed-in staff.
324
379
  </p>
325
380
 
326
- <div className="mt-8 border border-ink/10 bg-white">
327
- <table className="w-full text-sm">
381
+ {/* Six columns do not fit a phone: the table scrolls inside its frame
382
+ rather than widening the page. */}
383
+ <div className="mt-8 border border-ink/10 bg-white overflow-x-auto">
384
+ <table className="w-full min-w-[56rem] text-sm">
328
385
  <thead className="bg-ink/[0.03] text-left text-[0.7rem] uppercase tracking-[0.18em] text-ink/60">
329
386
  <tr>
330
387
  <th className="px-4 py-3">Name</th>
@@ -396,7 +453,12 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
396
453
  at={p.publication_at}
397
454
  transition={p.publication_transition}
398
455
  />
456
+ <ListingBadge
457
+ listing={normalizePageListing(p.listing)}
458
+ publication={p.publication}
459
+ />
399
460
  <PageStatusBadge status={normalizePageStatus(p.status)} />
461
+ {p.protected ? <ProtectedBadge /> : null}
400
462
  </div>
401
463
  </td>
402
464
  <td className="px-4 py-3 text-ink/50">{new Date(p.updated_at).toLocaleString()}</td>
@@ -410,18 +472,37 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
410
472
  </button>
411
473
  {setPageStatus ? (
412
474
  isPubliclyVisible(p.publication) ? (
475
+ p.protected ? null : (
476
+ <button
477
+ onClick={() => setStatusChange({ page: p, next: "draft" })}
478
+ className="inline-flex items-center gap-1 text-ink/60 hover:text-ink hover:underline"
479
+ >
480
+ <EyeOff className="size-3.5" /> Unpublish
481
+ </button>
482
+ )
483
+ ) : (
413
484
  <button
414
- onClick={() => setStatusChange({ page: p, next: "draft" })}
485
+ onClick={() => setStatusChange({ page: p, next: "published" })}
415
486
  className="inline-flex items-center gap-1 text-ink/60 hover:text-ink hover:underline"
416
487
  >
417
- <EyeOff className="size-3.5" /> Unpublish
488
+ <Eye className="size-3.5" /> Publish
489
+ </button>
490
+ )
491
+ ) : null}
492
+ {setPageListing && !p.protected ? (
493
+ normalizePageListing(p.listing) === "link-only" ? (
494
+ <button
495
+ onClick={() => setListingChange({ page: p, next: "listed" })}
496
+ className="inline-flex items-center gap-1 text-ink/60 hover:text-ink hover:underline"
497
+ >
498
+ <ListChecks className="size-3.5" /> Show on website
418
499
  </button>
419
500
  ) : (
420
501
  <button
421
- onClick={() => setStatusChange({ page: p, next: "published" })}
502
+ onClick={() => setListingChange({ page: p, next: "link-only" })}
422
503
  className="inline-flex items-center gap-1 text-ink/60 hover:text-ink hover:underline"
423
504
  >
424
- <Eye className="size-3.5" /> Publish
505
+ <Link2 className="size-3.5" /> Make link-only
425
506
  </button>
426
507
  )
427
508
  ) : null}
@@ -440,7 +521,7 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
440
521
  <Copy className="size-3.5" /> Duplicate
441
522
  </button>
442
523
  ) : null}
443
- {!p.is_system && (
524
+ {!p.is_system && !p.protected && (
444
525
  <button
445
526
  onClick={() => onDelete(p.id, p.name)}
446
527
  className="inline-flex items-center gap-1 text-red-600 hover:underline"
@@ -473,58 +554,60 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
473
554
  </p>
474
555
  </div>
475
556
  </div>
476
- <table className="w-full text-sm">
477
- <thead className="bg-ink/[0.03] text-left text-[0.7rem] uppercase tracking-[0.18em] text-ink/60">
478
- <tr>
479
- <th className="px-4 py-3">Name</th>
480
- <th className="px-4 py-3">Key</th>
481
- <th className="px-4 py-3">Deleted by</th>
482
- <th className="px-4 py-3">Deleted at</th>
483
- <th className="px-4 py-3 w-56"></th>
484
- </tr>
485
- </thead>
486
- <tbody>
487
- {trash?.rows.map((p) => (
488
- <tr key={p.id} className="border-t border-ink/10">
489
- <td className="px-4 py-3 text-ink">{p.name}</td>
490
- <td className="px-4 py-3 font-mono text-ink/70">{p.key}</td>
491
- <td className="px-4 py-3 text-ink/70">
492
- {p.deleted_by_email ?? p.deleted_by ?? "Unknown user"}
493
- </td>
494
- <td className="px-4 py-3 text-ink/50">
495
- {p.deleted_at ? new Date(p.deleted_at).toLocaleString() : "—"}
496
- </td>
497
- <td className="px-4 py-3 text-right">
498
- <button
499
- onClick={() => onRestore(p.id)}
500
- className="inline-flex items-center gap-1 text-sea hover:underline mr-3"
501
- >
502
- <Undo2 className="size-3.5" /> Restore
503
- </button>
504
- {trash?.canPurge ? (
557
+ <div className="overflow-x-auto">
558
+ <table className="w-full min-w-[40rem] text-sm">
559
+ <thead className="bg-ink/[0.03] text-left text-[0.7rem] uppercase tracking-[0.18em] text-ink/60">
560
+ <tr>
561
+ <th className="px-4 py-3">Name</th>
562
+ <th className="px-4 py-3">Key</th>
563
+ <th className="px-4 py-3">Deleted by</th>
564
+ <th className="px-4 py-3">Deleted at</th>
565
+ <th className="px-4 py-3 w-56"></th>
566
+ </tr>
567
+ </thead>
568
+ <tbody>
569
+ {trash?.rows.map((p) => (
570
+ <tr key={p.id} className="border-t border-ink/10">
571
+ <td className="px-4 py-3 text-ink">{p.name}</td>
572
+ <td className="px-4 py-3 font-mono text-ink/70">{p.key}</td>
573
+ <td className="px-4 py-3 text-ink/70">
574
+ {p.deleted_by_email ?? p.deleted_by ?? "Unknown user"}
575
+ </td>
576
+ <td className="px-4 py-3 text-ink/50">
577
+ {p.deleted_at ? new Date(p.deleted_at).toLocaleString() : "—"}
578
+ </td>
579
+ <td className="px-4 py-3 text-right">
505
580
  <button
506
- onClick={() => onPurge(p.id, p.name)}
507
- className="inline-flex items-center gap-1 text-red-600 hover:underline"
581
+ onClick={() => onRestore(p.id)}
582
+ className="inline-flex items-center gap-1 text-sea hover:underline mr-3"
508
583
  >
509
- <Trash2 className="size-3.5" /> Delete forever
584
+ <Undo2 className="size-3.5" /> Restore
510
585
  </button>
511
- ) : (
512
- <span className="text-[0.7rem] uppercase tracking-widest text-ink/40">
513
- Superadmin only
514
- </span>
515
- )}
516
- </td>
517
- </tr>
518
- ))}
519
- {(trash?.rows.length ?? 0) === 0 && (
520
- <tr>
521
- <td colSpan={5} className="px-4 py-8 text-center text-ink/50">
522
- Trash bin is empty.
523
- </td>
524
- </tr>
525
- )}
526
- </tbody>
527
- </table>
586
+ {trash?.canPurge ? (
587
+ <button
588
+ onClick={() => onPurge(p.id, p.name)}
589
+ className="inline-flex items-center gap-1 text-red-600 hover:underline"
590
+ >
591
+ <Trash2 className="size-3.5" /> Delete forever
592
+ </button>
593
+ ) : (
594
+ <span className="text-[0.7rem] uppercase tracking-widest text-ink/40">
595
+ Superadmin only
596
+ </span>
597
+ )}
598
+ </td>
599
+ </tr>
600
+ ))}
601
+ {(trash?.rows.length ?? 0) === 0 && (
602
+ <tr>
603
+ <td colSpan={5} className="px-4 py-8 text-center text-ink/50">
604
+ Trash bin is empty.
605
+ </td>
606
+ </tr>
607
+ )}
608
+ </tbody>
609
+ </table>
610
+ </div>
528
611
  </div>
529
612
 
530
613
  <div className="mt-10 border border-ink/10 bg-white p-6">
@@ -573,6 +656,14 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
573
656
  statusChange.page.publication_transition,
574
657
  )}
575
658
  details={[`${statusChange.page.name} — ${pathFor(statusChange.page.key)}`]}
659
+ consequences={
660
+ statusChange.next === "published"
661
+ ? undefined
662
+ : unpublishConsequences({
663
+ path: pathFor(statusChange.page.key),
664
+ listing: normalizePageListing(statusChange.page.listing),
665
+ })
666
+ }
576
667
  confirmLabel={statusChange.next === "published" ? "Publish" : "Unpublish"}
577
668
  danger={statusChange.next !== "published"}
578
669
  busy={busy}
@@ -581,6 +672,31 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
581
672
  />
582
673
  ) : null}
583
674
 
675
+ {listingChange ? (
676
+ (() => {
677
+ const facts = {
678
+ name: listingChange.page.name,
679
+ path: pathFor(listingChange.page.key),
680
+ live: isPubliclyVisible(listingChange.page.publication),
681
+ };
682
+ const copy =
683
+ listingChange.next === "link-only"
684
+ ? linkOnlyConfirmation(facts)
685
+ : showOnWebsiteConfirmation(facts);
686
+ return (
687
+ <ConfirmDialog
688
+ title={copy.title}
689
+ description={copy.body[0]}
690
+ consequences={copy.body.slice(1)}
691
+ confirmLabel={copy.confirmLabel}
692
+ busy={busy}
693
+ onConfirm={onConfirmListing}
694
+ onCancel={() => setListingChange(null)}
695
+ />
696
+ );
697
+ })()
698
+ ) : null}
699
+
584
700
  {duplicating ? (
585
701
  <ConfirmDialog
586
702
  title="Duplicate page"
@@ -47,6 +47,8 @@ export type SiteSettingsDeps = {
47
47
  */
48
48
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
49
49
  MediaField?: (props: any) => ReactNode;
50
+ /** Site settings → Booking, rendered after Modules when the host supports it. */
51
+ bookingSection?: ReactNode;
50
52
  };
51
53
 
52
54
  // Labels and order come from the module table, so a new module appears here
@@ -290,6 +292,8 @@ export function SiteSettingsView({ deps }: { deps: SiteSettingsDeps }) {
290
292
  </div>
291
293
  </section>
292
294
 
295
+ {deps.bookingSection ?? null}
296
+
293
297
  {Object.keys(brand).length > 0 && (
294
298
  <section className={`mt-6 ${cardClass}`}>
295
299
  <SectionHeader
@@ -15,26 +15,30 @@
15
15
  */
16
16
 
17
17
  import type { ReactNode } from "react";
18
- import { CalendarClock, CircleDot, CircleSlash, Eye, EyeOff, FileEdit } from "lucide-react";
18
+ import { CalendarClock, CircleDot, CircleSlash, Eye, EyeOff, FileEdit, Link2, Lock } from "lucide-react";
19
19
  import { PAGE_STATUS_DESCRIPTIONS, PAGE_STATUS_LABELS, type PageStatus } from "../cms/page-status";
20
20
  import {
21
21
  publicationSummary,
22
22
  type PublicationState,
23
23
  type PublicationTransitionKind,
24
24
  } from "../cms/page-publication";
25
+ import { PAGE_VISIBILITY_DESCRIPTIONS, PROTECTED_PAGE_REASON, type PageListing } from "../cms/page-visibility";
26
+ import { isPubliclyVisible } from "../cms/page-publication";
25
27
  import {
26
28
  VISIBILITY_STATE_DESCRIPTIONS,
27
29
  VISIBILITY_STATE_LABELS,
28
30
  type VisibilityState,
29
31
  } from "../cms/visibility-schedule";
30
32
 
31
- export type StatusTone = "draft" | "live" | "hidden" | "scheduled";
33
+ export type StatusTone = "draft" | "live" | "hidden" | "scheduled" | "unlisted" | "protected";
32
34
 
33
35
  const TONE_CLASS: Record<StatusTone, string> = {
34
36
  draft: "border-ink/25 bg-ink/[0.04] text-ink/70",
35
37
  live: "border-sea/40 bg-ice/60 text-ink",
36
38
  hidden: "border-ink/20 bg-ink/[0.02] text-ink/50",
37
39
  scheduled: "border-brass/50 bg-brass/10 text-ink/80",
40
+ unlisted: "border-ink/40 bg-paper text-ink",
41
+ protected: "border-ink/15 bg-ink/[0.02] text-ink/60",
38
42
  };
39
43
 
40
44
  const TONE_ICON: Record<StatusTone, (props: { className?: string }) => ReactNode> = {
@@ -42,6 +46,8 @@ const TONE_ICON: Record<StatusTone, (props: { className?: string }) => ReactNode
42
46
  live: CircleDot,
43
47
  hidden: CircleSlash,
44
48
  scheduled: CalendarClock,
49
+ unlisted: Link2,
50
+ protected: Lock,
45
51
  };
46
52
 
47
53
  const PUBLICATION_TONE: Record<PublicationState, StatusTone> = {
@@ -172,3 +178,33 @@ export function VisibilityIcon({ visible }: { visible: boolean }) {
172
178
  <EyeOff className="size-4" aria-hidden="true" />
173
179
  );
174
180
  }
181
+
182
+ /**
183
+ * `LINK ONLY` beside the publication badge when a page is unlisted.
184
+ *
185
+ * Nothing is shown for an ordinary listed page — that is the default and the
186
+ * publication badge already says everything. An offline page that is link-only
187
+ * says so in its own words, because publishing it will not list it.
188
+ */
189
+ export function ListingBadge({
190
+ listing,
191
+ publication,
192
+ }: {
193
+ listing: PageListing;
194
+ publication: PublicationState;
195
+ }) {
196
+ if (listing !== "link-only") return null;
197
+ const live = isPubliclyVisible(publication);
198
+ return (
199
+ <StatusBadge
200
+ label={live ? "Link only" : "Link only once published"}
201
+ tone="unlisted"
202
+ title={PAGE_VISIBILITY_DESCRIPTIONS["link-only"]}
203
+ />
204
+ );
205
+ }
206
+
207
+ /** Marks the start and contact pages, which cannot be unpublished or unlisted. */
208
+ export function ProtectedBadge() {
209
+ return <StatusBadge label="Protected" tone="protected" title={PROTECTED_PAGE_REASON} />;
210
+ }