@bygghammar/cms-platform 0.94.0 → 0.96.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 (43) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/docs/PAGES.md +72 -4
  3. package/package.json +8 -1
  4. package/src/admin/describe-log.ts +3 -0
  5. package/src/backoffice/BookingSettingsCard.tsx +252 -0
  6. package/src/backoffice/NavigationView.tsx +38 -0
  7. package/src/backoffice/PageBuilderView.tsx +149 -3
  8. package/src/backoffice/PageEditorView.tsx +47 -24
  9. package/src/backoffice/PagesListView.tsx +173 -57
  10. package/src/backoffice/SiteSettingsView.tsx +4 -0
  11. package/src/backoffice/StatusBadge.tsx +38 -2
  12. package/src/backoffice/media/ConfirmDialog.tsx +19 -1
  13. package/src/backoffice/routes/host.ts +24 -0
  14. package/src/backoffice/routes/index.tsx +54 -0
  15. package/src/backoffice/routes/pages.tsx +13 -0
  16. package/src/cms/booking-links.ts +176 -0
  17. package/src/cms/page-publication.ts +11 -6
  18. package/src/cms/page-visibility.ts +206 -0
  19. package/src/forms/form-audit.ts +22 -2
  20. package/src/html/html-tokenizer.ts +254 -0
  21. package/src/html/sanitize-html.ts +72 -37
  22. package/src/navigation/visibility.ts +235 -0
  23. package/src/popups/runtime.tsx +155 -4
  24. package/src/server/forms-core.ts +9 -0
  25. package/src/server/forms-delivery-core.ts +29 -22
  26. package/src/server/forms-submissions-core.ts +8 -5
  27. package/src/server/media-usage-core.ts +53 -13
  28. package/src/server/outbound-address.ts +131 -0
  29. package/src/server/outbound-http.ts +565 -0
  30. package/src/server/page-visibility-core.ts +450 -0
  31. package/src/server/pages-core.ts +238 -9
  32. package/src/server/popup-optin-webhook-core.ts +79 -140
  33. package/src/server/published-cache.ts +23 -3
  34. package/src/server/schemas.ts +17 -0
  35. package/src/site/PageRenderer.tsx +34 -5
  36. package/src/site/SiteFooter.tsx +11 -2
  37. package/src/site/SiteHeader.tsx +41 -21
  38. package/src/site/link-visibility.tsx +28 -0
  39. package/src/site/preview-messaging.ts +232 -0
  40. package/src/site/route-aliases.ts +74 -0
  41. package/src/site/routes/preview-page.tsx +75 -31
  42. package/src/site/routes/public-page.tsx +81 -9
  43. package/src/util/redact-url.ts +18 -0
package/CHANGELOG.md CHANGED
@@ -8,6 +8,152 @@ matches `package.json` exactly. See
8
8
  Versions 0.1.0 through 0.78.0 predate this repository and were reconstructed
9
9
  from the published npm tarballs.
10
10
 
11
+ ## 0.96.0
12
+
13
+ Security release. No migration, and no export was added or removed.
14
+
15
+ - **Rich text is sanitized by a parser, not by patterns** (`./html/sanitize-html`).
16
+ The old sanitizer copied the text between the tags it recognised through as
17
+ written, so an unterminated tag at the end of a fragment (for example an image
18
+ tag with an event handler and no closing `>`) survived, and in server
19
+ rendering the markup after the fragment completed it into a live element.
20
+ Markup is now read the way a browser's HTML tokenizer reads it (every quoting
21
+ form, comments, DOCTYPE, CDATA, raw-text elements, a tag cut off by the end of
22
+ the input) and the output is written from scratch: allowlisted elements and
23
+ attributes only, attributes decoded once and escaped once, and every `<` and
24
+ `>` in text escaped. The editor's allowlist, URL rules and booking-link
25
+ `&amp;` handling are unchanged.
26
+ - Tightened: a `data:` image is accepted only in an image's `src`, never as a
27
+ link target. `svg`, `math`, `template`, `textarea`, `title`, `xmp`,
28
+ `noembed`, `noframes` and `plaintext` are dropped together with their
29
+ content.
30
+ - `htmlToText` now returns plain text, with entities decoded.
31
+ - **The builder and its preview only talk to each other**
32
+ (`./site/routes/preview-page`, the page builder). The public preview route
33
+ used to render blocks posted by any window that put a marker string in its
34
+ message, and both ends posted to `"*"`. Now:
35
+ - the preview accepts the builder's messages only from its parent frame,
36
+ from its own origin or an origin the host lists, in one builder session
37
+ with increasing sequence numbers, shape-checked and at most 4 MB;
38
+ - the builder accepts the preview's messages only from its own iframe, and
39
+ every message names its target origin.
40
+ - **Host change, only for a builder on another origin:** list that origin in
41
+ the preview page deps as `trustedEditorOrigins`. A builder on the site's own
42
+ origin (Voyara's) needs no configuration.
43
+ - **One outbound request policy** for every address staff or configuration
44
+ chooses: media import, form webhooks and popup opt-in webhooks. Each of these
45
+ was a server-side request forgery primitive. Every request now:
46
+ - allows only the schemes its policy names (webhooks: https only), with no
47
+ credentials in the URL, no local or single-label host names and no
48
+ privileged ports other than 80 and 443;
49
+ - looks the host up and goes ahead only if every address it resolves to is
50
+ public (IPv4 and IPv6 special-purpose ranges, including mapped, NAT64 and
51
+ 6to4 forms), then connects to the address it checked (pinned, so a DNS
52
+ answer that changes in between cannot redirect it), with TLS still verified
53
+ for the host name;
54
+ - follows redirects itself, checking and pinning every hop, up to a cap;
55
+ - runs under one deadline for the whole request, which also holds when a
56
+ lookup, connection or response body ignores cancellation (Node's DNS
57
+ lookup cannot be cancelled), and reads at most a fixed number of bytes,
58
+ streaming, whatever `Content-Length` says;
59
+ - reports failures by category, with messages that carry no address, query
60
+ string or response body.
61
+ - **What each path does with that policy:**
62
+ - **Media import:** a downloaded file is stored as what its first bytes say
63
+ it is (JPEG, PNG, GIF, WebP, AVIF, MP4, MOV, M4V, WebM, MP3, WAV, Ogg or
64
+ PDF), with that extension, never as the server's content type or the URL's
65
+ extension. SVG and HTML are refused. Public redirects to other sites are
66
+ still followed, carrying only plain request headers.
67
+ - **Form and popup webhooks never follow a redirect to another site.** A
68
+ signed body, its signature, timestamp, event id and idempotency key reach
69
+ only the configured origin (a redirect within that origin is followed;
70
+ popup webhooks report the refusal as `blocked-endpoint`). When a redirect
71
+ turns a request into a GET, entity and signature headers are dropped.
72
+ **Receivers that redirect to another host must be configured with their
73
+ final address.**
74
+ - **Form webhooks** are checked when the form is saved, report a failure by
75
+ status only (the receiver's reply is never copied into it), and are never
76
+ sent unsigned when signing fails.
77
+ - **Addresses in records keep the origin only.** Webhook delivery records,
78
+ errors and the form audit trail show a webhook as its origin plus a fixed
79
+ `/[redacted]` marker, never its path, query string or credentials, because
80
+ receivers put keys in paths as well as in query strings. Settings whose name
81
+ says they are a secret are recorded as changed, never with a value.
82
+
83
+ ## 0.95.0
84
+
85
+ - **Pages can be link-only, and unpublished from the page itself.** Listing is
86
+ a second property beside publication, and neither changes the other:
87
+ - **Make link-only / Show on website** (`setPageListingCore`). A link-only
88
+ page stays online at its address but is left out of menus, the footer and
89
+ the sitemap, and gets `noindex`. No publication, version, schedule or
90
+ status is written, so "Show on website" cannot publish an offline page,
91
+ and saving, publishing and scheduled replacements keep a page link-only.
92
+ Link-only is not access control.
93
+ - **Unpublish** (the existing `cms_set_page_status`) is also offered in the
94
+ builder header. Both actions are in the page builder and the pages list,
95
+ and ask for confirmation with the consequences spelled out in full.
96
+ - **Protected pages.** A host passes `protectedPageKeys` in the page deps
97
+ (Voyara: the start page and the contact page). The server refuses to
98
+ unpublish, end-date, trash or unlist them. This is an application
99
+ safeguard; direct database access is not blocked by it.
100
+ - Offline states read `Offline`, `Scheduled to go offline` and
101
+ `Offline after expiry` (previously "Hidden…"), so they cannot be confused
102
+ with link-only.
103
+ - A copy of a link-only page starts link-only. A same-key retry completes a
104
+ copy whose listing write failed, with the original decision, and never
105
+ changes a finished copy: the decision is applied only if the database
106
+ recorded it before the copy existed. Booking exceptions are never copied.
107
+ - **A site-wide booking switch, with exception pages.** One switch
108
+ (`setBookingEnabledCore`, Booking card: `./backoffice/BookingSettingsCard`)
109
+ hides every link to a host starting with `booking` (`isBookingHref`) in page
110
+ content, the menu, the footer and popups, plus a header CTA the host marks
111
+ `booking: true`. Pages marked as exceptions keep the booking links in their
112
+ own content. Stored content is never changed: hosts remove booking from data
113
+ before it leaves the server (`stripBookingDeep`, `withoutBookingPopups`),
114
+ and the page renderer can apply the same removal to whatever the browser
115
+ cache holds.
116
+ - **One public policy for publication, listing and booking**
117
+ (`loadPublicPolicyCore`, `./cms/page-visibility`). It is read in one
118
+ snapshot and cached with the published-page cache. A read begun before an
119
+ invalidation can no longer refill the cache with the old answer. A failed
120
+ read throws and is not cached, and hosts fail closed.
121
+ - Hosts send the chrome only the hidden addresses the menu and footer link
122
+ to (`routeAliasMap` from `./site/route-aliases`; `restrictToLinks` and
123
+ `savedLinkHrefs` from `./navigation/visibility`) and provide them through
124
+ `LinkVisibilityProvider` (`./site/link-visibility`). The saved menu is
125
+ never changed.
126
+ - The public page route takes a per-page `pagePolicy`: an offline page
127
+ answers 404 even when the browser has it cached, a cached "not found" is
128
+ re-read once the page is live, and a non-indexable page gets `noindex`.
129
+ `PageRenderer` accepts an optional `transform` and `variant`, so the
130
+ current policy decides over a warm page cache.
131
+ - **Popups follow the booking switch without losing anything else.** The
132
+ popup module is no longer remounted when the policy changes. While booking
133
+ is hidden, booking popups are kept paused, with their booking links and
134
+ actions taken out, so an open one stays readable and closes normally. The
135
+ restriction applies at once from what is already loaded, without waiting for
136
+ a refresh, and a slow, failed or late refresh cannot bring booking back.
137
+ An open newsletter form keeps its field, text and focus, its trigger timers,
138
+ counts and analytics.
139
+ - **Storage: no migration.** One `site_settings` row per decision
140
+ (`page.listing.<uuid>`, `booking`, `booking.exception.<uuid>`, and a
141
+ short-lived `duplicate.intent.<key>` during a duplication). Every write is a
142
+ compare-and-swap on `updated_at` (`VisibilityConflictError`). None of these
143
+ keys can be reached by the generic settings save or reset.
144
+ - **New exports:**
145
+ - `./cms/page-visibility`;
146
+ - `./cms/booking-links`;
147
+ - `./server/page-visibility-core`;
148
+ - `./navigation/visibility`;
149
+ - `./site/link-visibility`;
150
+ - `./site/route-aliases`;
151
+ - `./backoffice/BookingSettingsCard`.
152
+
153
+ The new host wiring is optional (`pages.fns.setPageListing`, `booking.fns`,
154
+ `useLinkVisibility`); without it the new actions and the Booking card are
155
+ not shown. See `docs/PAGES.md`.
156
+
11
157
  ## 0.94.0
12
158
 
13
159
  - **The version screen shows what is actually deployed and installed.**
package/docs/PAGES.md CHANGED
@@ -92,11 +92,15 @@ show the state the rule produces — `publicationStateAt` over the page's own
92
92
 
93
93
  | State | Badge | Means |
94
94
  | --- | --- | --- |
95
- | `draft` | Hidden | Nothing open, nothing scheduled |
96
- | `scheduled` | Scheduled to publish | Hidden now; a window opens later |
95
+ | `draft` | Offline | Nothing open, nothing scheduled |
96
+ | `scheduled` | Scheduled to publish | Offline now; a window opens later |
97
97
  | `published` | Published | A visitor can open it right now |
98
- | `ending` | Scheduled to hide | Public, with an end date already set |
99
- | `expired` | Hidden after expiry | Was public; the window has closed |
98
+ | `ending` | Scheduled to go offline | Public, with an end date already set |
99
+ | `expired` | Offline after expiry | Was public; the window has closed |
100
+
101
+ (These read "Hidden", "Scheduled to hide" and "Hidden after expiry" until
102
+ listing was added: a link-only page is *online* but unlisted, and the two must
103
+ not share a word. See [Listing, link-only pages and booking](#listing-link-only-pages-and-booking).)
100
104
 
101
105
  `pages.status` is still shown, labelled **Editorial** so it cannot be mistaken
102
106
  for the other one, and the action offered follows the publication state rather
@@ -508,3 +512,67 @@ A host that has not registered them keeps working — the Pages list hides the t
508
512
  actions rather than offering a button that throws. Authorization is the existing
509
513
  one: both go through the same staff guard as every other page mutation, and
510
514
  `tests/page-publish-duplicate.test.ts` pins a non-staff caller being refused.
515
+
516
+ ## Listing, link-only pages and booking
517
+
518
+ Listing is a second property beside publication, and the two never touch:
519
+
520
+ | Publication | Listing | Shown as | A visitor gets |
521
+ | --- | --- | --- | --- |
522
+ | live | listed | `Published` | The page; it is in menus, footer and sitemap |
523
+ | live | link-only | `Published` + `Link only` | The page by its address only; left out of menus, footer and sitemap; `noindex` |
524
+ | not live | either | `Offline` (+ `Link only once published`) | 404 |
525
+
526
+ - **Make link-only / Show on website** change listing only (`setPageListingCore`).
527
+ No publication, version, schedule or `pages.status` is written, so "Show on
528
+ website" cannot publish an offline page, and saving, publishing and scheduled
529
+ replacements keep a page link-only. Link-only is not access control: the
530
+ address can be shared or found, and `noindex` asks crawlers, it does not
531
+ remove existing results.
532
+ - **Unpublish** is the existing `cms_set_page_status(draft)` with its
533
+ schedule-aware warnings, now also offered in the builder header.
534
+ - **Protected pages.** A host passes `protectedPageKeys` in the page deps (Voyara:
535
+ the start page and the contact page). The cores refuse to unpublish, end-date,
536
+ trash or unlist them. An application safeguard: direct database access is not
537
+ blocked by it.
538
+ - **Booking.** One switch hides every link whose address is `https:` (or
539
+ protocol-relative) with a host starting `booking` (`isBookingHref`), plus the
540
+ header CTA when the host marks it `booking: true`. Exception pages keep the
541
+ booking links in their own content. Removal (`stripBookingDeep`,
542
+ `withoutBookingPopups`) is for the host to apply to data before it leaves the
543
+ server; stored content is never changed.
544
+
545
+ ### Storage
546
+
547
+ No migration: one `site_settings` row per decision, keyed by page id —
548
+ `page.listing.<uuid>`, `booking`, `booking.exception.<uuid>`. Every write is a
549
+ compare-and-swap on `updated_at` (`VisibilityConflictError` when someone else
550
+ changed the row first). None is an `OVERRIDABLE_SETTING_KEYS` key, so the generic
551
+ settings save and reset cannot reach them.
552
+
553
+ ### The public policy
554
+
555
+ `loadPublicPolicyCore` reads publication, listing, booking and exceptions for
556
+ every page in one snapshot, cached with the published-page cache (cleared on
557
+ every page and visibility write, capped at the next publication boundary). A
558
+ failed read throws and is not cached; hosts fail closed (`known: false`: booking
559
+ hidden, nothing indexable, nothing reported offline).
560
+
561
+ Hosts turn the snapshot into what the chrome needs with `routeAliasMap`,
562
+ `restrictToLinks` and `savedLinkHrefs` — only the hidden addresses the menu and
563
+ footer actually link to are sent to the browser — and provide it through
564
+ `LinkVisibilityProvider`. The header and footer leave those links out; the
565
+ saved menu is never changed, and the navigation editor says why an item is not
566
+ shown. The public page route takes a per-page `pagePolicy` query and adds
567
+ `noindex` for a page that is not indexable.
568
+
569
+ ### Host wiring
570
+
571
+ ```ts
572
+ pages: { fns: { setPageStatus?, setPageListing?, ... } }
573
+ booking?: { fns: { getBookingSettings, setBookingEnabled, setBookingException } }
574
+ useLinkVisibility?: () => LinkVisibilityPolicy | undefined
575
+ ```
576
+
577
+ All optional: without them the actions, the Booking card and the editor's
578
+ explanation are simply not shown.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.94.0",
3
+ "version": "0.96.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -49,6 +49,7 @@
49
49
  "./trips": "./src/trips/index.ts",
50
50
  "./trips/TripSectionEditor": "./src/trips/TripSectionEditor.tsx",
51
51
  "./navigation": "./src/navigation/index.ts",
52
+ "./navigation/visibility": "./src/navigation/visibility.ts",
52
53
  "./forms/FormRenderer": "./src/forms/FormRenderer.tsx",
53
54
  "./forms/success": "./src/forms/success-listeners.ts",
54
55
  "./forms/submission-result": "./src/forms/submission-result.ts",
@@ -124,6 +125,7 @@
124
125
  "./config/site-config": "./src/config/site-config.ts",
125
126
  "./server/site-settings-core": "./src/server/site-settings-core.ts",
126
127
  "./backoffice/SiteSettingsView": "./src/backoffice/SiteSettingsView.tsx",
128
+ "./backoffice/BookingSettingsCard": "./src/backoffice/BookingSettingsCard.tsx",
127
129
  "./backoffice/SitemapView": "./src/backoffice/SitemapView.tsx",
128
130
  "./backoffice/UsersView": "./src/backoffice/UsersView.tsx",
129
131
  "./backoffice/RolesView": "./src/backoffice/RolesView.tsx",
@@ -154,6 +156,7 @@
154
156
  "./server/media-library-core": "./src/server/media-library-core.ts",
155
157
  "./server/admin-core": "./src/server/admin-core.ts",
156
158
  "./server/pages-core": "./src/server/pages-core.ts",
159
+ "./server/page-visibility-core": "./src/server/page-visibility-core.ts",
157
160
  "./server/edit-locks-core": "./src/server/edit-locks-core.ts",
158
161
  "./server/edit-lock-adapter": "./src/server/edit-lock-adapter.ts",
159
162
  "./server/edit-lock-supabase-adapter": "./src/server/edit-lock-supabase-adapter.ts",
@@ -205,6 +208,8 @@
205
208
  "./media/use-media-upload": "./src/media/use-media-upload.ts",
206
209
  "./cms/page-draft-store": "./src/cms/page-draft-store.ts",
207
210
  "./cms/page-publication": "./src/cms/page-publication.ts",
211
+ "./cms/page-visibility": "./src/cms/page-visibility.ts",
212
+ "./cms/booking-links": "./src/cms/booking-links.ts",
208
213
  "./site/routes": "./src/site/routes.ts",
209
214
  "./site/PageRenderer": "./src/site/PageRenderer.tsx",
210
215
  "./server/env": "./src/server/env.ts",
@@ -232,6 +237,8 @@
232
237
  "./site/host": "./src/site/host.ts",
233
238
  "./site/locale-domains": "./src/site/locale-domains.ts",
234
239
  "./site/route-index": "./src/site/route-index.ts",
240
+ "./site/route-aliases": "./src/site/route-aliases.ts",
241
+ "./site/link-visibility": "./src/site/link-visibility.tsx",
235
242
  "./site/SiteHeader": "./src/site/SiteHeader.tsx",
236
243
  "./site/SiteFooter": "./src/site/SiteFooter.tsx",
237
244
  "./server/schemas": "./src/server/schemas.ts",
@@ -34,6 +34,9 @@ export const ACTION_LABELS: Record<string, string> = {
34
34
  "page.delete": "Deleted page",
35
35
  "page.publish": "Published page",
36
36
  "page.unpublish": "Unpublished page",
37
+ "page.listing": "Changed whether a page is listed",
38
+ "booking.settings": "Switched booking on or off",
39
+ "booking.exception": "Changed which pages keep booking links",
37
40
  "page.version.create": "Saved page version",
38
41
  "page.version.restore": "Restored page version",
39
42
  "template.create": "Created block template",
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Site settings → Booking: one switch for every booking link on the public
3
+ * site, and the pages that keep theirs while it is off.
4
+ *
5
+ * What it does is said on the card, because the switch is easy to misread in
6
+ * both directions: it hides links and the header button, it does not close the
7
+ * booking site, and an address somebody already has still works.
8
+ */
9
+ import { useState, type ReactNode } from "react";
10
+ import { toast } from "sonner";
11
+ import { Plus, Trash2 } from "lucide-react";
12
+
13
+ import { ConfirmDialog } from "./media/ConfirmDialog";
14
+
15
+ export type BookingSettingsData = {
16
+ enabled: boolean;
17
+ version: string | null;
18
+ exceptions: Array<{
19
+ pageId: string;
20
+ key: string | null;
21
+ name: string | null;
22
+ missing: boolean;
23
+ version: string | null;
24
+ }>;
25
+ /** Versions of every stored exception row, removed ones included. */
26
+ rowVersions?: Record<string, string | null>;
27
+ };
28
+
29
+ export type BookingSettingsDeps = {
30
+ /** Undefined while loading. */
31
+ settings: BookingSettingsData | undefined;
32
+ /** A failed read: the card says so and offers nothing it cannot back up. */
33
+ error: Error | null;
34
+ /** Pages an exception can be added for. */
35
+ pages: Array<{ id: string; key: string; name: string }> | undefined;
36
+ /** Public path of a page key, for the list. */
37
+ pathFor: (key: string) => string;
38
+ setEnabled: (input: { enabled: boolean; expectedVersion: string | null }) => Promise<unknown>;
39
+ setException: (input: {
40
+ pageId: string;
41
+ exception: boolean;
42
+ expectedVersion: string | null;
43
+ }) => Promise<unknown>;
44
+ refresh: () => void;
45
+ };
46
+
47
+ const cardClass = "border border-ink/10 bg-white p-6";
48
+
49
+ export function BookingSettingsCard({ deps }: { deps: BookingSettingsDeps }): ReactNode {
50
+ const { settings, error, pages, pathFor } = deps;
51
+ const [confirming, setConfirming] = useState<boolean | null>(null);
52
+ const [adding, setAdding] = useState("");
53
+ const [busy, setBusy] = useState(false);
54
+
55
+ const run = async (action: () => Promise<unknown>, done: string) => {
56
+ setBusy(true);
57
+ try {
58
+ await action();
59
+ toast.success(done);
60
+ } catch (e) {
61
+ toast.error(e instanceof Error ? e.message : "Failed to save");
62
+ } finally {
63
+ setBusy(false);
64
+ // Success or conflict alike: show what is stored now.
65
+ deps.refresh();
66
+ }
67
+ };
68
+
69
+ const exceptionIds = new Set((settings?.exceptions ?? []).map((row) => row.pageId));
70
+ const candidates = (pages ?? [])
71
+ .filter((page) => !exceptionIds.has(page.id.toLowerCase()))
72
+ .sort((a, b) => a.name.localeCompare(b.name));
73
+
74
+ return (
75
+ <section className={`mt-6 ${cardClass}`} aria-labelledby="booking-settings-title">
76
+ <h2 id="booking-settings-title" className="font-serif text-xl text-ink">
77
+ Booking
78
+ </h2>
79
+ <p className="mt-2 max-w-2xl text-sm text-ink/60">
80
+ Booking links are links and buttons whose address starts with{" "}
81
+ <code className="font-mono text-xs">https://booking</code>, for example{" "}
82
+ <code className="font-mono text-xs">https://booking.voyara.se</code>. Hiding booking removes
83
+ them from every page, together with the booking button in the menu. The booking site itself
84
+ keeps working, and an address someone already has still opens.
85
+ </p>
86
+
87
+ {error ? (
88
+ <p role="alert" className="mt-4 text-sm text-red-700">
89
+ The booking setting could not be read: {error.message}. Nothing is shown here until it
90
+ can be, so nothing is changed by mistake.
91
+ </p>
92
+ ) : !settings ? (
93
+ <p className="mt-4 text-sm text-ink/50">Loading…</p>
94
+ ) : (
95
+ <>
96
+ <div className="mt-5 flex flex-wrap items-center gap-4">
97
+ <p className="text-sm text-ink" aria-live="polite">
98
+ {settings.enabled ? (
99
+ <>
100
+ Booking is <strong>shown</strong> on the website.
101
+ </>
102
+ ) : (
103
+ <>
104
+ Booking is <strong>hidden</strong> on the website.
105
+ </>
106
+ )}
107
+ </p>
108
+ <button
109
+ type="button"
110
+ disabled={busy}
111
+ onClick={() => setConfirming(!settings.enabled)}
112
+ className={[
113
+ "inline-flex items-center gap-2 px-4 py-2 text-xs uppercase tracking-[0.18em] disabled:opacity-40",
114
+ settings.enabled
115
+ ? "border border-red-300 text-red-700 hover:border-red-600"
116
+ : "bg-ink text-paper hover:bg-ink/85",
117
+ ].join(" ")}
118
+ >
119
+ {settings.enabled ? "Hide booking" : "Show booking"}
120
+ </button>
121
+ </div>
122
+
123
+ <div className="mt-8 border-t border-ink/10 pt-5">
124
+ <h3 className="text-xs uppercase tracking-wider text-ink/60">
125
+ Pages that keep their booking links
126
+ </h3>
127
+ <p className="mt-2 max-w-2xl text-sm text-ink/60">
128
+ While booking is hidden, these pages still show the booking links in their own
129
+ content — for example a link-only page for one group. The booking button in the menu
130
+ stays hidden on them too.
131
+ </p>
132
+ <ul className="mt-4 divide-y divide-ink/10 border border-ink/10">
133
+ {settings.exceptions.length === 0 ? (
134
+ <li className="px-4 py-3 text-sm text-ink/50">No pages.</li>
135
+ ) : (
136
+ settings.exceptions.map((row) => (
137
+ <li
138
+ key={row.pageId}
139
+ className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 text-sm"
140
+ >
141
+ <span className="min-w-0">
142
+ <span className="text-ink">{row.name ?? "Page no longer exists"}</span>
143
+ {row.key ? (
144
+ <span className="ml-2 break-all font-mono text-xs text-ink/50">
145
+ {pathFor(row.key)}
146
+ </span>
147
+ ) : null}
148
+ {row.missing ? (
149
+ <span className="ml-2 text-xs text-ink/50">
150
+ (in the trash or deleted — has no effect)
151
+ </span>
152
+ ) : null}
153
+ </span>
154
+ <button
155
+ type="button"
156
+ disabled={busy}
157
+ onClick={() =>
158
+ run(
159
+ () =>
160
+ deps.setException({
161
+ pageId: row.pageId,
162
+ exception: false,
163
+ expectedVersion: row.version,
164
+ }),
165
+ `Removed ${row.name ?? "the page"}`,
166
+ )
167
+ }
168
+ className="inline-flex items-center gap-1 text-xs text-red-700 hover:underline disabled:opacity-40"
169
+ >
170
+ <Trash2 className="size-3.5" aria-hidden="true" /> Remove
171
+ <span className="sr-only"> {row.name ?? "page"} from the list</span>
172
+ </button>
173
+ </li>
174
+ ))
175
+ )}
176
+ </ul>
177
+ <div className="mt-4 flex flex-wrap items-end gap-3">
178
+ <label className="block min-w-0 flex-1 sm:max-w-sm">
179
+ <span className="mb-1 block text-xs uppercase tracking-wider text-ink/60">
180
+ Add a page
181
+ </span>
182
+ <select
183
+ value={adding}
184
+ onChange={(e) => setAdding(e.target.value)}
185
+ disabled={busy || !pages}
186
+ className="w-full border border-ink/15 bg-white px-3 py-2 text-sm text-ink"
187
+ >
188
+ <option value="">Choose a page…</option>
189
+ {candidates.map((page) => (
190
+ <option key={page.id} value={page.id}>
191
+ {page.name} — {pathFor(page.key)}
192
+ </option>
193
+ ))}
194
+ </select>
195
+ </label>
196
+ <button
197
+ type="button"
198
+ disabled={busy || !adding}
199
+ onClick={() => {
200
+ const page = candidates.find((p) => p.id === adding);
201
+ // An exception removed earlier leaves its row behind; send its version.
202
+ const previous = settings.rowVersions?.[adding.toLowerCase()] ?? null;
203
+ void run(
204
+ () =>
205
+ deps.setException({
206
+ pageId: adding,
207
+ exception: true,
208
+ expectedVersion: previous,
209
+ }),
210
+ `${page?.name ?? "The page"} keeps its booking links`,
211
+ ).then(() => setAdding(""));
212
+ }}
213
+ 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"
214
+ >
215
+ <Plus className="size-4" aria-hidden="true" /> Add
216
+ </button>
217
+ </div>
218
+ </div>
219
+ </>
220
+ )}
221
+
222
+ {confirming !== null && settings ? (
223
+ <ConfirmDialog
224
+ title={confirming ? "Show booking on the website?" : "Hide booking on the website?"}
225
+ description={
226
+ confirming
227
+ ? "Booking links and the booking button in the menu appear again on every page."
228
+ : "Every link and button that goes to a booking site is removed from the public website, on every page, together with the booking button in the menu."
229
+ }
230
+ consequences={
231
+ confirming
232
+ ? ["The booking button in the menu also needs its booking page to be published and listed."]
233
+ : [
234
+ "Pages in the list below keep the booking links in their own content.",
235
+ "The booking site itself is not closed: anyone who already has a booking address can still use it.",
236
+ ]
237
+ }
238
+ confirmLabel={confirming ? "Show booking" : "Hide booking"}
239
+ danger={!confirming}
240
+ busy={busy}
241
+ onConfirm={() =>
242
+ void run(
243
+ () => deps.setEnabled({ enabled: confirming, expectedVersion: settings.version }),
244
+ confirming ? "Booking is shown on the website" : "Booking is hidden on the website",
245
+ ).then(() => setConfirming(null))
246
+ }
247
+ onCancel={() => setConfirming(null)}
248
+ />
249
+ ) : null}
250
+ </section>
251
+ );
252
+ }
@@ -7,6 +7,11 @@ import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
7
7
  import type { SiteTypography, TypographySlot } from "../config/typography";
8
8
  import { EditLockBanner, EditLockFence, useEditLock } from "./EditLock";
9
9
  import { TypographyPanel } from "./TypographyPanel";
10
+ import {
11
+ HIDDEN_LINK_REASONS,
12
+ hiddenSavedLinks,
13
+ type LinkVisibilityPolicy,
14
+ } from "../navigation/visibility";
10
15
 
11
16
  /** The two site-wide content sections this screen edits. */
12
17
  export type NavigationSectionKey = "nav" | "footer";
@@ -19,6 +24,12 @@ const TYPOGRAPHY_SLOT: Record<NavigationSectionKey, TypographySlot> = {
19
24
 
20
25
  /** Host-provided data and actions for the navigation & footer screen. */
21
26
  export type NavigationDeps = {
27
+ /**
28
+ * The link policy the public site applies right now. With it, the editor
29
+ * lists saved items the site is not showing and says why; without it,
30
+ * nothing is said. The saved menu is never changed either way.
31
+ */
32
+ linkPolicy?: LinkVisibilityPolicy;
22
33
  /** Saved overrides keyed by section, as stored in the CMS. */
23
34
  overrides: Record<string, unknown> | undefined;
24
35
  /** Resolved live site content (defaults merged with overrides). */
@@ -77,6 +88,11 @@ function Editor({
77
88
  }, [overrides, site, sectionKey]);
78
89
 
79
90
  const [draft, setDraft] = useState<unknown>(current);
91
+ // About what is saved, not the unsaved draft: this is what visitors get now.
92
+ const notShown = useMemo(
93
+ () => (deps.linkPolicy ? hiddenSavedLinks(sectionKey, current, deps.linkPolicy) : []),
94
+ [deps.linkPolicy, sectionKey, current],
95
+ );
80
96
  const [busy, setBusy] = useState(false);
81
97
  const [dirty, setDirty] = useState(false);
82
98
 
@@ -127,6 +143,28 @@ function Editor({
127
143
 
128
144
  <div className="p-6">
129
145
  <EditLockBanner lock={lock} className="mb-6" />
146
+ {notShown.length > 0 ? (
147
+ <div
148
+ role="status"
149
+ className="mb-6 border border-brass/50 bg-brass/10 px-4 py-3 text-sm text-ink"
150
+ >
151
+ <p className="font-medium">
152
+ {notShown.length === 1
153
+ ? "1 saved link is not shown on the site right now."
154
+ : `${notShown.length} saved links are not shown on the site right now.`}{" "}
155
+ They stay saved and come back by themselves.
156
+ </p>
157
+ <ul className="mt-2 space-y-1">
158
+ {notShown.map((item, index) => (
159
+ <li key={`${item.href}-${index}`} className="break-words">
160
+ <span className="text-ink">{item.label}</span>{" "}
161
+ <span className="font-mono text-xs text-ink/60">{item.href}</span> —{" "}
162
+ <span className="text-ink/70">{HIDDEN_LINK_REASONS[item.reason]}</span>
163
+ </li>
164
+ ))}
165
+ </ul>
166
+ </div>
167
+ ) : null}
130
168
 
131
169
  <EditLockFence lock={lock}>
132
170
  <SectionForm