@bygghammar/cms-platform 0.95.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/docs/PAGES.md +45 -2
  3. package/package.json +3 -1
  4. package/src/admin/describe-log.ts +2 -0
  5. package/src/backoffice/BookingLandingSection.tsx +672 -0
  6. package/src/backoffice/BookingSettingsCard.tsx +34 -4
  7. package/src/backoffice/PageEditorView.tsx +47 -24
  8. package/src/backoffice/routes/host.ts +7 -0
  9. package/src/backoffice/routes/index.tsx +22 -3
  10. package/src/blocks/components/CountdownBlock.tsx +3 -1
  11. package/src/blocks/components/CtaBannerBlock.tsx +8 -2
  12. package/src/blocks/components/HeroFullBlock.tsx +7 -1
  13. package/src/blocks/components/IconGridBlock.tsx +5 -1
  14. package/src/blocks/components/InstagramBlock.tsx +5 -1
  15. package/src/blocks/components/InstagramTiles.tsx +3 -1
  16. package/src/blocks/components/LinkTilesBlock.tsx +8 -1
  17. package/src/blocks/components/MediaSplitBlock.tsx +8 -3
  18. package/src/blocks/components/PriceTableBlock.tsx +6 -5
  19. package/src/blocks/components/TripListBlock.tsx +5 -1
  20. package/src/cms/booking-landing.ts +359 -0
  21. package/src/cms/booking-links.ts +24 -0
  22. package/src/cms/page-visibility.ts +67 -1
  23. package/src/forms/form-audit.ts +22 -2
  24. package/src/html/html-tokenizer.ts +254 -0
  25. package/src/html/sanitize-html.ts +71 -36
  26. package/src/navigation/visibility.ts +65 -3
  27. package/src/popups/runtime.tsx +75 -32
  28. package/src/server/forms-core.ts +9 -0
  29. package/src/server/forms-delivery-core.ts +29 -22
  30. package/src/server/forms-submissions-core.ts +8 -5
  31. package/src/server/media-usage-core.ts +53 -13
  32. package/src/server/outbound-address.ts +131 -0
  33. package/src/server/outbound-http.ts +565 -0
  34. package/src/server/page-visibility-core.ts +231 -5
  35. package/src/server/popup-optin-webhook-core.ts +79 -140
  36. package/src/server/schemas.ts +45 -0
  37. package/src/site/BookingOffNotice.tsx +73 -0
  38. package/src/site/PageRenderer.tsx +59 -3
  39. package/src/site/link-visibility.tsx +21 -2
  40. package/src/site/preview-messaging.ts +232 -0
  41. package/src/site/routes/preview-page.tsx +37 -29
  42. package/src/util/redact-url.ts +18 -0
@@ -0,0 +1,672 @@
1
+ /**
2
+ * Site settings → Booking → Pages hidden from the website while booking is
3
+ * hidden.
4
+ *
5
+ * The distinction the screen has to make clear: these pages leave the
6
+ * website's menus and page lists while booking is hidden, but they are not
7
+ * taken offline and not hidden from search engines — someone arriving from
8
+ * Google still gets the page, without its booking links and with the notice
9
+ * set here. Everything is checked again on the server; the screen only avoids
10
+ * offering what the server would refuse.
11
+ */
12
+ import { useEffect, useId, useRef, useState, type ReactNode, type Ref } from "react";
13
+ import { toast } from "sonner";
14
+ import { Pencil, Plus, Trash2 } from "lucide-react";
15
+
16
+ import {
17
+ BUILT_IN_NOTICE,
18
+ NOTICE_LIMITS,
19
+ ctaHrefProblemMessage,
20
+ overriddenFields,
21
+ parseNoticeCtaHref,
22
+ type NoticeContent,
23
+ type PageNoticeOverride,
24
+ } from "../cms/booking-landing";
25
+ import type { LocalizedString } from "../i18n/types";
26
+
27
+ export type BookingLandingData = {
28
+ default: { notice: NoticeContent | null; version: string | null };
29
+ pages: Array<{
30
+ pageId: string;
31
+ key: string | null;
32
+ name: string | null;
33
+ missing: boolean;
34
+ notice: PageNoticeOverride;
35
+ version: string | null;
36
+ conflict: boolean;
37
+ }>;
38
+ rowVersions?: Record<string, string | null>;
39
+ };
40
+
41
+ export type BookingLandingDeps = {
42
+ data: BookingLandingData;
43
+ bookingEnabled: boolean;
44
+ /** Every page; the add list leaves out the ones the server would refuse. */
45
+ pages: Array<{ id: string; key: string; name: string; protected?: boolean; listing?: string }> | undefined;
46
+ /** Page ids that keep their booking links: they cannot be added here. */
47
+ exceptionIds: ReadonlySet<string>;
48
+ pathFor: (key: string) => string;
49
+ locales: readonly string[];
50
+ setLanding: (input: {
51
+ pageId: string;
52
+ landing: boolean;
53
+ notice?: PageNoticeOverride | null;
54
+ expectedVersion: string | null;
55
+ }) => Promise<unknown>;
56
+ setLandingDefault: (input: { notice: NoticeContent; expectedVersion: string | null }) => Promise<unknown>;
57
+ refresh: () => void;
58
+ };
59
+
60
+ const LOCALE_NAMES: Record<string, string> = { sv: "Swedish", en: "English" };
61
+ const localeName = (locale: string) => LOCALE_NAMES[locale] ?? locale.toUpperCase();
62
+
63
+ const inputClass =
64
+ "block w-full min-w-0 border border-ink/15 bg-white px-3 py-2 text-sm text-ink aria-[invalid=true]:border-red-600";
65
+ const labelClass = "mb-1 block text-xs uppercase tracking-wider text-ink/60";
66
+
67
+ /** Form state: every text per locale as typed, plus the link. */
68
+ type Draft = {
69
+ heading: Record<string, string>;
70
+ body: Record<string, string>;
71
+ ctaLabel: Record<string, string>;
72
+ ctaHref: string;
73
+ /** Page form only: use the default link, an own link, or none. */
74
+ ctaMode: "default" | "own" | "none";
75
+ };
76
+
77
+ const toRecord = (value: LocalizedString | undefined | null, locales: readonly string[]) =>
78
+ Object.fromEntries(locales.map((locale) => [locale, value?.[locale as keyof LocalizedString] ?? ""]));
79
+
80
+ function draftFrom(
81
+ value: { heading?: LocalizedString; body?: LocalizedString; cta?: NoticeContent["cta"] | "none" } | null,
82
+ locales: readonly string[],
83
+ page: boolean,
84
+ ): Draft {
85
+ const cta = value?.cta && value.cta !== "none" ? value.cta : null;
86
+ return {
87
+ heading: toRecord(value?.heading, locales),
88
+ body: toRecord(value?.body, locales),
89
+ ctaLabel: toRecord(cta?.label, locales),
90
+ ctaHref: cta?.href ?? "",
91
+ ctaMode: !page ? (cta ? "own" : "none") : value?.cta === "none" ? "none" : cta ? "own" : "default",
92
+ };
93
+ }
94
+
95
+ const cleaned = (texts: Record<string, string>): LocalizedString | undefined => {
96
+ const out = Object.fromEntries(
97
+ Object.entries(texts)
98
+ .map(([locale, text]) => [locale, text.trim()] as const)
99
+ .filter(([, text]) => text !== ""),
100
+ );
101
+ return Object.keys(out).length > 0 ? (out as LocalizedString) : undefined;
102
+ };
103
+
104
+ type Problems = Partial<Record<string, string>>;
105
+
106
+ /** The same rules the server applies, said next to the field. */
107
+ function check(draft: Draft, locales: readonly string[], linkWanted: boolean): Problems {
108
+ const problems: Problems = {};
109
+ for (const locale of locales) {
110
+ if ((draft.heading[locale] ?? "").trim().length > NOTICE_LIMITS.heading) {
111
+ problems[`heading.${locale}`] = `At most ${NOTICE_LIMITS.heading} characters.`;
112
+ }
113
+ if ((draft.body[locale] ?? "").trim().length > NOTICE_LIMITS.body) {
114
+ problems[`body.${locale}`] = `At most ${NOTICE_LIMITS.body} characters.`;
115
+ }
116
+ if ((draft.ctaLabel[locale] ?? "").trim().length > NOTICE_LIMITS.ctaLabel) {
117
+ problems[`ctaLabel.${locale}`] = `At most ${NOTICE_LIMITS.ctaLabel} characters.`;
118
+ }
119
+ }
120
+ if (linkWanted) {
121
+ const href = parseNoticeCtaHref(draft.ctaHref);
122
+ if (!href.ok) problems["ctaHref"] = ctaHrefProblemMessage(href.reason);
123
+ if (!cleaned(draft.ctaLabel)) problems["ctaLabel"] = "Write the link's text in at least one language.";
124
+ }
125
+ return problems;
126
+ }
127
+
128
+ /** The fields of one notice: default or one page's own. */
129
+ function NoticeFields({
130
+ draft,
131
+ setDraft,
132
+ problems,
133
+ locales,
134
+ page,
135
+ disabled,
136
+ firstFieldRef,
137
+ }: {
138
+ draft: Draft;
139
+ setDraft: (next: Draft) => void;
140
+ problems: Problems;
141
+ locales: readonly string[];
142
+ page: boolean;
143
+ disabled: boolean;
144
+ firstFieldRef?: Ref<HTMLInputElement>;
145
+ }): ReactNode {
146
+ const id = useId();
147
+ const field = (name: string) => `${id}-${name}`;
148
+ const error = (name: string) =>
149
+ problems[name] ? (
150
+ <span id={field(`${name}-error`)} className="mt-1 block text-xs text-red-700">
151
+ {problems[name]}
152
+ </span>
153
+ ) : null;
154
+ const a11y = (name: string) => ({
155
+ "aria-invalid": problems[name] ? true : undefined,
156
+ "aria-describedby": problems[name] ? field(`${name}-error`) : undefined,
157
+ });
158
+ const setText = (part: "heading" | "body" | "ctaLabel", locale: string, text: string) =>
159
+ setDraft({ ...draft, [part]: { ...draft[part], [locale]: text } });
160
+ const linkShown = draft.ctaMode === "own";
161
+
162
+ return (
163
+ <div className="grid gap-4">
164
+ <div className="grid gap-4 sm:grid-cols-2">
165
+ {locales.map((locale, i) => (
166
+ <div key={locale} className="min-w-0">
167
+ <label className="block" htmlFor={field(`heading-${locale}`)}>
168
+ <span className={labelClass}>Heading — {localeName(locale)}</span>
169
+ </label>
170
+ <input
171
+ id={field(`heading-${locale}`)}
172
+ ref={i === 0 ? firstFieldRef : undefined}
173
+ type="text"
174
+ lang={locale}
175
+ disabled={disabled}
176
+ maxLength={NOTICE_LIMITS.heading + 40}
177
+ value={draft.heading[locale] ?? ""}
178
+ placeholder={page ? "Use the default heading" : (BUILT_IN_NOTICE.heading[locale as "sv"] ?? "")}
179
+ onChange={(e) => setText("heading", locale, e.target.value)}
180
+ className={inputClass}
181
+ {...a11y(`heading.${locale}`)}
182
+ />
183
+ {error(`heading.${locale}`)}
184
+ <label className="mt-3 block" htmlFor={field(`body-${locale}`)}>
185
+ <span className={labelClass}>Message — {localeName(locale)}</span>
186
+ </label>
187
+ <textarea
188
+ id={field(`body-${locale}`)}
189
+ lang={locale}
190
+ rows={3}
191
+ disabled={disabled}
192
+ maxLength={NOTICE_LIMITS.body + 200}
193
+ value={draft.body[locale] ?? ""}
194
+ placeholder={page ? "Use the default message" : (BUILT_IN_NOTICE.body[locale as "sv"] ?? "")}
195
+ onChange={(e) => setText("body", locale, e.target.value)}
196
+ className={inputClass}
197
+ {...a11y(`body.${locale}`)}
198
+ />
199
+ {error(`body.${locale}`)}
200
+ </div>
201
+ ))}
202
+ </div>
203
+
204
+ <fieldset className="min-w-0 border border-ink/10 p-4">
205
+ <legend className="px-1 text-xs uppercase tracking-wider text-ink/60">Link (optional)</legend>
206
+ <p className="text-xs text-ink/60">
207
+ For example a contact page or an interest form — never booking.
208
+ </p>
209
+ <div className="mt-3 flex flex-wrap gap-x-5 gap-y-2 text-sm text-ink">
210
+ {(page ? (["default", "own", "none"] as const) : (["none", "own"] as const)).map((mode) => (
211
+ <label key={mode} className="inline-flex items-center gap-2">
212
+ <input
213
+ type="radio"
214
+ name={field("cta-mode")}
215
+ value={mode}
216
+ disabled={disabled}
217
+ checked={draft.ctaMode === mode}
218
+ onChange={() => setDraft({ ...draft, ctaMode: mode })}
219
+ />
220
+ {mode === "default" ? "Use the default link" : mode === "own" ? (page ? "Own link" : "Show a link") : "No link"}
221
+ </label>
222
+ ))}
223
+ </div>
224
+ {linkShown ? (
225
+ <div className="mt-4 grid gap-4 sm:grid-cols-2">
226
+ {locales.map((locale) => (
227
+ <div key={locale} className="min-w-0">
228
+ <label className="block" htmlFor={field(`cta-${locale}`)}>
229
+ <span className={labelClass}>Link text — {localeName(locale)}</span>
230
+ </label>
231
+ <input
232
+ id={field(`cta-${locale}`)}
233
+ type="text"
234
+ lang={locale}
235
+ disabled={disabled}
236
+ maxLength={NOTICE_LIMITS.ctaLabel + 20}
237
+ value={draft.ctaLabel[locale] ?? ""}
238
+ onChange={(e) => setText("ctaLabel", locale, e.target.value)}
239
+ className={inputClass}
240
+ {...a11y(`ctaLabel.${locale}`)}
241
+ />
242
+ {error(`ctaLabel.${locale}`)}
243
+ </div>
244
+ ))}
245
+ {problems["ctaLabel"] ? (
246
+ <p className="text-xs text-red-700 sm:col-span-2" id={field("ctaLabel-error")}>
247
+ {problems["ctaLabel"]}
248
+ </p>
249
+ ) : null}
250
+ <div className="min-w-0 sm:col-span-2">
251
+ <label className="block" htmlFor={field("cta-href")}>
252
+ <span className={labelClass}>Link address</span>
253
+ </label>
254
+ <input
255
+ id={field("cta-href")}
256
+ type="text"
257
+ inputMode="url"
258
+ autoComplete="off"
259
+ spellCheck={false}
260
+ disabled={disabled}
261
+ maxLength={NOTICE_LIMITS.href + 1}
262
+ value={draft.ctaHref}
263
+ placeholder="/kontakt or https://…"
264
+ onChange={(e) => setDraft({ ...draft, ctaHref: e.target.value })}
265
+ className={`${inputClass} font-mono`}
266
+ {...a11y("ctaHref")}
267
+ />
268
+ {error("ctaHref")}
269
+ </div>
270
+ </div>
271
+ ) : null}
272
+ </fieldset>
273
+ </div>
274
+ );
275
+ }
276
+
277
+ /** One notice form with its own save. */
278
+ function NoticeForm({
279
+ initial,
280
+ locales,
281
+ page,
282
+ busy,
283
+ saveLabel,
284
+ onSave,
285
+ onCancel,
286
+ focusOnOpen,
287
+ }: {
288
+ initial: Draft;
289
+ locales: readonly string[];
290
+ page: boolean;
291
+ busy: boolean;
292
+ saveLabel: string;
293
+ onSave: (draft: Draft) => Promise<boolean>;
294
+ onCancel?: () => void;
295
+ focusOnOpen?: boolean;
296
+ }): ReactNode {
297
+ const [draft, setDraft] = useState(initial);
298
+ const [problems, setProblems] = useState<Problems>({});
299
+ const firstField = useRef<HTMLInputElement>(null);
300
+ useEffect(() => {
301
+ if (focusOnOpen) firstField.current?.focus();
302
+ }, [focusOnOpen]);
303
+ const count = Object.keys(problems).length;
304
+ return (
305
+ <form
306
+ noValidate
307
+ className="grid gap-4"
308
+ onSubmit={(e) => {
309
+ e.preventDefault();
310
+ const found = check(draft, locales, draft.ctaMode === "own");
311
+ setProblems(found);
312
+ if (Object.keys(found).length > 0) return;
313
+ void onSave(draft);
314
+ }}
315
+ >
316
+ {count > 0 ? (
317
+ <p role="alert" className="text-sm text-red-700">
318
+ Nothing was saved: {count === 1 ? "one field needs" : `${count} fields need`} attention.
319
+ </p>
320
+ ) : null}
321
+ <NoticeFields
322
+ draft={draft}
323
+ setDraft={setDraft}
324
+ problems={problems}
325
+ locales={locales}
326
+ page={page}
327
+ disabled={busy}
328
+ firstFieldRef={firstField}
329
+ />
330
+ <div className="flex flex-wrap gap-3">
331
+ <button
332
+ type="submit"
333
+ disabled={busy}
334
+ className="inline-flex items-center gap-2 bg-ink px-4 py-2 text-xs uppercase tracking-[0.18em] text-paper hover:bg-ink/85 disabled:opacity-40"
335
+ >
336
+ {saveLabel}
337
+ </button>
338
+ {onCancel ? (
339
+ <button
340
+ type="button"
341
+ disabled={busy}
342
+ onClick={onCancel}
343
+ className="inline-flex items-center gap-2 border border-ink/20 px-4 py-2 text-xs uppercase tracking-[0.18em] hover:border-ink disabled:opacity-40"
344
+ >
345
+ Cancel
346
+ </button>
347
+ ) : null}
348
+ </div>
349
+ </form>
350
+ );
351
+ }
352
+
353
+ function behaviourOf(row: BookingLandingData["pages"][number]): string {
354
+ const own = overriddenFields(row.notice);
355
+ return own.length === 0 ? "Shows the default message" : `Own ${own.join(", ")}`;
356
+ }
357
+
358
+ export function BookingLandingSection({ deps }: { deps: BookingLandingDeps }): ReactNode {
359
+ const { data, pages, pathFor, locales } = deps;
360
+ const [busy, setBusy] = useState(false);
361
+ const [adding, setAdding] = useState("");
362
+ const [editing, setEditing] = useState<string | null>(null);
363
+ const [status, setStatus] = useState("");
364
+ /**
365
+ * The version a form was saving against when its save failed, by page id
366
+ * (and `"default"`). When the refresh then brings a different version,
367
+ * somebody else saved in between: the form is rebuilt from what is stored
368
+ * now (it is keyed by the version), so the old draft can never be sent
369
+ * against the new version unseen, and the editor is told why.
370
+ */
371
+ const [failedAt, setFailedAt] = useState<Record<string, string | null>>({});
372
+ const editButtons = useRef(new Map<string, HTMLButtonElement | null>());
373
+ const changedSince = (id: string, version: string | null) =>
374
+ id in failedAt && failedAt[id] !== version;
375
+ const remember = (id: string, version: string | null | undefined) =>
376
+ setFailedAt((all) => {
377
+ const next = { ...all };
378
+ if (version === undefined) delete next[id];
379
+ else next[id] = version;
380
+ return next;
381
+ });
382
+
383
+ const run = async (action: () => Promise<unknown>, done: string): Promise<boolean> => {
384
+ setBusy(true);
385
+ try {
386
+ await action();
387
+ toast.success(done);
388
+ setStatus(done);
389
+ return true;
390
+ } catch (e) {
391
+ const message = e instanceof Error ? e.message : "Failed to save";
392
+ toast.error(message);
393
+ setStatus(`Not saved: ${message}`);
394
+ return false;
395
+ } finally {
396
+ setBusy(false);
397
+ // Success or conflict alike: show what is stored now.
398
+ deps.refresh();
399
+ }
400
+ };
401
+
402
+ const closeEditor = (pageId: string) => {
403
+ setEditing(null);
404
+ remember(pageId, undefined);
405
+ // Give focus back to the button that opened the editor.
406
+ queueMicrotask(() => editButtons.current.get(pageId)?.focus());
407
+ };
408
+
409
+ const selected = new Set(data.pages.map((row) => row.pageId));
410
+ const candidates = (pages ?? [])
411
+ .filter((page) => {
412
+ const id = page.id.toLowerCase();
413
+ return !selected.has(id) && !deps.exceptionIds.has(id) && !page.protected;
414
+ })
415
+ .sort((a, b) => a.name.localeCompare(b.name));
416
+ const conflicts = data.pages.filter((row) => row.conflict);
417
+
418
+ const pageNotice = (draft: Draft): PageNoticeOverride => {
419
+ const out: PageNoticeOverride = {};
420
+ const heading = cleaned(draft.heading);
421
+ const body = cleaned(draft.body);
422
+ if (heading) out.heading = heading;
423
+ if (body) out.body = body;
424
+ if (draft.ctaMode === "none") out.cta = "none";
425
+ else if (draft.ctaMode === "own") out.cta = { label: cleaned(draft.ctaLabel) ?? {}, href: draft.ctaHref.trim() };
426
+ return out;
427
+ };
428
+
429
+ return (
430
+ <div className="mt-8 border-t border-ink/10 pt-5" role="group" aria-labelledby="booking-landing-title">
431
+ <h3 id="booking-landing-title" className="text-xs uppercase tracking-wider text-ink/60">
432
+ Pages hidden from the website while booking is hidden
433
+ </h3>
434
+ <div className="mt-2 max-w-2xl space-y-2 text-sm text-ink/60">
435
+ <p>
436
+ While booking is hidden, these pages are removed from the website’s menus, footer, page
437
+ cards, buttons and popups. Their addresses stay live, indexable and in the sitemap, so
438
+ visitors arriving from a search result can still read them.
439
+ </p>
440
+ <p>
441
+ They remain eligible to appear in search results, but rankings are not guaranteed: removing
442
+ links to them from the website can affect how search engines value them.
443
+ </p>
444
+ <p>
445
+ On the page itself the booking links are removed and the message below is shown instead.
446
+ Links written into a page’s own text are left as they are. When booking is shown again,
447
+ everything returns and the message disappears.
448
+ </p>
449
+ </div>
450
+ <p className="mt-3 text-sm text-ink">
451
+ {deps.bookingEnabled
452
+ ? "Booking is shown right now, so these pages are listed as usual."
453
+ : "Booking is hidden right now, so these pages are hidden from the menus and show the message."}
454
+ </p>
455
+
456
+ {conflicts.length > 0 ? (
457
+ <div role="alert" className="mt-4 border border-amber-400 bg-amber-50 px-4 py-3 text-sm text-ink">
458
+ <p className="font-medium">Some pages are in both lists.</p>
459
+ <p className="mt-1">
460
+ {conflicts.map((row) => row.name ?? "A page").join(", ")}{" "}
461
+ {conflicts.length === 1 ? "is" : "are"} also set to keep booking links. Until removed from
462
+ one list, {conflicts.length === 1 ? "it keeps" : "they keep"} the booking links and{" "}
463
+ {conflicts.length === 1 ? "stays" : "stay"} in the menus, and no message is shown.
464
+ </p>
465
+ </div>
466
+ ) : null}
467
+
468
+ <ul className="mt-4 divide-y divide-ink/10 border border-ink/10">
469
+ {data.pages.length === 0 ? (
470
+ <li className="px-4 py-3 text-sm text-ink/50">No pages.</li>
471
+ ) : (
472
+ data.pages.map((row) => {
473
+ const label = row.name ?? "Page no longer exists";
474
+ const isEditing = editing === row.pageId;
475
+ const panelId = `booking-landing-edit-${row.pageId}`;
476
+ return (
477
+ <li key={row.pageId} className="px-4 py-3 text-sm">
478
+ <div className="flex flex-wrap items-start justify-between gap-3">
479
+ <span className="min-w-0">
480
+ <span className="text-ink">{label}</span>
481
+ {row.key ? (
482
+ <span className="ml-2 break-all font-mono text-xs text-ink/50">{pathFor(row.key)}</span>
483
+ ) : null}
484
+ <span className="mt-1 block text-xs text-ink/60">
485
+ {row.conflict
486
+ ? "Not hidden: it also keeps its booking links (see the warning above)."
487
+ : behaviourOf(row)}
488
+ {row.missing ? " · In the trash or deleted — has no effect." : ""}
489
+ </span>
490
+ </span>
491
+ <span className="flex flex-wrap items-center gap-4">
492
+ {!row.missing ? (
493
+ <button
494
+ type="button"
495
+ ref={(node) => {
496
+ editButtons.current.set(row.pageId, node);
497
+ }}
498
+ disabled={busy}
499
+ aria-expanded={isEditing}
500
+ aria-controls={panelId}
501
+ onClick={() => (isEditing ? closeEditor(row.pageId) : setEditing(row.pageId))}
502
+ className="inline-flex items-center gap-1 text-xs text-ink hover:underline disabled:opacity-40"
503
+ >
504
+ <Pencil className="size-3.5" aria-hidden="true" /> Edit message
505
+ <span className="sr-only"> for {label}</span>
506
+ </button>
507
+ ) : null}
508
+ <button
509
+ type="button"
510
+ disabled={busy}
511
+ onClick={() =>
512
+ void run(
513
+ () =>
514
+ deps.setLanding({
515
+ pageId: row.pageId,
516
+ landing: false,
517
+ expectedVersion: row.version,
518
+ }),
519
+ `${row.name ?? "The page"} is no longer hidden while booking is hidden`,
520
+ ).then((ok) => {
521
+ if (ok && isEditing) setEditing(null);
522
+ })
523
+ }
524
+ className="inline-flex items-center gap-1 text-xs text-red-700 hover:underline disabled:opacity-40"
525
+ >
526
+ <Trash2 className="size-3.5" aria-hidden="true" /> Remove
527
+ <span className="sr-only"> {label} from the list</span>
528
+ </button>
529
+ </span>
530
+ </div>
531
+ {isEditing ? (
532
+ <div
533
+ id={panelId}
534
+ role="region"
535
+ aria-label={`Message for ${label}`}
536
+ className="mt-4 border-t border-ink/10 pt-4"
537
+ >
538
+ {changedSince(row.pageId, row.version) ? (
539
+ <p role="alert" className="mb-3 border border-amber-400 bg-amber-50 px-3 py-2 text-sm text-ink">
540
+ This message was changed by someone else while you were editing. Your changes
541
+ were not saved; their version is shown below. Review it and edit again if needed.
542
+ </p>
543
+ ) : null}
544
+ <p className="mb-3 text-xs text-ink/60">
545
+ Leave a field empty to use the default message for it.
546
+ </p>
547
+ <NoticeForm
548
+ key={row.version ?? "none"}
549
+ initial={draftFrom(row.notice, locales, true)}
550
+ locales={locales}
551
+ page
552
+ busy={busy}
553
+ focusOnOpen
554
+ saveLabel="Save page message"
555
+ onCancel={() => closeEditor(row.pageId)}
556
+ onSave={async (draft) => {
557
+ const ok = await run(
558
+ () =>
559
+ deps.setLanding({
560
+ pageId: row.pageId,
561
+ landing: true,
562
+ notice: pageNotice(draft),
563
+ expectedVersion: row.version,
564
+ }),
565
+ `Saved the message for ${label}`,
566
+ );
567
+ if (ok) closeEditor(row.pageId);
568
+ else remember(row.pageId, row.version);
569
+ return ok;
570
+ }}
571
+ />
572
+ </div>
573
+ ) : null}
574
+ </li>
575
+ );
576
+ })
577
+ )}
578
+ </ul>
579
+
580
+ <div className="mt-4 flex flex-wrap items-end gap-3">
581
+ <label className="block min-w-0 flex-1 sm:max-w-sm">
582
+ <span className={labelClass}>Add a page</span>
583
+ <select
584
+ value={adding}
585
+ onChange={(e) => setAdding(e.target.value)}
586
+ disabled={busy || !pages}
587
+ className="w-full border border-ink/15 bg-white px-3 py-2 text-sm text-ink"
588
+ >
589
+ <option value="">Choose a page…</option>
590
+ {candidates.map((page) => (
591
+ <option key={page.id} value={page.id}>
592
+ {page.name} — {pathFor(page.key)}
593
+ </option>
594
+ ))}
595
+ </select>
596
+ </label>
597
+ <button
598
+ type="button"
599
+ disabled={busy || !adding}
600
+ onClick={() => {
601
+ const page = candidates.find((p) => p.id === adding);
602
+ // A page removed earlier leaves its row behind; send its version.
603
+ const previous = data.rowVersions?.[adding.toLowerCase()] ?? null;
604
+ void run(
605
+ () => deps.setLanding({ pageId: adding, landing: true, expectedVersion: previous }),
606
+ `${page?.name ?? "The page"} is hidden from the website while booking is hidden`,
607
+ ).then((ok) => {
608
+ if (ok) setAdding("");
609
+ });
610
+ }}
611
+ className="inline-flex items-center gap-2 border border-ink/20 px-4 py-2 text-xs uppercase tracking-[0.18em] hover:border-ink disabled:opacity-40"
612
+ >
613
+ <Plus className="size-4" aria-hidden="true" /> Add
614
+ </button>
615
+ </div>
616
+ <p className="mt-2 max-w-2xl text-xs text-ink/50">
617
+ The start page and the contact page always stay in the menus, and a page that keeps its
618
+ booking links cannot be added, so they are not offered.
619
+ </p>
620
+
621
+ <div className="mt-6 border border-ink/10 p-4" role="group" aria-labelledby="booking-landing-default-title">
622
+ <h4 id="booking-landing-default-title" className="text-sm text-ink">
623
+ Default message
624
+ </h4>
625
+ <p className="mt-1 max-w-2xl text-xs text-ink/60">
626
+ Shown on every page in the list unless the page has its own. Empty fields use the built-in
627
+ text shown in grey. Plain text only.
628
+ </p>
629
+ {changedSince("default", data.default.version) ? (
630
+ <p role="alert" className="mt-3 border border-amber-400 bg-amber-50 px-3 py-2 text-sm text-ink">
631
+ The default message was changed by someone else while you were editing. Your changes were
632
+ not saved; their version is shown below. Review it and edit again if needed.
633
+ </p>
634
+ ) : null}
635
+ <div className="mt-4">
636
+ <NoticeForm
637
+ key={data.default.version ?? "none"}
638
+ initial={draftFrom(data.default.notice, locales, false)}
639
+ locales={locales}
640
+ page={false}
641
+ busy={busy}
642
+ focusOnOpen={changedSince("default", data.default.version)}
643
+ saveLabel="Save default message"
644
+ onSave={async (draft) => {
645
+ const ok = await run(
646
+ () =>
647
+ deps.setLandingDefault({
648
+ notice: {
649
+ heading: cleaned(draft.heading) ?? {},
650
+ body: cleaned(draft.body) ?? {},
651
+ cta:
652
+ draft.ctaMode === "own"
653
+ ? { label: cleaned(draft.ctaLabel) ?? {}, href: draft.ctaHref.trim() }
654
+ : null,
655
+ },
656
+ expectedVersion: data.default.version,
657
+ }),
658
+ "Saved the default message",
659
+ );
660
+ remember("default", ok ? undefined : data.default.version);
661
+ return ok;
662
+ }}
663
+ />
664
+ </div>
665
+ </div>
666
+
667
+ <p role="status" className="sr-only">
668
+ {status}
669
+ </p>
670
+ </div>
671
+ );
672
+ }