@bygghammar/cms-platform 0.96.0 → 0.97.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.
@@ -25,7 +25,8 @@ import type {
25
25
  import { ManagedForm } from "../forms/FormRenderer";
26
26
  import { onFormSubmissionSuccess } from "../forms/success-listeners";
27
27
  import { siteConfig } from "../config/site-config";
28
- import { containsBookingTarget, isBookingHref, stripBookingDeep } from "../cms/booking-links";
28
+ import { containsBookingTarget, containsLinkTo, isBookingHref, stripBookingDeep } from "../cms/booking-links";
29
+ import { isHiddenFromDiscovery, type LinkVisibilityPolicy } from "../navigation/visibility";
29
30
  import { useLinkVisibility } from "../site/link-visibility";
30
31
  import type { PopupPublicHost } from "./host";
31
32
  import { popupPublicHost } from "./host";
@@ -111,14 +112,25 @@ export function PopupModuleRuntime(): ReactNode {
111
112
  * it found through the same rules, and only the latest read counts, so one
112
113
  * begun under the old policy cannot bring booking back. Without a policy
113
114
  * provider booking is visible and nothing changes.
115
+ *
116
+ * The same goes for a popup that leads to a page hidden from the website
117
+ * while booking is hidden (a booking-off landing page): not offered, and
118
+ * retired if it is the one on screen, until the page is shown again.
114
119
  */
115
- const bookingVisible = useLinkVisibility().bookingVisible;
120
+ const linkPolicy = useLinkVisibility();
121
+ const bookingVisible = linkPolicy.bookingVisible;
122
+ const hiddenKey = hiddenPagesKey(linkPolicy);
116
123
  const [loaded, setLoaded] = useState<readonly PopupConfig[] | null>(null);
117
124
  const offered = useRef<Offered>(NOTHING_OFFERED);
118
125
  const offer = useMemo(
119
- () => popupsToOffer(loaded ?? [], offered.current, bookingVisible),
120
- // `offered` is what the runtime was last handed, read for identity only.
121
- [loaded, bookingVisible],
126
+ () =>
127
+ popupsToOffer(loaded ?? [], offered.current, bookingVisible, (href) =>
128
+ isHiddenFromDiscovery(href, linkPolicy),
129
+ ),
130
+ // `offered` is what the runtime was last handed, read for identity only;
131
+ // the policy matters only through what `hiddenKey` names.
132
+ // eslint-disable-next-line react-hooks/exhaustive-deps
133
+ [loaded, bookingVisible, hiddenKey],
122
134
  );
123
135
  useEffect(() => {
124
136
  offered.current = offer;
@@ -126,14 +138,23 @@ export function PopupModuleRuntime(): ReactNode {
126
138
 
127
139
  // The repository is asked again when the host or the policy changes — and
128
140
  // only once the runtime asks for its popups, so never where it does not run.
129
- const askedFor = useRef<{ configs: PopupPublicHost["configs"]; bookingVisible: boolean } | null>(null);
141
+ const askedFor = useRef<{
142
+ configs: PopupPublicHost["configs"];
143
+ bookingVisible: boolean;
144
+ hiddenKey: string;
145
+ } | null>(null);
130
146
  const latestRead = useRef(0);
131
147
  const configs = useMemo<RuntimeAdapters["configs"]>(
132
148
  () => ({
133
149
  listActive: async () => {
134
150
  const asked = askedFor.current;
135
- if (asked === null || asked.configs !== host.configs || asked.bookingVisible !== bookingVisible) {
136
- askedFor.current = { configs: host.configs, bookingVisible };
151
+ if (
152
+ asked === null ||
153
+ asked.configs !== host.configs ||
154
+ asked.bookingVisible !== bookingVisible ||
155
+ asked.hiddenKey !== hiddenKey
156
+ ) {
157
+ askedFor.current = { configs: host.configs, bookingVisible, hiddenKey };
137
158
  const read = ++latestRead.current;
138
159
  Promise.resolve()
139
160
  .then(() => host.configs.listActive())
@@ -152,7 +173,7 @@ export function PopupModuleRuntime(): ReactNode {
152
173
  return offer.list;
153
174
  },
154
175
  }),
155
- [host.configs, bookingVisible, offer],
176
+ [host.configs, bookingVisible, hiddenKey, offer],
156
177
  );
157
178
 
158
179
  // The argument type comes from the port, not a copy of it: a package that
@@ -189,6 +210,15 @@ type ContentBlock = PopupConfig["steps"][number]["blocks"][number];
189
210
  type PopupButton = Extract<ContentBlock, { type: "buttons" }>["buttons"][number];
190
211
  type BlockAction = PopupButton["action"];
191
212
 
213
+ /** The booking-off pages a policy hides, as one comparable string. */
214
+ function hiddenPagesKey(policy: LinkVisibilityPolicy): string {
215
+ return Object.entries(policy.hiddenPaths)
216
+ .filter(([, reason]) => reason === "booking-off")
217
+ .map(([path]) => path)
218
+ .sort()
219
+ .join("\n");
220
+ }
221
+
192
222
  /** The list last handed to the runtime, and each popup's content, by key. */
193
223
  type Offered = { list: readonly PopupConfig[]; content: ReadonlyMap<string, string> };
194
224
  const NOTHING_OFFERED: Offered = { list: [], content: new Map() };
@@ -197,7 +227,8 @@ const NOTHING_OFFERED: Offered = { list: [], content: new Map() };
197
227
  * What the runtime is handed from a load, given what it already holds.
198
228
  *
199
229
  * - While booking is hidden, a popup that leads to booking is not offered: it
200
- * is handed over retired (see {@link retire}).
230
+ * is handed over retired (see {@link retire}). So is a popup that leads to
231
+ * a page hidden while booking is hidden (`hiddenPage`).
201
232
  * - A popup the runtime already holds is never dropped, even when the load no
202
233
  * longer has it (a server that withholds booking popups, a popup paused or
203
234
  * deleted since). The runtime tracks the open popup by key, so dropping the
@@ -213,7 +244,11 @@ function popupsToOffer(
213
244
  loaded: readonly PopupConfig[],
214
245
  previous: Offered,
215
246
  bookingVisible: boolean,
247
+ hiddenPage: (href: string) => boolean,
216
248
  ): Offered {
249
+ const hideBooking = !bookingVisible;
250
+ const withheld = (popup: PopupConfig) =>
251
+ (hideBooking && containsBookingTarget(popup)) || containsLinkTo(popup, hiddenPage);
217
252
  const held = new Map(previous.list.map((popup) => [popup.key, popup]));
218
253
  const content = new Map<string, string>();
219
254
  const list: PopupConfig[] = [];
@@ -224,10 +259,10 @@ function popupsToOffer(
224
259
  list.push(same !== undefined && previous.content.get(popup.key) === text ? same : popup);
225
260
  };
226
261
  for (const popup of loaded) {
227
- offer(bookingVisible || !containsBookingTarget(popup) ? popup : retire(popup, true));
262
+ offer(withheld(popup) ? retire(popup, hideBooking, hiddenPage) : popup);
228
263
  }
229
264
  for (const popup of previous.list) {
230
- if (!content.has(popup.key)) offer(retire(popup, !bookingVisible));
265
+ if (!content.has(popup.key)) offer(retire(popup, hideBooking, hiddenPage));
231
266
  }
232
267
  const unchanged =
233
268
  list.length === previous.list.length && list.every((popup, index) => popup === previous.list[index]);
@@ -237,43 +272,51 @@ function popupsToOffer(
237
272
  /**
238
273
  * A popup the runtime keeps but never offers again.
239
274
  *
240
- * Paused, which the runtime neither shows nor reports as suppressed. With
241
- * `hideBooking`, everything in it that leads to booking is taken out too —
242
- * links, buttons, an answer or a form that would open the booking site now
243
- * simply closes — so if it is the one on screen, the visitor keeps what they
244
- * were reading, cannot reach booking from it, and closes it the usual way.
245
- * The stored popup is not touched; this is the copy the visitor sees.
275
+ * Paused, which the runtime neither shows nor reports as suppressed. Every
276
+ * place in it that leads to booking (with `hideBooking`) or to a page hidden
277
+ * while booking is hidden (`hiddenPage`) is taken out too: links and
278
+ * buttons go, an answer or a form that would open it now simply closes. So if
279
+ * it is the one on screen, the visitor keeps what they were reading, cannot
280
+ * follow it anywhere it must not lead, and closes it the usual way. The
281
+ * stored popup is not touched; this is the copy the visitor sees.
246
282
  */
247
- function retire(popup: PopupConfig, hideBooking: boolean): PopupConfig {
283
+ function retire(
284
+ popup: PopupConfig,
285
+ hideBooking: boolean,
286
+ hiddenPage: (href: string) => boolean,
287
+ ): PopupConfig {
288
+ const blocked = (href: string) => (hideBooking && isBookingHref(href)) || hiddenPage(href);
248
289
  const paused: PopupConfig = { ...popup, status: "paused" };
249
- if (!hideBooking) return paused;
250
- return stripBookingDeep({
290
+ const cleaned: PopupConfig = {
251
291
  ...paused,
252
- steps: paused.steps.map((step) => ({ ...step, blocks: step.blocks.flatMap(withoutBookingTargets) })),
253
- });
292
+ steps: paused.steps.map((step) => ({
293
+ ...step,
294
+ blocks: step.blocks.flatMap((block) => withoutTargets(block, blocked)),
295
+ })),
296
+ };
297
+ // Booking links inside text are removed as before, and only for booking.
298
+ return hideBooking ? stripBookingDeep(cleaned) : cleaned;
254
299
  }
255
300
 
256
- function withoutBookingTargets(block: ContentBlock): ContentBlock[] {
301
+ function withoutTargets(block: ContentBlock, blocked: (href: string) => boolean): ContentBlock[] {
302
+ const opens = (action: BlockAction) => action.kind === "openUrl" && blocked(action.href);
303
+ const closingInstead = (button: PopupButton): PopupButton =>
304
+ opens(button.action) ? { ...button, action: { kind: "close" } } : button;
257
305
  switch (block.type) {
258
306
  case "link":
259
- return isBookingHref(block.href) ? [] : [block];
307
+ return blocked(block.href) ? [] : [block];
260
308
  case "buttons": {
261
- const buttons = block.buttons.filter((button) => !opensBooking(button.action));
309
+ const buttons = block.buttons.filter((button) => !opens(button.action));
262
310
  return buttons.length === 0 ? [] : [{ ...block, buttons }];
263
311
  }
264
312
  case "choice":
265
313
  return [{ ...block, yes: closingInstead(block.yes), no: closingInstead(block.no) }];
266
314
  case "form":
267
- return [opensBooking(block.onSuccess) ? { ...block, onSuccess: { kind: "close" } } : block];
315
+ return [opens(block.onSuccess) ? { ...block, onSuccess: { kind: "close" } } : block];
268
316
  default:
269
317
  return [block];
270
318
  }
271
319
  }
272
320
 
273
- const opensBooking = (action: BlockAction) => action.kind === "openUrl" && isBookingHref(action.href);
274
-
275
- const closingInstead = (button: PopupButton): PopupButton =>
276
- opensBooking(button.action) ? { ...button, action: { kind: "close" } } : button;
277
-
278
321
  // Default export so the module host can load this file lazily.
279
322
  export default PopupModuleRuntime;
@@ -11,6 +11,11 @@
11
11
  * | `page.listing.<page uuid>` | `{ "listing": "link-only" }` | listed |
12
12
  * | `booking` | `{ "enabled": false }` | booking on |
13
13
  * | `booking.exception.<uuid>` | `{ "exception": true }` | no exception |
14
+ * | `booking.landing.<uuid>` | `{ "landing": true, "notice"?: … }` | not a landing page |
15
+ * | `booking.landing.default` | `{ "notice": … }` | built-in copy |
16
+ *
17
+ * A landing page leaves the site's menus and page lists while booking is off
18
+ * and stays live and indexable (see `cms/booking-landing`).
14
19
  *
15
20
  * Keys use the page's id, never its key or address, so every alias and locale
16
21
  * of a page shares one decision, a restored page keeps its own, and a page
@@ -25,6 +30,14 @@
25
30
  * None of these rows is an `OVERRIDABLE_SETTING_KEYS` key, so the generic
26
31
  * settings save and reset cannot read, overwrite or erase them.
27
32
  */
33
+ import {
34
+ NoticeValidationError,
35
+ noticeLinkHrefs,
36
+ normalizeNoticeContent,
37
+ normalizePageNoticeOverride,
38
+ type NoticeContent,
39
+ type PageNoticeOverride,
40
+ } from "../cms/booking-landing";
28
41
  import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
29
42
  import {
30
43
  nextPublicationBoundary,
@@ -49,6 +62,8 @@ export const SITE_SETTINGS = "site_settings";
49
62
  export const PAGE_LISTING_PREFIX = "page.listing.";
50
63
  export const BOOKING_SETTING_KEY = "booking";
51
64
  export const BOOKING_EXCEPTION_PREFIX = "booking.exception.";
65
+ export const BOOKING_LANDING_PREFIX = "booking.landing.";
66
+ export const BOOKING_LANDING_DEFAULT_KEY = "booking.landing.default";
52
67
 
53
68
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
54
69
 
@@ -63,8 +78,28 @@ export type VisibilityDeps = {
63
78
  * by the browser.
64
79
  */
65
80
  protectedPageKeys: readonly string[];
81
+ /**
82
+ * The host's check of a booking-off notice link against its own routes:
83
+ * why the link may not be used (it leads to the host's booking page, or to a
84
+ * page hidden while booking is hidden), or null. Supplied by the host's
85
+ * server code. Without it only the platform's own rules apply (no booking
86
+ * site, safe address forms).
87
+ */
88
+ noticeTargetProblem?: (href: string) => string | null | Promise<string | null>;
66
89
  };
67
90
 
91
+ /** Refuse a notice whose link the host says leads to booking or to a hidden page. */
92
+ async function assertNoticeTargets(
93
+ deps: Pick<VisibilityDeps, "noticeTargetProblem">,
94
+ notice: NoticeContent | PageNoticeOverride,
95
+ ): Promise<void> {
96
+ if (!deps.noticeTargetProblem) return;
97
+ for (const href of noticeLinkHrefs(notice)) {
98
+ const problem = await deps.noticeTargetProblem(href);
99
+ if (problem) throw new NoticeValidationError(problem);
100
+ }
101
+ }
102
+
68
103
  /** Someone else changed the same decision since the editor last looked. */
69
104
  export class VisibilityConflictError extends Error {
70
105
  readonly code = "visibility_conflict";
@@ -96,10 +131,31 @@ export async function readVisibilitySettingsCore(client: any): Promise<Visibilit
96
131
  booking: { enabled: true, version: null },
97
132
  listings: new Map(),
98
133
  exceptions: new Map(),
134
+ landings: new Map(),
135
+ landingDefault: { notice: null, version: null },
99
136
  };
100
137
  for (const row of (data ?? []) as SettingRow[]) {
101
138
  const version = row.updated_at ?? null;
102
- if (row.key === BOOKING_SETTING_KEY) {
139
+ if (row.key === BOOKING_LANDING_DEFAULT_KEY) {
140
+ // Stored copy is read leniently: a field that no longer validates is
141
+ // left out rather than failing every public page.
142
+ const notice = record(row.value)["notice"];
143
+ settings.landingDefault = {
144
+ notice: notice == null ? null : normalizeNoticeContent(notice, false),
145
+ version,
146
+ };
147
+ } else if (row.key.startsWith(BOOKING_LANDING_PREFIX)) {
148
+ const pageId = row.key.slice(BOOKING_LANDING_PREFIX.length);
149
+ if (UUID.test(pageId)) {
150
+ const value = record(row.value);
151
+ settings.landings.set(pageId.toLowerCase(), {
152
+ // Only an explicit `true` makes a landing page.
153
+ landing: value["landing"] === true,
154
+ notice: normalizePageNoticeOverride(value["notice"], false),
155
+ version,
156
+ });
157
+ }
158
+ } else if (row.key === BOOKING_SETTING_KEY) {
103
159
  // Only an explicit `false` turns booking off; anything else is today's behaviour.
104
160
  settings.booking = { enabled: record(row.value)["enabled"] !== false, version };
105
161
  } else if (row.key.startsWith(PAGE_LISTING_PREFIX)) {
@@ -127,6 +183,8 @@ export type VisibilitySettings = {
127
183
  booking: { enabled: boolean; version: VersionToken };
128
184
  listings: Map<string, { listing: PageListing; version: VersionToken }>;
129
185
  exceptions: Map<string, { exception: boolean; version: VersionToken }>;
186
+ landings: Map<string, { landing: boolean; notice: PageNoticeOverride; version: VersionToken }>;
187
+ landingDefault: { notice: NoticeContent | null; version: VersionToken };
130
188
  };
131
189
 
132
190
  /**
@@ -304,7 +362,14 @@ export async function setBookingExceptionCore(
304
362
  if (!UUID.test(input.pageId)) throw new Error("Page not found");
305
363
  // Adding needs a page that exists; removing works even for one in the trash.
306
364
  let name = "this page";
307
- if (input.exception) name = (await readLivePage(context.supabase, input.pageId)).name;
365
+ if (input.exception) {
366
+ name = (await readLivePage(context.supabase, input.pageId)).name;
367
+ // A page cannot both keep its booking links and be a booking-off landing page.
368
+ const settings = await readVisibilitySettingsCore(context.supabase);
369
+ if (settings.landings.get(input.pageId.toLowerCase())?.landing === true) {
370
+ throw new BookingConflictError(name, "landing");
371
+ }
372
+ }
308
373
  invalidatePublishedCache();
309
374
  const version = await compareAndSwap(
310
375
  context.supabase,
@@ -329,6 +394,114 @@ export async function setBookingExceptionCore(
329
394
  return { exception: input.exception, version };
330
395
  }
331
396
 
397
+ /** Thrown when one page would both keep its booking links and be a booking-off landing page. */
398
+ export class BookingConflictError extends Error {
399
+ readonly code = "booking_conflict";
400
+ constructor(name: string, already: "exception" | "landing") {
401
+ super(
402
+ already === "exception"
403
+ ? `“${name}” keeps its booking links while booking is hidden, so it cannot also be hidden ` +
404
+ "from the website. Remove it from “Pages that keep their booking links” first."
405
+ : `“${name}” is hidden from the website while booking is hidden, so it cannot also keep ` +
406
+ "its booking links. Remove it from “Pages hidden from the website while booking is hidden” first.",
407
+ );
408
+ this.name = "BookingConflictError";
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Add a page to, or remove it from, the booking-off landing pages, or change
414
+ * its own notice.
415
+ *
416
+ * Adding needs a page that exists, is not protected (the start page and the
417
+ * contact page always stay in the menus) and does not keep its booking links.
418
+ * Removing works even for a page in the trash, and drops its own notice.
419
+ * Listing, publication, schedules and indexing are not touched: the page stays
420
+ * exactly as reachable and indexable as it was.
421
+ */
422
+ export async function setBookingLandingCore(
423
+ deps: VisibilityDeps,
424
+ input: {
425
+ pageId: string;
426
+ landing: boolean;
427
+ notice?: unknown;
428
+ expectedVersion: VersionToken;
429
+ },
430
+ ): Promise<{ landing: boolean; version: string }> {
431
+ const { context } = deps;
432
+ await assertStaff(context);
433
+ if (!UUID.test(input.pageId)) throw new Error("Page not found");
434
+ const pageId = input.pageId.toLowerCase();
435
+ let name = "this page";
436
+ let value: Record<string, unknown> = { landing: false };
437
+ if (input.landing) {
438
+ const page = await readLivePage(context.supabase, pageId);
439
+ name = page.name;
440
+ assertNotProtected(deps.protectedPageKeys, page, "hidden from the website while booking is hidden");
441
+ // Validated here as well as in the request schema: the core is the authority.
442
+ const notice = normalizePageNoticeOverride(input.notice ?? null, true);
443
+ await assertNoticeTargets(deps, notice);
444
+ const settings = await readVisibilitySettingsCore(context.supabase);
445
+ if (settings.exceptions.get(pageId)?.exception === true) {
446
+ throw new BookingConflictError(name, "exception");
447
+ }
448
+ value = Object.keys(notice).length > 0 ? { landing: true, notice } : { landing: true };
449
+ }
450
+ invalidatePublishedCache();
451
+ const version = await compareAndSwap(
452
+ context.supabase,
453
+ `${BOOKING_LANDING_PREFIX}${pageId}`,
454
+ value,
455
+ input.expectedVersion,
456
+ context.userId,
457
+ `whether ${input.landing ? `“${name}”` : name} is hidden while booking is hidden`,
458
+ );
459
+ invalidatePublishedCache();
460
+ await auditAs(context, {
461
+ action: "booking.landing",
462
+ entity: "pages",
463
+ entityId: pageId,
464
+ meta: {
465
+ landing: input.landing,
466
+ ownNotice: input.landing && "notice" in value,
467
+ description: input.landing
468
+ ? `Set "${name}" to leave the website's menus and page lists while booking is hidden; it stays reachable and indexable.`
469
+ : "Removed a page from the pages hidden from the website while booking is hidden.",
470
+ },
471
+ });
472
+ return { landing: input.landing, version };
473
+ }
474
+
475
+ /** Save the site-wide booking-off notice. */
476
+ export async function setBookingLandingDefaultCore(
477
+ deps: Pick<VisibilityDeps, "context" | "noticeTargetProblem">,
478
+ input: { notice: unknown; expectedVersion: VersionToken },
479
+ ): Promise<{ version: string }> {
480
+ const { context } = deps;
481
+ await assertStaff(context);
482
+ const notice = normalizeNoticeContent(input.notice, true);
483
+ await assertNoticeTargets(deps, notice);
484
+ invalidatePublishedCache();
485
+ const version = await compareAndSwap(
486
+ context.supabase,
487
+ BOOKING_LANDING_DEFAULT_KEY,
488
+ { notice },
489
+ input.expectedVersion,
490
+ context.userId,
491
+ "the booking-off notice",
492
+ );
493
+ invalidatePublishedCache();
494
+ await auditAs(context, {
495
+ action: "booking.landing.default",
496
+ entity: SITE_SETTINGS,
497
+ entityId: BOOKING_LANDING_DEFAULT_KEY,
498
+ meta: {
499
+ description: "Changed the notice booking-off landing pages show while booking is hidden.",
500
+ },
501
+ });
502
+ return { version };
503
+ }
504
+
332
505
  /** Booking setting and its exception pages, for the settings screen. */
333
506
  export type BookingSettingsView = {
334
507
  enabled: boolean;
@@ -346,6 +519,28 @@ export type BookingSettingsView = {
346
519
  * ones: adding a page back has to send the version of the row it left.
347
520
  */
348
521
  rowVersions: Record<string, VersionToken>;
522
+ /** Booking-off landing pages and their notice. */
523
+ landing: {
524
+ /** The stored default notice (empty fields use the built-in copy), and its version. */
525
+ default: { notice: NoticeContent | null; version: VersionToken };
526
+ pages: Array<{
527
+ pageId: string;
528
+ key: string | null;
529
+ name: string | null;
530
+ /** The page is in the trash or gone: the setting does nothing until restored. */
531
+ missing: boolean;
532
+ notice: PageNoticeOverride;
533
+ version: VersionToken;
534
+ /**
535
+ * Stored as a booking exception too. It is treated as an exception only
536
+ * — it keeps its booking links and stays in the menus — until somebody
537
+ * removes it from one of the lists.
538
+ */
539
+ conflict: boolean;
540
+ }>;
541
+ /** Versions of every stored landing row by page id, removed ones included. */
542
+ rowVersions: Record<string, VersionToken>;
543
+ };
349
544
  };
350
545
 
351
546
  export async function readBookingSettingsCore(
@@ -355,17 +550,40 @@ export async function readBookingSettingsCore(
355
550
  await assertStaff(context);
356
551
  const settings = await readVisibilitySettingsCore(context.supabase);
357
552
  const ids = [...settings.exceptions].filter(([, row]) => row.exception).map(([id]) => id);
553
+ const landingIds = [...settings.landings].filter(([, row]) => row.landing).map(([id]) => id);
358
554
  const pages = new Map<string, PageRow>();
359
- if (ids.length > 0) {
555
+ const wanted = [...new Set([...ids, ...landingIds])];
556
+ if (wanted.length > 0) {
360
557
  const { data, error } = await context.supabase
361
558
  .from("pages")
362
559
  .select("id,key,name")
363
- .in("id", ids)
560
+ .in("id", wanted)
364
561
  .is("deleted_at", null);
365
562
  if (error) throw new Error(error.message);
366
563
  for (const row of (data ?? []) as PageRow[]) pages.set(row.id.toLowerCase(), row);
367
564
  }
565
+ const byName = (a: { name: string | null }, b: { name: string | null }) =>
566
+ (a.name ?? "~").localeCompare(b.name ?? "~");
368
567
  return {
568
+ landing: {
569
+ default: settings.landingDefault,
570
+ pages: landingIds
571
+ .map((pageId) => {
572
+ const page = pages.get(pageId);
573
+ const row = settings.landings.get(pageId)!;
574
+ return {
575
+ pageId,
576
+ key: page?.key ?? null,
577
+ name: page?.name ?? null,
578
+ missing: !page,
579
+ notice: row.notice,
580
+ version: row.version,
581
+ conflict: settings.exceptions.get(pageId)?.exception === true,
582
+ };
583
+ })
584
+ .sort(byName),
585
+ rowVersions: Object.fromEntries([...settings.landings].map(([id, row]) => [id, row.version])),
586
+ },
369
587
  enabled: settings.booking.enabled,
370
588
  version: settings.booking.version,
371
589
  exceptions: ids
@@ -443,8 +661,16 @@ async function readPublicPolicy(client: any): Promise<PublicPolicySnapshot> {
443
661
  live: publicVersionId(byPage.get(row.id) ?? [], now) !== null,
444
662
  listing: settings.listings.get(id)?.listing ?? "listed",
445
663
  bookingException: settings.exceptions.get(id)?.exception === true,
664
+ bookingLanding: settings.landings.get(id)?.landing === true,
665
+ landingNotice: settings.landings.get(id)?.landing ? settings.landings.get(id)!.notice : null,
446
666
  };
447
667
  });
448
668
 
449
- return { known: true, bookingEnabled: settings.booking.enabled, pages, revalidateAt };
669
+ return {
670
+ known: true,
671
+ bookingEnabled: settings.booking.enabled,
672
+ pages,
673
+ revalidateAt,
674
+ landingDefault: settings.landingDefault.notice,
675
+ };
450
676
  }
@@ -11,6 +11,8 @@
11
11
  */
12
12
  import { z } from "zod";
13
13
 
14
+ import { NOTICE_LIMITS, parseNoticeCtaHref } from "../cms/booking-landing";
15
+
14
16
  /* ------------------------------------------------------------------ shared */
15
17
 
16
18
  export const uuid = z.string().uuid();
@@ -33,6 +35,37 @@ export const cmsKey = z
33
35
 
34
36
  export const listLimit = z.object({ limit: z.number().min(1).max(500).default(200) });
35
37
 
38
+ /* ------------------------------------------------------- booking-off notice */
39
+
40
+ /** Text per language, bounded. Content rules (limits, links) are re-checked by the core. */
41
+ const noticeText = (max: number) =>
42
+ z.record(z.string().regex(/^[a-z]{2}(-[a-z0-9]{2,8})?$/i), z.string().max(max)).refine(
43
+ (value) => Object.keys(value).length <= 8,
44
+ "Too many languages",
45
+ );
46
+
47
+ const noticeCtaSchema = z.object({
48
+ label: noticeText(NOTICE_LIMITS.ctaLabel),
49
+ href: z
50
+ .string()
51
+ .max(NOTICE_LIMITS.href)
52
+ .refine((href) => parseNoticeCtaHref(href).ok, {
53
+ message: "Use an address on this site (/…) or an https:// address that does not lead to booking.",
54
+ }),
55
+ });
56
+
57
+ export const noticeSchema = z.object({
58
+ heading: noticeText(NOTICE_LIMITS.heading).optional(),
59
+ body: noticeText(NOTICE_LIMITS.body).optional(),
60
+ cta: noticeCtaSchema.nullable().optional(),
61
+ });
62
+
63
+ export const noticeOverrideSchema = z.object({
64
+ heading: noticeText(NOTICE_LIMITS.heading).optional(),
65
+ body: noticeText(NOTICE_LIMITS.body).optional(),
66
+ cta: z.union([noticeCtaSchema, z.literal("none")]).optional(),
67
+ });
68
+
36
69
  /* ---------------------------------------------------------------- edit locks */
37
70
 
38
71
  /**
@@ -148,6 +181,18 @@ export const pageSchemas = {
148
181
  exception: z.boolean(),
149
182
  expectedVersion: z.string().min(1).max(64).nullable(),
150
183
  }),
184
+ /** A booking-off landing page, added, removed, or given its own notice. */
185
+ setBookingLanding: z.object({
186
+ pageId: uuid,
187
+ landing: z.boolean(),
188
+ notice: noticeOverrideSchema.nullable().optional(),
189
+ expectedVersion: z.string().min(1).max(64).nullable(),
190
+ }),
191
+ /** The site-wide booking-off notice. */
192
+ setBookingLandingDefault: z.object({
193
+ notice: noticeSchema,
194
+ expectedVersion: z.string().min(1).max(64).nullable(),
195
+ }),
151
196
  duplicatePage: z.object({
152
197
  pageId: uuid,
153
198
  name: z.string().min(1).max(120),
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The notice a booking-off landing page shows while booking is hidden.
3
+ *
4
+ * The page keeps its own content, title, H1 and address; this only says,
5
+ * honestly, that booking is not offered here right now, and offers an
6
+ * optional link somewhere that is not booking. A titled region with an `h2`,
7
+ * so it is found by heading and landmark navigation without interrupting a
8
+ * screen reader the way an alert would. Plain text only: the message is
9
+ * rendered as text with its line breaks, never as markup.
10
+ *
11
+ * Placed after a leading hero, or first. First, it can follow the fixed
12
+ * header directly (a route with no top padding and no hero): it then keeps
13
+ * the same offset the site's padded routes use, instead of sitting under the
14
+ * header. Anywhere else the rule does not match.
15
+ *
16
+ * Its link is offered only if the current policy allows it: never to booking
17
+ * (a booking site, or the host's own booking page in any spelling) and never
18
+ * to a page hidden while booking is hidden. The host's server already drops
19
+ * such a link before the notice is sent; this is the same rule where it is
20
+ * rendered, so a policy that changed since cannot bring one back.
21
+ */
22
+ import { useId } from "react";
23
+
24
+ import { useT } from "../i18n";
25
+ import type { ResolvedNotice } from "../cms/booking-landing";
26
+ import { isNoticeLinkAllowed } from "../navigation/visibility";
27
+ import { useLinkVisibility } from "./link-visibility";
28
+
29
+ export function BookingOffNotice({ notice }: { notice: ResolvedNotice | null | undefined }) {
30
+ const { t } = useT();
31
+ const id = useId();
32
+ const policy = useLinkVisibility();
33
+ if (!notice) return null;
34
+ const heading = t(notice.heading);
35
+ const body = t(notice.body);
36
+ const cta = notice.cta && isNoticeLinkAllowed(notice.cta.href, policy) ? notice.cta : null;
37
+ const label = cta ? t(cta.label) : "";
38
+ if (!heading && !body) return null;
39
+ const external = cta ? /^https:\/\//i.test(cta.href) : false;
40
+ return (
41
+ <section
42
+ aria-labelledby={heading ? `${id}-title` : undefined}
43
+ aria-label={heading ? undefined : body}
44
+ data-booking-off-notice=""
45
+ className="container-editorial py-8 md:py-10 [header+&]:pt-24 md:[header+&]:pt-32"
46
+ >
47
+ <div className="max-w-3xl border-l-4 border-ink/70 bg-paper px-5 py-5 md:px-7 md:py-6">
48
+ {heading ? (
49
+ <h2
50
+ id={`${id}-title`}
51
+ className="font-serif text-[clamp(1.4rem,2.6vw,2rem)] leading-[1.15] text-ink [overflow-wrap:anywhere]"
52
+ >
53
+ {heading}
54
+ </h2>
55
+ ) : null}
56
+ {body ? (
57
+ <p className="mt-3 whitespace-pre-line text-base leading-relaxed text-ink/80 [overflow-wrap:anywhere]">
58
+ {body}
59
+ </p>
60
+ ) : null}
61
+ {cta && label ? (
62
+ <a
63
+ href={cta.href}
64
+ {...(external ? { rel: "noopener" } : {})}
65
+ className="mt-5 inline-flex min-h-11 max-w-full items-center justify-center bg-ink px-6 py-3 text-center text-xs uppercase tracking-[0.22em] text-paper hover:bg-ink/85 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink [overflow-wrap:anywhere]"
66
+ >
67
+ {label}
68
+ </a>
69
+ ) : null}
70
+ </div>
71
+ </section>
72
+ );
73
+ }