@bygghammar/cms-platform 0.93.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 +141 -0
- package/docs/PAGES.md +72 -4
- package/package.json +9 -1
- package/src/admin/describe-log.ts +3 -0
- package/src/admin/module-inventory.ts +67 -0
- package/src/admin/registry-metadata.ts +256 -0
- package/src/admin/semver.ts +111 -0
- package/src/admin/version-core.ts +233 -25
- package/src/admin/version-report.ts +295 -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/VersionView.tsx +747 -66
- package/src/backoffice/media/ConfirmDialog.tsx +19 -1
- package/src/backoffice/routes/host.ts +24 -0
- package/src/backoffice/routes/index.tsx +148 -9
- package/src/backoffice/routes/pages.tsx +13 -0
- package/src/backoffice/shell/use-resolved-config.tsx +30 -5
- 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/config/modules.ts +11 -0
- package/src/html/sanitize-html.ts +1 -1
- package/src/navigation/visibility.ts +235 -0
- package/src/popups/package-name.ts +8 -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
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version screen's data contract, and the update policy it is judged by.
|
|
3
|
+
*
|
|
4
|
+
* Neutral on purpose: the server builds a `VersionReport` (see `version-core`)
|
|
5
|
+
* and the backoffice renders it, so the types and the pure decisions live here,
|
|
6
|
+
* in a module both sides may import. Nothing in this file reads the network,
|
|
7
|
+
* the environment or the database.
|
|
8
|
+
*
|
|
9
|
+
* Update policy, stated once:
|
|
10
|
+
*
|
|
11
|
+
* - Versions are ordered by semver precedence, never as strings and never by
|
|
12
|
+
* publication date.
|
|
13
|
+
* - A release install (`0.93.0`) follows the **stable** channel: the newest
|
|
14
|
+
* published, non-deprecated release version. Prereleases never count as
|
|
15
|
+
* updates for it, whatever their dist-tag.
|
|
16
|
+
* - A prerelease install (`0.2.0-m7.9`) follows its **prerelease line**: the
|
|
17
|
+
* same `major.minor.patch` and first prerelease identifier (`0.2.0-m7`).
|
|
18
|
+
* Being the newest of that line is reported as "prerelease installed", not
|
|
19
|
+
* "up to date". A newer stable release, if one exists, is reported beside it
|
|
20
|
+
* as advisory information, never as the line's update.
|
|
21
|
+
* - An installed version the registry does not list is "not published" or
|
|
22
|
+
* "ahead of registry"; an unknown or unparseable installed version, or no
|
|
23
|
+
* registry answer at all, is never called up to date.
|
|
24
|
+
*/
|
|
25
|
+
import { compareSemver, isPrerelease, parseSemver, prereleaseLine, type SemVer } from "./semver";
|
|
26
|
+
|
|
27
|
+
/** One published version, as the registry describes it. */
|
|
28
|
+
export type RegistryVersion = {
|
|
29
|
+
readonly publishedAt: string | null;
|
|
30
|
+
readonly deprecated: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** The subset of registry metadata the version screen uses. */
|
|
34
|
+
export type RegistryMetadata = {
|
|
35
|
+
readonly distTags: Readonly<Record<string, string>>;
|
|
36
|
+
/** Keyed by normalized version (`major.minor.patch[-prerelease]`). */
|
|
37
|
+
readonly versions: Readonly<Record<string, RegistryVersion>>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Fixed, non-sensitive reasons a registry lookup did not produce metadata. */
|
|
41
|
+
export type RegistryError =
|
|
42
|
+
| "timeout"
|
|
43
|
+
| "network"
|
|
44
|
+
| "http"
|
|
45
|
+
| "not-found"
|
|
46
|
+
| "malformed"
|
|
47
|
+
| "too-large"
|
|
48
|
+
| "not-allowed";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `fresh`: the most recent attempt succeeded, within the cache lifetime.
|
|
52
|
+
* `stale`: an older successful answer is being shown, because the most recent
|
|
53
|
+
* attempt failed (see `error`) or the answer outlived the cache lifetime; it is
|
|
54
|
+
* cached, not re-verified. `unavailable`: no successful answer is held.
|
|
55
|
+
*/
|
|
56
|
+
export type RegistryCheckState = "fresh" | "stale" | "unavailable";
|
|
57
|
+
|
|
58
|
+
export type RegistryCheck = {
|
|
59
|
+
readonly state: RegistryCheckState;
|
|
60
|
+
/** When the registry last answered with usable metadata (ISO 8601). */
|
|
61
|
+
readonly lastSuccessAt: string | null;
|
|
62
|
+
/** When the most recent lookup was attempted, successful or not (ISO 8601). */
|
|
63
|
+
readonly lastAttemptAt: string | null;
|
|
64
|
+
/** Why the most recent attempt failed, if it did. */
|
|
65
|
+
readonly error: RegistryError | null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type UpdateStatus =
|
|
69
|
+
| "up-to-date"
|
|
70
|
+
| "update-available"
|
|
71
|
+
| "prerelease-current"
|
|
72
|
+
| "check-unavailable"
|
|
73
|
+
| "not-published"
|
|
74
|
+
| "ahead-of-registry"
|
|
75
|
+
| "installed-unknown"
|
|
76
|
+
| "installed-invalid";
|
|
77
|
+
|
|
78
|
+
export type UpdateChannel = { kind: "stable" } | { kind: "prerelease"; line: string };
|
|
79
|
+
|
|
80
|
+
export type PublishedVersion = { version: string; publishedAt: string | null };
|
|
81
|
+
|
|
82
|
+
export type PackageUpdate = {
|
|
83
|
+
readonly status: UpdateStatus;
|
|
84
|
+
readonly channel: UpdateChannel | null;
|
|
85
|
+
/** Publication date of the installed version, when the registry lists it. */
|
|
86
|
+
readonly installedPublishedAt: string | null;
|
|
87
|
+
readonly installedDeprecated: boolean;
|
|
88
|
+
/** Newest published, non-deprecated version in the installed version's channel. */
|
|
89
|
+
readonly latestInChannel: PublishedVersion | null;
|
|
90
|
+
/** For a prerelease install: the newest stable release newer than it. Advisory only. */
|
|
91
|
+
readonly newerStable: PublishedVersion | null;
|
|
92
|
+
/** Registry dist-tags that currently point at the installed version. */
|
|
93
|
+
readonly installedTags: readonly string[];
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type PackageRole = "cms" | "module";
|
|
97
|
+
|
|
98
|
+
/** One first-party package as installed in this build, with its registry verdict. */
|
|
99
|
+
export type PackageReport = {
|
|
100
|
+
readonly name: string;
|
|
101
|
+
readonly role: PackageRole;
|
|
102
|
+
/** Exact version in the build's installed dependency tree. */
|
|
103
|
+
readonly installed: string | null;
|
|
104
|
+
/** What the site's own manifest declares for it (a range or exact version). */
|
|
105
|
+
readonly declared: string | null;
|
|
106
|
+
/** The range the installed CMS package declares for it, for module packages. */
|
|
107
|
+
readonly requiredByCms: string | null;
|
|
108
|
+
readonly registry: RegistryCheck;
|
|
109
|
+
readonly update: PackageUpdate;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export type ReleaseIdentity = {
|
|
113
|
+
/** The deployed release, e.g. a full commit SHA. `null` when not reported. */
|
|
114
|
+
readonly value: string | null;
|
|
115
|
+
readonly kind: "commit" | "label" | null;
|
|
116
|
+
/** The runtime variable it was read from. */
|
|
117
|
+
readonly source: string | null;
|
|
118
|
+
/** True when a value was present but not in a recognised format (it is not echoed). */
|
|
119
|
+
readonly unrecognized: boolean;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export type ReleaseHistoryEntry = {
|
|
123
|
+
readonly version: string;
|
|
124
|
+
readonly publishedAt: string | null;
|
|
125
|
+
readonly prerelease: boolean;
|
|
126
|
+
readonly current: boolean;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export type VersionReport = {
|
|
130
|
+
readonly schemaVersion: 1;
|
|
131
|
+
/** When the server assembled this report (ISO 8601). */
|
|
132
|
+
readonly generatedAt: string;
|
|
133
|
+
readonly release: ReleaseIdentity;
|
|
134
|
+
readonly environment: { readonly name: string | null; readonly origin: string | null };
|
|
135
|
+
/** When the server bundle was built. Not a deployment time. */
|
|
136
|
+
readonly builtAt: string | null;
|
|
137
|
+
/** When this release went live, only if the host has real evidence of it. */
|
|
138
|
+
readonly deployedAt: string | null;
|
|
139
|
+
/** The CMS package first, then module packages. */
|
|
140
|
+
readonly packages: readonly PackageReport[];
|
|
141
|
+
/** Recent CMS package publications, newest publication first. */
|
|
142
|
+
readonly releaseHistory: readonly ReleaseHistoryEntry[];
|
|
143
|
+
/** The earliest time a manual "check again" will reach the registry (ISO 8601). */
|
|
144
|
+
readonly nextCheckAllowedAt: string | null;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
type Published = { sv: SemVer; info: RegistryVersion };
|
|
148
|
+
|
|
149
|
+
function maxOf(list: readonly Published[]): Published | null {
|
|
150
|
+
let best: Published | null = null;
|
|
151
|
+
for (const p of list) if (!best || compareSemver(p.sv, best.sv) > 0) best = p;
|
|
152
|
+
return best;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function asPublished(p: Published | null): PublishedVersion | null {
|
|
156
|
+
return p ? { version: p.sv.version, publishedAt: p.info.publishedAt } : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function inChannel(sv: SemVer, channel: UpdateChannel): boolean {
|
|
160
|
+
return channel.kind === "stable" ? !isPrerelease(sv) : prereleaseLine(sv) === channel.line;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const EMPTY_UPDATE = {
|
|
164
|
+
channel: null,
|
|
165
|
+
installedPublishedAt: null,
|
|
166
|
+
installedDeprecated: false,
|
|
167
|
+
latestInChannel: null,
|
|
168
|
+
newerStable: null,
|
|
169
|
+
installedTags: [],
|
|
170
|
+
} as const;
|
|
171
|
+
|
|
172
|
+
/** Judge one installed version against registry metadata, by the policy above. */
|
|
173
|
+
export function evaluatePackageUpdate(
|
|
174
|
+
installedRaw: string | null | undefined,
|
|
175
|
+
metadata: RegistryMetadata | null | undefined,
|
|
176
|
+
): PackageUpdate {
|
|
177
|
+
if (!installedRaw) return { ...EMPTY_UPDATE, status: "installed-unknown" };
|
|
178
|
+
const installed = parseSemver(installedRaw);
|
|
179
|
+
if (!installed) return { ...EMPTY_UPDATE, status: "installed-invalid" };
|
|
180
|
+
|
|
181
|
+
const line = prereleaseLine(installed);
|
|
182
|
+
const channel: UpdateChannel = line ? { kind: "prerelease", line } : { kind: "stable" };
|
|
183
|
+
if (!metadata) return { ...EMPTY_UPDATE, channel, status: "check-unavailable" };
|
|
184
|
+
|
|
185
|
+
const published: Published[] = [];
|
|
186
|
+
for (const [key, info] of Object.entries(metadata.versions)) {
|
|
187
|
+
const sv = parseSemver(key);
|
|
188
|
+
if (sv) published.push({ sv, info });
|
|
189
|
+
}
|
|
190
|
+
const own = published.find((p) => compareSemver(p.sv, installed) === 0) ?? null;
|
|
191
|
+
const live = published.filter((p) => !p.info.deprecated);
|
|
192
|
+
const latest = maxOf(live.filter((p) => inChannel(p.sv, channel)));
|
|
193
|
+
const installedTags = Object.entries(metadata.distTags)
|
|
194
|
+
.filter(([, v]) => {
|
|
195
|
+
const sv = parseSemver(v);
|
|
196
|
+
return sv !== null && compareSemver(sv, installed) === 0;
|
|
197
|
+
})
|
|
198
|
+
.map(([tag]) => tag)
|
|
199
|
+
.sort();
|
|
200
|
+
const newerStable =
|
|
201
|
+
channel.kind === "prerelease"
|
|
202
|
+
? maxOf(live.filter((p) => !isPrerelease(p.sv) && compareSemver(p.sv, installed) > 0))
|
|
203
|
+
: null;
|
|
204
|
+
|
|
205
|
+
const common = {
|
|
206
|
+
channel,
|
|
207
|
+
installedPublishedAt: own?.info.publishedAt ?? null,
|
|
208
|
+
installedDeprecated: own?.info.deprecated ?? false,
|
|
209
|
+
latestInChannel: asPublished(latest),
|
|
210
|
+
newerStable: asPublished(newerStable),
|
|
211
|
+
installedTags,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if (!own) {
|
|
215
|
+
// Not in the registry: a local or unpublished build. It is "ahead" only
|
|
216
|
+
// when it is newer than everything published in its channel (or, with an
|
|
217
|
+
// empty channel, than everything published at all).
|
|
218
|
+
const reference = latest ?? maxOf(published);
|
|
219
|
+
const ahead = reference !== null && compareSemver(installed, reference.sv) > 0;
|
|
220
|
+
return { ...common, status: ahead ? "ahead-of-registry" : "not-published" };
|
|
221
|
+
}
|
|
222
|
+
if (latest && compareSemver(installed, latest.sv) < 0) {
|
|
223
|
+
return { ...common, status: "update-available" };
|
|
224
|
+
}
|
|
225
|
+
return { ...common, status: channel.kind === "stable" ? "up-to-date" : "prerelease-current" };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Statuses that should draw an administrator's attention beyond "an update exists". */
|
|
229
|
+
const ATTENTION: ReadonlySet<UpdateStatus> = new Set([
|
|
230
|
+
"not-published",
|
|
231
|
+
"ahead-of-registry",
|
|
232
|
+
"installed-unknown",
|
|
233
|
+
"installed-invalid",
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
export type UpdateSummaryKind =
|
|
237
|
+
| "updates-available"
|
|
238
|
+
| "attention"
|
|
239
|
+
| "up-to-date"
|
|
240
|
+
| "partial"
|
|
241
|
+
| "unavailable";
|
|
242
|
+
|
|
243
|
+
export type UpdateSummary = {
|
|
244
|
+
readonly kind: UpdateSummaryKind;
|
|
245
|
+
readonly updates: readonly string[];
|
|
246
|
+
readonly prereleases: readonly string[];
|
|
247
|
+
readonly attention: readonly string[];
|
|
248
|
+
readonly unavailable: readonly string[];
|
|
249
|
+
/** Oldest "last successful check" across the packages, when every one has one. */
|
|
250
|
+
readonly checkedAt: string | null;
|
|
251
|
+
/** True when any package's registry answer is cached rather than current. */
|
|
252
|
+
readonly stale: boolean;
|
|
253
|
+
/** Packages whose most recent registry attempt failed. */
|
|
254
|
+
readonly failed: readonly string[];
|
|
255
|
+
/**
|
|
256
|
+
* True only when every package has a fresh answer from a successful most
|
|
257
|
+
* recent attempt. A verdict built on cached answers is not verified.
|
|
258
|
+
*/
|
|
259
|
+
readonly verified: boolean;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/** One overall verdict for the summary banner. */
|
|
263
|
+
export function summarizeUpdates(packages: readonly PackageReport[]): UpdateSummary {
|
|
264
|
+
const updates: string[] = [];
|
|
265
|
+
const prereleases: string[] = [];
|
|
266
|
+
const attention: string[] = [];
|
|
267
|
+
const unavailable: string[] = [];
|
|
268
|
+
for (const p of packages) {
|
|
269
|
+
const s = p.update.status;
|
|
270
|
+
if (s === "update-available") updates.push(p.name);
|
|
271
|
+
if (s === "prerelease-current" || (s === "update-available" && p.update.channel?.kind === "prerelease"))
|
|
272
|
+
prereleases.push(p.name);
|
|
273
|
+
if (ATTENTION.has(s) || p.update.installedDeprecated) attention.push(p.name);
|
|
274
|
+
if (s === "check-unavailable") unavailable.push(p.name);
|
|
275
|
+
}
|
|
276
|
+
const successes = packages.map((p) => p.registry.lastSuccessAt);
|
|
277
|
+
const checkedAt =
|
|
278
|
+
packages.length > 0 && successes.every((t): t is string => typeof t === "string")
|
|
279
|
+
? successes.reduce((a, b) => (a < b ? a : b))
|
|
280
|
+
: null;
|
|
281
|
+
const stale = packages.some((p) => p.registry.state === "stale");
|
|
282
|
+
const failed = packages.filter((p) => p.registry.error !== null).map((p) => p.name);
|
|
283
|
+
const verified =
|
|
284
|
+
packages.length > 0 &&
|
|
285
|
+
packages.every((p) => p.registry.state === "fresh" && p.registry.error === null);
|
|
286
|
+
|
|
287
|
+
let kind: UpdateSummaryKind;
|
|
288
|
+
if (packages.length === 0 || unavailable.length === packages.length) kind = "unavailable";
|
|
289
|
+
else if (updates.length > 0) kind = "updates-available";
|
|
290
|
+
else if (attention.length > 0) kind = "attention";
|
|
291
|
+
else if (unavailable.length > 0) kind = "partial";
|
|
292
|
+
else kind = "up-to-date";
|
|
293
|
+
|
|
294
|
+
return { kind, updates, prereleases, attention, unavailable, checkedAt, stale, failed, verified };
|
|
295
|
+
}
|
|
@@ -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
|