@bygghammar/cms-platform 0.94.0 → 0.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,80 @@ 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.95.0
12
+
13
+ - **Pages can be link-only, and unpublished from the page itself.** Listing is
14
+ a second property beside publication, and neither changes the other:
15
+ - **Make link-only / Show on website** (`setPageListingCore`). A link-only
16
+ page stays online at its address but is left out of menus, the footer and
17
+ the sitemap, and gets `noindex`. No publication, version, schedule or
18
+ status is written, so "Show on website" cannot publish an offline page,
19
+ and saving, publishing and scheduled replacements keep a page link-only.
20
+ Link-only is not access control.
21
+ - **Unpublish** (the existing `cms_set_page_status`) is also offered in the
22
+ builder header. Both actions are in the page builder and the pages list,
23
+ and ask for confirmation with the consequences spelled out in full.
24
+ - **Protected pages.** A host passes `protectedPageKeys` in the page deps
25
+ (Voyara: the start page and the contact page). The server refuses to
26
+ unpublish, end-date, trash or unlist them. This is an application
27
+ safeguard; direct database access is not blocked by it.
28
+ - Offline states read `Offline`, `Scheduled to go offline` and
29
+ `Offline after expiry` (previously "Hidden…"), so they cannot be confused
30
+ with link-only.
31
+ - A copy of a link-only page starts link-only. A same-key retry completes a
32
+ copy whose listing write failed, with the original decision, and never
33
+ changes a finished copy: the decision is applied only if the database
34
+ recorded it before the copy existed. Booking exceptions are never copied.
35
+ - **A site-wide booking switch, with exception pages.** One switch
36
+ (`setBookingEnabledCore`, Booking card: `./backoffice/BookingSettingsCard`)
37
+ hides every link to a host starting with `booking` (`isBookingHref`) in page
38
+ content, the menu, the footer and popups, plus a header CTA the host marks
39
+ `booking: true`. Pages marked as exceptions keep the booking links in their
40
+ own content. Stored content is never changed: hosts remove booking from data
41
+ before it leaves the server (`stripBookingDeep`, `withoutBookingPopups`),
42
+ and the page renderer can apply the same removal to whatever the browser
43
+ cache holds.
44
+ - **One public policy for publication, listing and booking**
45
+ (`loadPublicPolicyCore`, `./cms/page-visibility`). It is read in one
46
+ snapshot and cached with the published-page cache. A read begun before an
47
+ invalidation can no longer refill the cache with the old answer. A failed
48
+ read throws and is not cached, and hosts fail closed.
49
+ - Hosts send the chrome only the hidden addresses the menu and footer link
50
+ to (`routeAliasMap` from `./site/route-aliases`; `restrictToLinks` and
51
+ `savedLinkHrefs` from `./navigation/visibility`) and provide them through
52
+ `LinkVisibilityProvider` (`./site/link-visibility`). The saved menu is
53
+ never changed.
54
+ - The public page route takes a per-page `pagePolicy`: an offline page
55
+ answers 404 even when the browser has it cached, a cached "not found" is
56
+ re-read once the page is live, and a non-indexable page gets `noindex`.
57
+ `PageRenderer` accepts an optional `transform` and `variant`, so the
58
+ current policy decides over a warm page cache.
59
+ - **Popups follow the booking switch without losing anything else.** The
60
+ popup module is no longer remounted when the policy changes. While booking
61
+ is hidden, booking popups are kept paused, with their booking links and
62
+ actions taken out, so an open one stays readable and closes normally. The
63
+ restriction applies at once from what is already loaded, without waiting for
64
+ a refresh, and a slow, failed or late refresh cannot bring booking back.
65
+ An open newsletter form keeps its field, text and focus, its trigger timers,
66
+ counts and analytics.
67
+ - **Storage: no migration.** One `site_settings` row per decision
68
+ (`page.listing.<uuid>`, `booking`, `booking.exception.<uuid>`, and a
69
+ short-lived `duplicate.intent.<key>` during a duplication). Every write is a
70
+ compare-and-swap on `updated_at` (`VisibilityConflictError`). None of these
71
+ keys can be reached by the generic settings save or reset.
72
+ - **New exports:**
73
+ - `./cms/page-visibility`;
74
+ - `./cms/booking-links`;
75
+ - `./server/page-visibility-core`;
76
+ - `./navigation/visibility`;
77
+ - `./site/link-visibility`;
78
+ - `./site/route-aliases`;
79
+ - `./backoffice/BookingSettingsCard`.
80
+
81
+ The new host wiring is optional (`pages.fns.setPageListing`, `booking.fns`,
82
+ `useLinkVisibility`); without it the new actions and the Booking card are
83
+ not shown. See `docs/PAGES.md`.
84
+
11
85
  ## 0.94.0
12
86
 
13
87
  - **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.95.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