@bygghammar/cms-platform 0.93.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/docs/PAGES.md +72 -4
  3. package/package.json +9 -1
  4. package/src/admin/describe-log.ts +3 -0
  5. package/src/admin/module-inventory.ts +67 -0
  6. package/src/admin/registry-metadata.ts +256 -0
  7. package/src/admin/semver.ts +111 -0
  8. package/src/admin/version-core.ts +233 -25
  9. package/src/admin/version-report.ts +295 -0
  10. package/src/backoffice/BookingSettingsCard.tsx +252 -0
  11. package/src/backoffice/NavigationView.tsx +38 -0
  12. package/src/backoffice/PageBuilderView.tsx +149 -3
  13. package/src/backoffice/PagesListView.tsx +173 -57
  14. package/src/backoffice/SiteSettingsView.tsx +4 -0
  15. package/src/backoffice/StatusBadge.tsx +38 -2
  16. package/src/backoffice/VersionView.tsx +747 -66
  17. package/src/backoffice/media/ConfirmDialog.tsx +19 -1
  18. package/src/backoffice/routes/host.ts +24 -0
  19. package/src/backoffice/routes/index.tsx +148 -9
  20. package/src/backoffice/routes/pages.tsx +13 -0
  21. package/src/backoffice/shell/use-resolved-config.tsx +30 -5
  22. package/src/cms/booking-links.ts +176 -0
  23. package/src/cms/page-publication.ts +11 -6
  24. package/src/cms/page-visibility.ts +206 -0
  25. package/src/config/modules.ts +11 -0
  26. package/src/html/sanitize-html.ts +1 -1
  27. package/src/navigation/visibility.ts +235 -0
  28. package/src/popups/package-name.ts +8 -0
  29. package/src/popups/runtime.tsx +155 -4
  30. package/src/server/page-visibility-core.ts +450 -0
  31. package/src/server/pages-core.ts +238 -9
  32. package/src/server/published-cache.ts +23 -3
  33. package/src/server/schemas.ts +17 -0
  34. package/src/site/PageRenderer.tsx +34 -5
  35. package/src/site/SiteFooter.tsx +11 -2
  36. package/src/site/SiteHeader.tsx +41 -21
  37. package/src/site/link-visibility.tsx +28 -0
  38. package/src/site/route-aliases.ts +74 -0
  39. package/src/site/routes/preview-page.tsx +39 -3
  40. package/src/site/routes/public-page.tsx +81 -9
@@ -37,6 +37,13 @@ import {
37
37
  type PublicationWindow,
38
38
  } from "../cms/page-publication";
39
39
  import { resolveGlobalsDeep } from "./resolve-globals";
40
+ import { normalizePageListing, type PageListing } from "../cms/page-visibility";
41
+ import {
42
+ assertNotProtected,
43
+ PAGE_LISTING_PREFIX,
44
+ readVisibilitySettingsCore,
45
+ type VersionToken,
46
+ } from "./page-visibility-core";
40
47
  import { CONTRACT_V2_MIGRATIONS, CONTRACT_V3_MIGRATIONS } from "../platform/contract";
41
48
  import { digestJson, type DigestValue } from "../platform/digest";
42
49
 
@@ -50,8 +57,48 @@ export type PagesDeps = {
50
57
  context: PagesContext;
51
58
  /** Privileged client; only used to resolve legacy version authors. */
52
59
  admin?: () => Promise<any>;
60
+ /**
61
+ * Keys of pages that must always stay published and listed (for Voyara the
62
+ * start page and the contact page). The cores refuse to unpublish, end-date,
63
+ * trash or unlist them. Supplied by the host's server code, never the browser.
64
+ */
65
+ protectedPageKeys?: readonly string[];
66
+ };
67
+
68
+ /** Listing fields the list and the builder show next to publication. */
69
+ export type PageListingFields = {
70
+ listing: PageListing;
71
+ /** Version token to send back with a listing change (null: never changed). */
72
+ listing_version: VersionToken;
73
+ /** Protected pages offer neither Unpublish nor Make link-only. */
74
+ protected: boolean;
53
75
  };
54
76
 
77
+ function listingFieldsFor(
78
+ pageId: string,
79
+ key: string,
80
+ settings: Awaited<ReturnType<typeof readVisibilitySettingsCore>>,
81
+ protectedPageKeys: readonly string[] | undefined,
82
+ ): PageListingFields {
83
+ const row = settings.listings.get(pageId.toLowerCase());
84
+ return {
85
+ listing: normalizePageListing(row?.listing),
86
+ listing_version: row?.version ?? null,
87
+ protected: (protectedPageKeys ?? []).includes(key),
88
+ };
89
+ }
90
+
91
+ /** Name and key of one page, for the protected-page rule. */
92
+ async function pageIdentity(client: any, pageId: string): Promise<{ key: string; name: string } | null> {
93
+ const { data, error } = await client
94
+ .from("pages")
95
+ .select("key,name")
96
+ .eq("id", pageId)
97
+ .maybeSingle();
98
+ if (error) throw new Error(error.message);
99
+ return (data as { key: string; name: string } | null) ?? null;
100
+ }
101
+
55
102
  export type ResolvedPage = {
56
103
  key: string;
57
104
  name: string;
@@ -304,9 +351,10 @@ export type PageListRow = {
304
351
  status: string;
305
352
  updated_at: string;
306
353
  current_version_id: string | null;
307
- } & PublicationSummaryFields;
354
+ } & PublicationSummaryFields &
355
+ PageListingFields;
308
356
 
309
- export async function listPagesCore({ context }: PagesDeps): Promise<PageListRow[]> {
357
+ export async function listPagesCore({ context, protectedPageKeys }: PagesDeps): Promise<PageListRow[]> {
310
358
  await assertStaff(context);
311
359
  const { data, error } = await context.supabase
312
360
  .from("pages")
@@ -315,7 +363,9 @@ export async function listPagesCore({ context }: PagesDeps): Promise<PageListRow
315
363
  .order("is_system", { ascending: false })
316
364
  .order("name");
317
365
  if (error) throw new Error(error.message);
318
- const rows = (data ?? []) as Array<Omit<PageListRow, keyof PublicationSummaryFields>>;
366
+ const rows = (data ?? []) as Array<
367
+ Omit<PageListRow, keyof PublicationSummaryFields | keyof PageListingFields>
368
+ >;
319
369
  if (rows.length === 0) return [];
320
370
 
321
371
  /*
@@ -341,11 +391,17 @@ export async function listPagesCore({ context }: PagesDeps): Promise<PageListRow
341
391
  byPage.set(pageId, [...(byPage.get(pageId) ?? []), ...toPublicationWindows([row])]);
342
392
  }
343
393
 
394
+ // Listing is its own decision, stored apart from publication; read once.
395
+ const settings = await readVisibilitySettingsCore(context.supabase);
344
396
  const now = Date.now();
345
- return rows.map((row) => ({ ...row, ...summarize(byPage.get(row.id) ?? [], now) }));
397
+ return rows.map((row) => ({
398
+ ...row,
399
+ ...summarize(byPage.get(row.id) ?? [], now),
400
+ ...listingFieldsFor(row.id, row.key, settings, protectedPageKeys),
401
+ }));
346
402
  }
347
403
 
348
- export async function getPageForEditCore({ context }: PagesDeps, key: string) {
404
+ export async function getPageForEditCore({ context, protectedPageKeys }: PagesDeps, key: string) {
349
405
  await assertStaff(context);
350
406
  const { data: page, error } = await context.supabase
351
407
  .from("pages")
@@ -373,7 +429,9 @@ export async function getPageForEditCore({ context }: PagesDeps, key: string) {
373
429
  * the public routes use.
374
430
  */
375
431
  const publication = await publicationOf(context.supabase, page.id);
376
- return { page: { ...page, ...publication }, blocks };
432
+ const settings = await readVisibilitySettingsCore(context.supabase);
433
+ const listing = listingFieldsFor(page.id, page.key, settings, protectedPageKeys);
434
+ return { page: { ...page, ...publication, ...listing }, blocks };
377
435
  }
378
436
 
379
437
  /**
@@ -649,7 +707,7 @@ export async function createPageCore({ context }: PagesDeps, input: { key: strin
649
707
  * or a schedule somebody forgot about.
650
708
  */
651
709
  export async function setPageStatusCore(
652
- { context }: PagesDeps,
710
+ { context, protectedPageKeys }: PagesDeps,
653
711
  input: { pageId: string; status: PageStatus; editSessionId?: string },
654
712
  ) {
655
713
  await assertStaff(context);
@@ -670,6 +728,7 @@ export async function setPageStatusCore(
670
728
  if (status === "published" && !page.current_version_id) {
671
729
  throw new Error("This page has no saved content yet, so there is nothing to publish.");
672
730
  }
731
+ if (status === "draft") assertNotProtected(protectedPageKeys, page, "unpublished");
673
732
 
674
733
  invalidatePublishedCache();
675
734
 
@@ -774,6 +833,11 @@ export async function duplicatePageCore(
774
833
  blocks = duplicateBlockTree(blocksOf(version.blocks));
775
834
  }
776
835
 
836
+ // Before the copy exists: what this duplication means for its listing, fixed
837
+ // now and kept with the idempotency key, so a retry completes this decision
838
+ // rather than taking a new one from the source as it is by then.
839
+ const intent = await recordDuplicateIntent(context, idempotencyKey, input.pageId);
840
+
777
841
  invalidatePublishedCache();
778
842
  const copy = await publicationRpc(
779
843
  context.supabase,
@@ -797,6 +861,7 @@ export async function duplicatePageCore(
797
861
 
798
862
  const id = typeof copy?.pageId === "string" ? copy.pageId : "";
799
863
  if (!id) throw new Error("The database made no copy of this page.");
864
+ await completeDuplicateListing(context, intent, id);
800
865
  return {
801
866
  id,
802
867
  key: String(copy.key ?? key),
@@ -846,7 +911,7 @@ export async function renamePageCore(
846
911
  * publishing action nobody confirmed.
847
912
  */
848
913
  export async function deletePageCore(
849
- { context }: PagesDeps,
914
+ { context, protectedPageKeys }: PagesDeps,
850
915
  pageId: string,
851
916
  editSessionId?: string,
852
917
  ) {
@@ -856,6 +921,8 @@ export async function deletePageCore(
856
921
  resourceId: pageId,
857
922
  sessionId: editSessionId,
858
923
  });
924
+ const identity = await pageIdentity(context.supabase, pageId);
925
+ if (identity) assertNotProtected(protectedPageKeys, identity, "moved to the trash");
859
926
  invalidatePublishedCache();
860
927
  await publicationRpc(
861
928
  context.supabase,
@@ -956,6 +1023,163 @@ export async function purgePageCore({ context }: PagesDeps, pageId: string) {
956
1023
  return { ok: true as const };
957
1024
  }
958
1025
 
1026
+ /** Where a duplication's listing decision is kept until it has been applied. */
1027
+ const DUPLICATE_INTENT_PREFIX = "duplicate.intent.";
1028
+
1029
+ type DuplicateIntent = {
1030
+ key: string;
1031
+ /** What the copy's listing is to be: the source's, as it was when the row was written. */
1032
+ listing: PageListing;
1033
+ /** When the database wrote the row (`site_settings.created_at`, its own clock). */
1034
+ recordedAt: string;
1035
+ };
1036
+
1037
+ /**
1038
+ * A copy of a link-only page starts link-only too.
1039
+ *
1040
+ * The copy is a draft, so nothing is public yet; but the moment somebody
1041
+ * publishes it, a listed copy would put the address the original kept out of
1042
+ * menus and the sitemap straight into them. Booking exceptions are never
1043
+ * copied: the safe default for a new page is booking hidden while booking is
1044
+ * off.
1045
+ *
1046
+ * The copy is made in one transaction and listed in a second write, so the
1047
+ * listing can fail after the copy exists, and a retry with the same
1048
+ * idempotency key is how a caller finishes it. A retry must finish *that*
1049
+ * decision and never take a new one: if the source has been listed or
1050
+ * unlisted since, a call that read the source again would change a copy
1051
+ * nobody asked to change. So the decision is written down before the copy is
1052
+ * made — a `site_settings` row keyed by the idempotency key, durable across
1053
+ * processes, no migration — and applied after it.
1054
+ *
1055
+ * Which row is that decision is settled by the database's own record, not by
1056
+ * which call wrote it, whether a cleanup succeeded, or how many calls are in
1057
+ * flight: the decision is the row written **before the copy existed**. The
1058
+ * row's `created_at` and the copy's `pages.created_at` are both the
1059
+ * database's `now()`, and the row is always committed before the call that
1060
+ * makes the copy starts. A row written by a call that arrived after the copy
1061
+ * was made — a replay of a finished duplication, alone or overlapping
1062
+ * another, or one whose cleanup failed — is only an observation of the source
1063
+ * as it is today, and is never applied.
1064
+ *
1065
+ * The row is inserted only if absent (`ON CONFLICT DO NOTHING`), so the first
1066
+ * attempt's decision is the one that stands, and its `created_at` never moves.
1067
+ * The table is publicly readable, so the row holds the decision only, as the
1068
+ * page's own listing row would.
1069
+ */
1070
+ async function recordDuplicateIntent(
1071
+ context: PagesContext,
1072
+ idempotencyKey: string,
1073
+ sourceId: string,
1074
+ ): Promise<DuplicateIntent | null> {
1075
+ const key = `${DUPLICATE_INTENT_PREFIX}${idempotencyKey.toLowerCase()}`;
1076
+ const settings = await readVisibilitySettingsCore(context.supabase);
1077
+ const listing = normalizePageListing(settings.listings.get(sourceId.toLowerCase())?.listing);
1078
+ const { error } = await context.supabase
1079
+ .from("site_settings")
1080
+ .upsert(
1081
+ { key, value: { listing }, updated_by: context.userId },
1082
+ { onConflict: "key", ignoreDuplicates: true },
1083
+ )
1084
+ .select("key");
1085
+ if (error) throw new Error(`Could not prepare the copy: ${error.message}. Nothing was copied.`);
1086
+ // Whoever wrote it, the row now in the database is the one that counts.
1087
+ const { data: row, error: readError } = await context.supabase
1088
+ .from("site_settings")
1089
+ .select("value, created_at")
1090
+ .eq("key", key)
1091
+ .maybeSingle();
1092
+ if (readError) throw new Error(`Could not prepare the copy: ${readError.message}. Nothing was copied.`);
1093
+ // Gone again: a call that finished this duplication has applied and removed it.
1094
+ if (!row) return null;
1095
+ const value = (row.value ?? {}) as { listing?: unknown };
1096
+ return { key, listing: normalizePageListing(value.listing), recordedAt: String(row.created_at) };
1097
+ }
1098
+
1099
+ /**
1100
+ * Apply the duplication's recorded listing decision to the copy, if it is the
1101
+ * original decision (see {@link recordDuplicateIntent}).
1102
+ *
1103
+ * Only ever inserts a missing listing row, so a listing chosen for the copy
1104
+ * since (a row already present, listed or link-only) is never overwritten,
1105
+ * and applying the same decision twice is harmless.
1106
+ */
1107
+ async function completeDuplicateListing(
1108
+ context: PagesContext,
1109
+ intent: DuplicateIntent | null,
1110
+ copyId: string,
1111
+ ): Promise<void> {
1112
+ if (!intent) return;
1113
+ const { data: copy, error } = await context.supabase
1114
+ .from("pages")
1115
+ .select("created_at")
1116
+ .eq("id", copyId)
1117
+ .maybeSingle();
1118
+ const recorded = timestampMicros(intent.recordedAt);
1119
+ const created = timestampMicros(copy?.created_at);
1120
+ if (error || recorded === null || created === null) {
1121
+ // Undecidable now. The decision stays where it is, for a retry to settle.
1122
+ throw new Error(
1123
+ "The copy was made as a draft, but whether it is listed like the original could not be settled" +
1124
+ (error ? `: ${error.message}` : "") +
1125
+ ". Retrying the same duplication (same idempotency key) completes it without a second copy; " +
1126
+ "otherwise check the copy's listing before publishing it.",
1127
+ );
1128
+ }
1129
+ if (recorded < created && intent.listing === "link-only") {
1130
+ const { error: writeError } = await context.supabase
1131
+ .from("site_settings")
1132
+ .upsert(
1133
+ {
1134
+ key: `${PAGE_LISTING_PREFIX}${copyId.toLowerCase()}`,
1135
+ value: { listing: "link-only" },
1136
+ updated_by: context.userId,
1137
+ },
1138
+ { onConflict: "key", ignoreDuplicates: true },
1139
+ )
1140
+ .select("updated_at");
1141
+ if (writeError) {
1142
+ throw new Error(
1143
+ `The copy was made as a draft, but it could not be marked link-only like the original: ${writeError.message}. ` +
1144
+ "Retrying the same duplication (same idempotency key) completes it without a second copy; " +
1145
+ "otherwise set the copy to link-only before publishing it.",
1146
+ );
1147
+ }
1148
+ invalidatePublishedCache();
1149
+ }
1150
+ await forgetDuplicateIntent(context, intent);
1151
+ }
1152
+
1153
+ /**
1154
+ * Remove the decision row once it has been dealt with. Nothing depends on this
1155
+ * succeeding, so a failure is not an error: an original decision left behind
1156
+ * only ever inserts what is already there, and a row written after the copy
1157
+ * existed is never applied. Whatever row holds the key by now was written
1158
+ * after the copy existed, so removing it loses no pending decision.
1159
+ */
1160
+ async function forgetDuplicateIntent(context: PagesContext, intent: DuplicateIntent): Promise<void> {
1161
+ await context.supabase.from("site_settings").delete().eq("key", intent.key);
1162
+ }
1163
+
1164
+ /**
1165
+ * A `timestamptz` as PostgREST returns it (`2026-10-16T11:03:36.123456+00:00`),
1166
+ * in whole microseconds since the epoch, so two instants in the same
1167
+ * millisecond still compare correctly. Null when it is not one.
1168
+ */
1169
+ function timestampMicros(value: unknown): number | null {
1170
+ if (typeof value !== "string") return null;
1171
+ const match = /^(\d{4}-\d\d-\d\d)[T ](\d\d:\d\d:\d\d)(?:\.(\d{1,6})\d*)?(Z|[+-]\d\d(?::?\d\d)?)$/.exec(
1172
+ value.trim(),
1173
+ );
1174
+ if (!match) return null;
1175
+ const offset = match[4]!;
1176
+ const zone =
1177
+ offset === "Z" ? "Z" : offset.length === 3 ? `${offset}:00` : offset.replace(/^([+-]\d\d)(\d\d)$/, "$1:$2");
1178
+ const seconds = Date.parse(`${match[1]}T${match[2]}${zone}`);
1179
+ if (!Number.isFinite(seconds)) return null;
1180
+ return seconds * 1000 + Number((match[3] ?? "").padEnd(6, "0"));
1181
+ }
1182
+
959
1183
  /* ---------- Global blocks ---------- */
960
1184
 
961
1185
  /** A global block holds exactly one block and may not reference other globals. */
@@ -1236,7 +1460,7 @@ function scheduleInstant(value: unknown, field: string): number {
1236
1460
  * and the page it promised to reveal stayed hidden for good.
1237
1461
  */
1238
1462
  export async function schedulePageVersionCore(
1239
- { context }: PagesDeps,
1463
+ { context, protectedPageKeys }: PagesDeps,
1240
1464
  input: {
1241
1465
  versionId: string;
1242
1466
  publishAt: string;
@@ -1271,6 +1495,11 @@ export async function schedulePageVersionCore(
1271
1495
  resourceId: v.page_id,
1272
1496
  sessionId: input.editSessionId,
1273
1497
  });
1498
+ // An end date is an unpublish on a timer, which a protected page refuses too.
1499
+ if (unpublishAt !== null) {
1500
+ const identity = await pageIdentity(context.supabase, v.page_id);
1501
+ if (identity) assertNotProtected(protectedPageKeys, identity, "given an unpublish time");
1502
+ }
1274
1503
  invalidatePublishedCache();
1275
1504
  const scheduled = await publicationRpc(
1276
1505
  context.supabase,
@@ -13,7 +13,7 @@
13
13
  * tests. A cold process simply has an empty cache.
14
14
  */
15
15
 
16
- export type PublishedCacheScope = "page" | "global" | "custom";
16
+ export type PublishedCacheScope = "page" | "global" | "policy" | "custom";
17
17
 
18
18
  type Entry = { value: unknown; expiresAt: number };
19
19
 
@@ -22,6 +22,16 @@ const store = new Map<string, Entry>();
22
22
  /** How long an entry may be served before it is re-read. */
23
23
  let ttlMs = 20_000;
24
24
 
25
+ /**
26
+ * Bumped by every invalidation. A load records the generation it started in
27
+ * and may only store its answer if no invalidation happened meanwhile: a read
28
+ * that began before a write and finished after it holds the old state, and
29
+ * putting that back into the cache would undo the invalidation the write just
30
+ * made — every request after the write would be served what the write
31
+ * replaced.
32
+ */
33
+ let generation = 0;
34
+
25
35
  /** Cheap counters so the backoffice can show whether the cache is working. */
26
36
  let hits = 0;
27
37
  let misses = 0;
@@ -83,6 +93,10 @@ export function publishedCacheSet(
83
93
  */
84
94
  export function invalidatePublishedCache(scope?: PublishedCacheScope, key?: string): void {
85
95
  invalidations += 1;
96
+ // Any invalidation, however narrow, retires every load already in flight:
97
+ // it may only cost a fill that was going to be correct, never let a stale
98
+ // one through.
99
+ generation += 1;
86
100
  if (!scope) {
87
101
  store.clear();
88
102
  return;
@@ -116,9 +130,14 @@ export async function withPublishedCache<T>(
116
130
  return cached;
117
131
  }
118
132
  misses += 1;
133
+ const startedIn = generation;
119
134
  const value = await load();
120
- // `undefined` is indistinguishable from "absent", so it is never stored.
121
- if (value !== undefined) publishedCacheSet(scope, key, value, boundaryOf?.(value));
135
+ // `undefined` is indistinguishable from "absent", so it is never stored. A
136
+ // value whose read began before an invalidation is returned to its caller —
137
+ // it is what that caller asked for — but never stored for anyone else.
138
+ if (value !== undefined && startedIn === generation) {
139
+ publishedCacheSet(scope, key, value, boundaryOf?.(value));
140
+ }
122
141
  return value;
123
142
  }
124
143
 
@@ -136,6 +155,7 @@ export function publishedCacheStats(): {
136
155
  /** Resets counters and contents. Tests use this between cases. */
137
156
  export function resetPublishedCache(): void {
138
157
  store.clear();
158
+ generation += 1;
139
159
  hits = 0;
140
160
  misses = 0;
141
161
  invalidations = 0;
@@ -131,6 +131,23 @@ export const pageSchemas = {
131
131
  status: z.enum(["draft", "published"]),
132
132
  editSessionId: editSessionId.optional(),
133
133
  }),
134
+ /** Listing only: never publishes, unpublishes or reschedules. */
135
+ setPageListing: z.object({
136
+ pageId: uuid,
137
+ listing: z.enum(["listed", "link-only"]),
138
+ /** The version the editor was shown; null when nothing was stored yet. */
139
+ expectedVersion: z.string().min(1).max(64).nullable(),
140
+ editSessionId: editSessionId.optional(),
141
+ }),
142
+ setBookingEnabled: z.object({
143
+ enabled: z.boolean(),
144
+ expectedVersion: z.string().min(1).max(64).nullable(),
145
+ }),
146
+ setBookingException: z.object({
147
+ pageId: uuid,
148
+ exception: z.boolean(),
149
+ expectedVersion: z.string().min(1).max(64).nullable(),
150
+ }),
134
151
  duplicatePage: z.object({
135
152
  pageId: uuid,
136
153
  name: z.string().min(1).max(120),
@@ -177,14 +177,24 @@ export function createPageQueryOptions(
177
177
  fetchPublishedPage: (key: string) => Promise<RenderedPage | null>,
178
178
  defaultStaleTimeMs: number = PAGE_STALE_TIME_MS,
179
179
  ) {
180
- return (key: string) =>
180
+ /*
181
+ * `variant` partitions the cache by whatever the site's public policy says
182
+ * about the page (booking shown or not, say). The server stays the
183
+ * authority on what it returns; the partition only stops an answer fetched
184
+ * under one policy being reused under another, so switching booking back
185
+ * on is not answered from a copy fetched while it was off.
186
+ */
187
+ return (key: string, variant?: string) =>
181
188
  queryOptions({
182
- queryKey: ["cms", "page", key],
189
+ queryKey: variant ? ["cms", "page", key, variant] : ["cms", "page", key],
183
190
  queryFn: () => fetchPublishedPage(key),
184
191
  staleTime: (query) => publishedPageStaleTime(query.state.data, defaultStaleTimeMs),
185
192
  });
186
193
  }
187
194
 
195
+ /** A rendering-time view of a fetched page, e.g. with links the policy forbids removed. */
196
+ export type PageTransform = (page: RenderedPage) => RenderedPage;
197
+
188
198
  /**
189
199
  * Build the published-page renderer for a site.
190
200
  * `fetchPublishedPage` is the site's `getPublishedPage` server function.
@@ -195,9 +205,28 @@ export function createPageRenderer(
195
205
  ) {
196
206
  const pageQueryOptions = createPageQueryOptions(fetchPublishedPage);
197
207
 
198
- function PageRenderer({ pageKey }: { pageKey: string }) {
199
- const { data } = useSuspenseQuery(pageQueryOptions(pageKey));
200
- const page = data as RenderedPage | null;
208
+ /*
209
+ * `transform` is applied to whatever the cache holds, on every render: a
210
+ * page cached before the policy changed, or one still being refetched in
211
+ * the background, is shown as the current policy says, never as it was
212
+ * fetched. The cached original is not changed, so a later policy can show
213
+ * it again. Pass a stable function, or none.
214
+ */
215
+ function PageRenderer({
216
+ pageKey,
217
+ variant,
218
+ transform,
219
+ }: {
220
+ pageKey: string;
221
+ variant?: string;
222
+ transform?: PageTransform;
223
+ }) {
224
+ const { data } = useSuspenseQuery(pageQueryOptions(pageKey, variant));
225
+ const fetched = data as RenderedPage | null;
226
+ const page = useMemo(
227
+ () => (fetched && transform ? transform(fetched) : fetched),
228
+ [fetched, transform],
229
+ );
201
230
  if (!page) return null;
202
231
  return (
203
232
  <DraftPageRendererView blocks={page.blocks} globals={page.globals} registry={registry} />
@@ -12,6 +12,8 @@ import type { SiteTypography } from "../config/typography";
12
12
  import { useT } from "../i18n";
13
13
  import type { LocalizedString } from "../i18n/types";
14
14
  import { SiteFontLinks, fontStyle, slotFont, typographyFontRequests } from "./typography";
15
+ import { filterFooterColumnsForPolicy } from "../navigation/visibility";
16
+ import { useLinkVisibility } from "./link-visibility";
15
17
 
16
18
  export type SiteFooterCompany = {
17
19
  legalName: string;
@@ -81,12 +83,19 @@ export function SiteFooter({
81
83
  // boundary rather than on the next full page load.
82
84
  const now = useScheduleClock(footerSchedules(columns));
83
85
  const footerFont = slotFont(typography, "footer");
84
- const visibleColumns = columns
86
+ const linkPolicy = useLinkVisibility();
87
+ // Scheduled-out links first, then links the site must not offer right now
88
+ // (an offline or link-only page, booking while it is off). A column left with
89
+ // nothing to link to is not rendered as an empty heading.
90
+ const scheduled = columns
85
91
  .filter((column) => isVisibleAt(column.schedule, now))
86
92
  .map((column) => ({
87
93
  ...column,
88
- links: (column.links ?? []).filter((link) => isVisibleAt(link.schedule, now)),
94
+ links: (column.links ?? []).filter(
95
+ (link) => isVisibleAt(link.schedule, now) && (link.href ?? "").trim() !== "",
96
+ ),
89
97
  }));
98
+ const visibleColumns = filterFooterColumnsForPolicy(scheduled, linkPolicy);
90
99
 
91
100
  const jsonLd = {
92
101
  "@context": "https://schema.org",
@@ -21,6 +21,8 @@ import {
21
21
  type NavigationItem,
22
22
  } from "../navigation";
23
23
  import { SiteFontLinks, fontStyle, slotFont, typographyFontRequests } from "./typography";
24
+ import { filterNavigationForPolicy, linkHideReason } from "../navigation/visibility";
25
+ import { useLinkVisibility } from "./link-visibility";
24
26
 
25
27
  export type SiteHeaderProps = {
26
28
  /**
@@ -39,8 +41,13 @@ export type SiteHeaderProps = {
39
41
  logoAlt?: string;
40
42
  /** Set false when the logo artwork is already dark. */
41
43
  invertLogoOnLightSurface?: boolean;
42
- /** Primary call to action shown on desktop and at the end of the mobile panel. */
43
- primaryCta: { href: string; label: LocalizedString };
44
+ /**
45
+ * Primary call to action shown on desktop and at the end of the mobile panel.
46
+ * `null` shows none. With `booking: true` it is a booking action, hidden
47
+ * while the site's booking is switched off; either way it is hidden when the
48
+ * page it leads to is offline or link-only (see `LinkVisibilityProvider`).
49
+ */
50
+ primaryCta: { href: string; label: LocalizedString; booking?: boolean } | null;
44
51
  /** Mobile menu toggle labels. */
45
52
  menuLabels: { open: LocalizedString; close: LocalizedString };
46
53
  /** Optional backoffice shortcut, rendered only when `signedIn` is true. */
@@ -252,8 +259,17 @@ export function SiteHeader({
252
259
  // A page left open past a scheduled publish/hide time re-renders on the
253
260
  // boundary instead of showing what was true when it loaded.
254
261
  const now = useScheduleClock(navigationSchedules(nav));
262
+ // Links to offline or link-only pages, and booking links while booking is
263
+ // off, are left out here; the saved menu itself is untouched.
264
+ const linkPolicy = useLinkVisibility();
255
265
  // Half-finished entries are the editor's business, not a visitor's.
256
- const items = renderableNavigation(nav, locale, now);
266
+ const items = renderableNavigation(filterNavigationForPolicy(nav, linkPolicy), locale, now);
267
+ const cta =
268
+ primaryCta &&
269
+ !(primaryCta.booking && !linkPolicy.bookingVisible) &&
270
+ linkHideReason(primaryCta.href, linkPolicy) === null
271
+ ? primaryCta
272
+ : null;
257
273
  const menuFont = slotFont(typography, "menu");
258
274
 
259
275
  return (
@@ -297,17 +313,19 @@ export function SiteHeader({
297
313
  {backoffice.label}
298
314
  </Link>
299
315
  )}
300
- <Link
301
- to={primaryCta.href}
302
- className={[
303
- "inline-flex items-center border px-5 py-2.5 text-xs uppercase tracking-[0.2em] transition-colors",
304
- !lightText
305
- ? "border-foreground text-foreground hover:bg-foreground hover:text-background"
306
- : "border-paper text-paper hover:bg-paper hover:text-ink",
307
- ].join(" ")}
308
- >
309
- {t(primaryCta.label)}
310
- </Link>
316
+ {cta ? (
317
+ <Link
318
+ to={cta.href}
319
+ className={[
320
+ "inline-flex items-center border px-5 py-2.5 text-xs uppercase tracking-[0.2em] transition-colors",
321
+ !lightText
322
+ ? "border-foreground text-foreground hover:bg-foreground hover:text-background"
323
+ : "border-paper text-paper hover:bg-paper hover:text-ink",
324
+ ].join(" ")}
325
+ >
326
+ {t(cta.label)}
327
+ </Link>
328
+ ) : null}
311
329
  </div>
312
330
 
313
331
  <button
@@ -395,13 +413,15 @@ export function SiteHeader({
395
413
  {backoffice.label}
396
414
  </Link>
397
415
  )}
398
- <Link
399
- to={primaryCta.href}
400
- className="mt-4 inline-flex items-center justify-center border border-foreground px-5 py-3 text-xs uppercase tracking-[0.2em]"
401
- onClick={() => setOpen(false)}
402
- >
403
- {t(primaryCta.label)}
404
- </Link>
416
+ {cta ? (
417
+ <Link
418
+ to={cta.href}
419
+ className="mt-4 inline-flex items-center justify-center border border-foreground px-5 py-3 text-xs uppercase tracking-[0.2em]"
420
+ onClick={() => setOpen(false)}
421
+ >
422
+ {t(cta.label)}
423
+ </Link>
424
+ ) : null}
405
425
  </nav>
406
426
  </div>
407
427
  )}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The public link policy, made available to the site chrome.
3
+ *
4
+ * A site resolves the policy on the server (which pages are offline or
5
+ * link-only, whether booking is on) and provides it here; the shared header and
6
+ * footer read it to leave out links a visitor must not be offered. Without a
7
+ * provider nothing is filtered, which is what the backoffice and any screen
8
+ * that renders the chrome outside the public site get.
9
+ */
10
+ import { createContext, useContext, type ReactNode } from "react";
11
+
12
+ import { OPEN_LINK_POLICY, type LinkVisibilityPolicy } from "../navigation/visibility";
13
+
14
+ const LinkVisibilityContext = createContext<LinkVisibilityPolicy>(OPEN_LINK_POLICY);
15
+
16
+ export function LinkVisibilityProvider({
17
+ policy,
18
+ children,
19
+ }: {
20
+ policy: LinkVisibilityPolicy;
21
+ children: ReactNode;
22
+ }) {
23
+ return <LinkVisibilityContext.Provider value={policy}>{children}</LinkVisibilityContext.Provider>;
24
+ }
25
+
26
+ export function useLinkVisibility(): LinkVisibilityPolicy {
27
+ return useContext(LinkVisibilityContext);
28
+ }