@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 +74 -0
- package/docs/PAGES.md +72 -4
- package/package.json +8 -1
- package/src/admin/describe-log.ts +3 -0
- package/src/backoffice/BookingSettingsCard.tsx +252 -0
- package/src/backoffice/NavigationView.tsx +38 -0
- package/src/backoffice/PageBuilderView.tsx +149 -3
- package/src/backoffice/PagesListView.tsx +173 -57
- package/src/backoffice/SiteSettingsView.tsx +4 -0
- package/src/backoffice/StatusBadge.tsx +38 -2
- package/src/backoffice/media/ConfirmDialog.tsx +19 -1
- package/src/backoffice/routes/host.ts +24 -0
- package/src/backoffice/routes/index.tsx +54 -0
- package/src/backoffice/routes/pages.tsx +13 -0
- package/src/cms/booking-links.ts +176 -0
- package/src/cms/page-publication.ts +11 -6
- package/src/cms/page-visibility.ts +206 -0
- package/src/html/sanitize-html.ts +1 -1
- package/src/navigation/visibility.ts +235 -0
- package/src/popups/runtime.tsx +155 -4
- package/src/server/page-visibility-core.ts +450 -0
- package/src/server/pages-core.ts +238 -9
- package/src/server/published-cache.ts +23 -3
- package/src/server/schemas.ts +17 -0
- package/src/site/PageRenderer.tsx +34 -5
- package/src/site/SiteFooter.tsx +11 -2
- package/src/site/SiteHeader.tsx +41 -21
- package/src/site/link-visibility.tsx +28 -0
- package/src/site/route-aliases.ts +74 -0
- package/src/site/routes/preview-page.tsx +39 -3
- package/src/site/routes/public-page.tsx +81 -9
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` |
|
|
96
|
-
| `scheduled` | Scheduled to publish |
|
|
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
|
|
99
|
-
| `expired` |
|
|
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.
|
|
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
|