@bygghammar/cms-platform 0.96.0 → 0.97.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/docs/PAGES.md +45 -2
- package/package.json +3 -1
- package/src/admin/describe-log.ts +2 -0
- package/src/backoffice/BookingLandingSection.tsx +672 -0
- package/src/backoffice/BookingSettingsCard.tsx +34 -4
- package/src/backoffice/routes/host.ts +7 -0
- package/src/backoffice/routes/index.tsx +22 -3
- package/src/blocks/components/CountdownBlock.tsx +3 -1
- package/src/blocks/components/CtaBannerBlock.tsx +8 -2
- package/src/blocks/components/HeroFullBlock.tsx +7 -1
- package/src/blocks/components/IconGridBlock.tsx +5 -1
- package/src/blocks/components/InstagramBlock.tsx +5 -1
- package/src/blocks/components/InstagramTiles.tsx +3 -1
- package/src/blocks/components/LinkTilesBlock.tsx +8 -1
- package/src/blocks/components/MediaSplitBlock.tsx +8 -3
- package/src/blocks/components/PriceTableBlock.tsx +6 -5
- package/src/blocks/components/TripListBlock.tsx +5 -1
- package/src/cms/booking-landing.ts +359 -0
- package/src/cms/booking-links.ts +24 -0
- package/src/cms/page-visibility.ts +67 -1
- package/src/navigation/visibility.ts +65 -3
- package/src/popups/runtime.tsx +75 -32
- package/src/server/page-visibility-core.ts +231 -5
- package/src/server/schemas.ts +45 -0
- package/src/site/BookingOffNotice.tsx +73 -0
- package/src/site/PageRenderer.tsx +59 -3
- package/src/site/link-visibility.tsx +21 -2
package/src/popups/runtime.tsx
CHANGED
|
@@ -25,7 +25,8 @@ import type {
|
|
|
25
25
|
import { ManagedForm } from "../forms/FormRenderer";
|
|
26
26
|
import { onFormSubmissionSuccess } from "../forms/success-listeners";
|
|
27
27
|
import { siteConfig } from "../config/site-config";
|
|
28
|
-
import { containsBookingTarget, isBookingHref, stripBookingDeep } from "../cms/booking-links";
|
|
28
|
+
import { containsBookingTarget, containsLinkTo, isBookingHref, stripBookingDeep } from "../cms/booking-links";
|
|
29
|
+
import { isHiddenFromDiscovery, type LinkVisibilityPolicy } from "../navigation/visibility";
|
|
29
30
|
import { useLinkVisibility } from "../site/link-visibility";
|
|
30
31
|
import type { PopupPublicHost } from "./host";
|
|
31
32
|
import { popupPublicHost } from "./host";
|
|
@@ -111,14 +112,25 @@ export function PopupModuleRuntime(): ReactNode {
|
|
|
111
112
|
* it found through the same rules, and only the latest read counts, so one
|
|
112
113
|
* begun under the old policy cannot bring booking back. Without a policy
|
|
113
114
|
* provider booking is visible and nothing changes.
|
|
115
|
+
*
|
|
116
|
+
* The same goes for a popup that leads to a page hidden from the website
|
|
117
|
+
* while booking is hidden (a booking-off landing page): not offered, and
|
|
118
|
+
* retired if it is the one on screen, until the page is shown again.
|
|
114
119
|
*/
|
|
115
|
-
const
|
|
120
|
+
const linkPolicy = useLinkVisibility();
|
|
121
|
+
const bookingVisible = linkPolicy.bookingVisible;
|
|
122
|
+
const hiddenKey = hiddenPagesKey(linkPolicy);
|
|
116
123
|
const [loaded, setLoaded] = useState<readonly PopupConfig[] | null>(null);
|
|
117
124
|
const offered = useRef<Offered>(NOTHING_OFFERED);
|
|
118
125
|
const offer = useMemo(
|
|
119
|
-
() =>
|
|
120
|
-
|
|
121
|
-
|
|
126
|
+
() =>
|
|
127
|
+
popupsToOffer(loaded ?? [], offered.current, bookingVisible, (href) =>
|
|
128
|
+
isHiddenFromDiscovery(href, linkPolicy),
|
|
129
|
+
),
|
|
130
|
+
// `offered` is what the runtime was last handed, read for identity only;
|
|
131
|
+
// the policy matters only through what `hiddenKey` names.
|
|
132
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
133
|
+
[loaded, bookingVisible, hiddenKey],
|
|
122
134
|
);
|
|
123
135
|
useEffect(() => {
|
|
124
136
|
offered.current = offer;
|
|
@@ -126,14 +138,23 @@ export function PopupModuleRuntime(): ReactNode {
|
|
|
126
138
|
|
|
127
139
|
// The repository is asked again when the host or the policy changes — and
|
|
128
140
|
// only once the runtime asks for its popups, so never where it does not run.
|
|
129
|
-
const askedFor = useRef<{
|
|
141
|
+
const askedFor = useRef<{
|
|
142
|
+
configs: PopupPublicHost["configs"];
|
|
143
|
+
bookingVisible: boolean;
|
|
144
|
+
hiddenKey: string;
|
|
145
|
+
} | null>(null);
|
|
130
146
|
const latestRead = useRef(0);
|
|
131
147
|
const configs = useMemo<RuntimeAdapters["configs"]>(
|
|
132
148
|
() => ({
|
|
133
149
|
listActive: async () => {
|
|
134
150
|
const asked = askedFor.current;
|
|
135
|
-
if (
|
|
136
|
-
|
|
151
|
+
if (
|
|
152
|
+
asked === null ||
|
|
153
|
+
asked.configs !== host.configs ||
|
|
154
|
+
asked.bookingVisible !== bookingVisible ||
|
|
155
|
+
asked.hiddenKey !== hiddenKey
|
|
156
|
+
) {
|
|
157
|
+
askedFor.current = { configs: host.configs, bookingVisible, hiddenKey };
|
|
137
158
|
const read = ++latestRead.current;
|
|
138
159
|
Promise.resolve()
|
|
139
160
|
.then(() => host.configs.listActive())
|
|
@@ -152,7 +173,7 @@ export function PopupModuleRuntime(): ReactNode {
|
|
|
152
173
|
return offer.list;
|
|
153
174
|
},
|
|
154
175
|
}),
|
|
155
|
-
[host.configs, bookingVisible, offer],
|
|
176
|
+
[host.configs, bookingVisible, hiddenKey, offer],
|
|
156
177
|
);
|
|
157
178
|
|
|
158
179
|
// The argument type comes from the port, not a copy of it: a package that
|
|
@@ -189,6 +210,15 @@ type ContentBlock = PopupConfig["steps"][number]["blocks"][number];
|
|
|
189
210
|
type PopupButton = Extract<ContentBlock, { type: "buttons" }>["buttons"][number];
|
|
190
211
|
type BlockAction = PopupButton["action"];
|
|
191
212
|
|
|
213
|
+
/** The booking-off pages a policy hides, as one comparable string. */
|
|
214
|
+
function hiddenPagesKey(policy: LinkVisibilityPolicy): string {
|
|
215
|
+
return Object.entries(policy.hiddenPaths)
|
|
216
|
+
.filter(([, reason]) => reason === "booking-off")
|
|
217
|
+
.map(([path]) => path)
|
|
218
|
+
.sort()
|
|
219
|
+
.join("\n");
|
|
220
|
+
}
|
|
221
|
+
|
|
192
222
|
/** The list last handed to the runtime, and each popup's content, by key. */
|
|
193
223
|
type Offered = { list: readonly PopupConfig[]; content: ReadonlyMap<string, string> };
|
|
194
224
|
const NOTHING_OFFERED: Offered = { list: [], content: new Map() };
|
|
@@ -197,7 +227,8 @@ const NOTHING_OFFERED: Offered = { list: [], content: new Map() };
|
|
|
197
227
|
* What the runtime is handed from a load, given what it already holds.
|
|
198
228
|
*
|
|
199
229
|
* - While booking is hidden, a popup that leads to booking is not offered: it
|
|
200
|
-
* is handed over retired (see {@link retire}).
|
|
230
|
+
* is handed over retired (see {@link retire}). So is a popup that leads to
|
|
231
|
+
* a page hidden while booking is hidden (`hiddenPage`).
|
|
201
232
|
* - A popup the runtime already holds is never dropped, even when the load no
|
|
202
233
|
* longer has it (a server that withholds booking popups, a popup paused or
|
|
203
234
|
* deleted since). The runtime tracks the open popup by key, so dropping the
|
|
@@ -213,7 +244,11 @@ function popupsToOffer(
|
|
|
213
244
|
loaded: readonly PopupConfig[],
|
|
214
245
|
previous: Offered,
|
|
215
246
|
bookingVisible: boolean,
|
|
247
|
+
hiddenPage: (href: string) => boolean,
|
|
216
248
|
): Offered {
|
|
249
|
+
const hideBooking = !bookingVisible;
|
|
250
|
+
const withheld = (popup: PopupConfig) =>
|
|
251
|
+
(hideBooking && containsBookingTarget(popup)) || containsLinkTo(popup, hiddenPage);
|
|
217
252
|
const held = new Map(previous.list.map((popup) => [popup.key, popup]));
|
|
218
253
|
const content = new Map<string, string>();
|
|
219
254
|
const list: PopupConfig[] = [];
|
|
@@ -224,10 +259,10 @@ function popupsToOffer(
|
|
|
224
259
|
list.push(same !== undefined && previous.content.get(popup.key) === text ? same : popup);
|
|
225
260
|
};
|
|
226
261
|
for (const popup of loaded) {
|
|
227
|
-
offer(
|
|
262
|
+
offer(withheld(popup) ? retire(popup, hideBooking, hiddenPage) : popup);
|
|
228
263
|
}
|
|
229
264
|
for (const popup of previous.list) {
|
|
230
|
-
if (!content.has(popup.key)) offer(retire(popup,
|
|
265
|
+
if (!content.has(popup.key)) offer(retire(popup, hideBooking, hiddenPage));
|
|
231
266
|
}
|
|
232
267
|
const unchanged =
|
|
233
268
|
list.length === previous.list.length && list.every((popup, index) => popup === previous.list[index]);
|
|
@@ -237,43 +272,51 @@ function popupsToOffer(
|
|
|
237
272
|
/**
|
|
238
273
|
* A popup the runtime keeps but never offers again.
|
|
239
274
|
*
|
|
240
|
-
* Paused, which the runtime neither shows nor reports as suppressed.
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
275
|
+
* Paused, which the runtime neither shows nor reports as suppressed. Every
|
|
276
|
+
* place in it that leads to booking (with `hideBooking`) or to a page hidden
|
|
277
|
+
* while booking is hidden (`hiddenPage`) is taken out too: links and
|
|
278
|
+
* buttons go, an answer or a form that would open it now simply closes. So if
|
|
279
|
+
* it is the one on screen, the visitor keeps what they were reading, cannot
|
|
280
|
+
* follow it anywhere it must not lead, and closes it the usual way. The
|
|
281
|
+
* stored popup is not touched; this is the copy the visitor sees.
|
|
246
282
|
*/
|
|
247
|
-
function retire(
|
|
283
|
+
function retire(
|
|
284
|
+
popup: PopupConfig,
|
|
285
|
+
hideBooking: boolean,
|
|
286
|
+
hiddenPage: (href: string) => boolean,
|
|
287
|
+
): PopupConfig {
|
|
288
|
+
const blocked = (href: string) => (hideBooking && isBookingHref(href)) || hiddenPage(href);
|
|
248
289
|
const paused: PopupConfig = { ...popup, status: "paused" };
|
|
249
|
-
|
|
250
|
-
return stripBookingDeep({
|
|
290
|
+
const cleaned: PopupConfig = {
|
|
251
291
|
...paused,
|
|
252
|
-
steps: paused.steps.map((step) => ({
|
|
253
|
-
|
|
292
|
+
steps: paused.steps.map((step) => ({
|
|
293
|
+
...step,
|
|
294
|
+
blocks: step.blocks.flatMap((block) => withoutTargets(block, blocked)),
|
|
295
|
+
})),
|
|
296
|
+
};
|
|
297
|
+
// Booking links inside text are removed as before, and only for booking.
|
|
298
|
+
return hideBooking ? stripBookingDeep(cleaned) : cleaned;
|
|
254
299
|
}
|
|
255
300
|
|
|
256
|
-
function
|
|
301
|
+
function withoutTargets(block: ContentBlock, blocked: (href: string) => boolean): ContentBlock[] {
|
|
302
|
+
const opens = (action: BlockAction) => action.kind === "openUrl" && blocked(action.href);
|
|
303
|
+
const closingInstead = (button: PopupButton): PopupButton =>
|
|
304
|
+
opens(button.action) ? { ...button, action: { kind: "close" } } : button;
|
|
257
305
|
switch (block.type) {
|
|
258
306
|
case "link":
|
|
259
|
-
return
|
|
307
|
+
return blocked(block.href) ? [] : [block];
|
|
260
308
|
case "buttons": {
|
|
261
|
-
const buttons = block.buttons.filter((button) => !
|
|
309
|
+
const buttons = block.buttons.filter((button) => !opens(button.action));
|
|
262
310
|
return buttons.length === 0 ? [] : [{ ...block, buttons }];
|
|
263
311
|
}
|
|
264
312
|
case "choice":
|
|
265
313
|
return [{ ...block, yes: closingInstead(block.yes), no: closingInstead(block.no) }];
|
|
266
314
|
case "form":
|
|
267
|
-
return [
|
|
315
|
+
return [opens(block.onSuccess) ? { ...block, onSuccess: { kind: "close" } } : block];
|
|
268
316
|
default:
|
|
269
317
|
return [block];
|
|
270
318
|
}
|
|
271
319
|
}
|
|
272
320
|
|
|
273
|
-
const opensBooking = (action: BlockAction) => action.kind === "openUrl" && isBookingHref(action.href);
|
|
274
|
-
|
|
275
|
-
const closingInstead = (button: PopupButton): PopupButton =>
|
|
276
|
-
opensBooking(button.action) ? { ...button, action: { kind: "close" } } : button;
|
|
277
|
-
|
|
278
321
|
// Default export so the module host can load this file lazily.
|
|
279
322
|
export default PopupModuleRuntime;
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* | `page.listing.<page uuid>` | `{ "listing": "link-only" }` | listed |
|
|
12
12
|
* | `booking` | `{ "enabled": false }` | booking on |
|
|
13
13
|
* | `booking.exception.<uuid>` | `{ "exception": true }` | no exception |
|
|
14
|
+
* | `booking.landing.<uuid>` | `{ "landing": true, "notice"?: … }` | not a landing page |
|
|
15
|
+
* | `booking.landing.default` | `{ "notice": … }` | built-in copy |
|
|
16
|
+
*
|
|
17
|
+
* A landing page leaves the site's menus and page lists while booking is off
|
|
18
|
+
* and stays live and indexable (see `cms/booking-landing`).
|
|
14
19
|
*
|
|
15
20
|
* Keys use the page's id, never its key or address, so every alias and locale
|
|
16
21
|
* of a page shares one decision, a restored page keeps its own, and a page
|
|
@@ -25,6 +30,14 @@
|
|
|
25
30
|
* None of these rows is an `OVERRIDABLE_SETTING_KEYS` key, so the generic
|
|
26
31
|
* settings save and reset cannot read, overwrite or erase them.
|
|
27
32
|
*/
|
|
33
|
+
import {
|
|
34
|
+
NoticeValidationError,
|
|
35
|
+
noticeLinkHrefs,
|
|
36
|
+
normalizeNoticeContent,
|
|
37
|
+
normalizePageNoticeOverride,
|
|
38
|
+
type NoticeContent,
|
|
39
|
+
type PageNoticeOverride,
|
|
40
|
+
} from "../cms/booking-landing";
|
|
28
41
|
import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
|
|
29
42
|
import {
|
|
30
43
|
nextPublicationBoundary,
|
|
@@ -49,6 +62,8 @@ export const SITE_SETTINGS = "site_settings";
|
|
|
49
62
|
export const PAGE_LISTING_PREFIX = "page.listing.";
|
|
50
63
|
export const BOOKING_SETTING_KEY = "booking";
|
|
51
64
|
export const BOOKING_EXCEPTION_PREFIX = "booking.exception.";
|
|
65
|
+
export const BOOKING_LANDING_PREFIX = "booking.landing.";
|
|
66
|
+
export const BOOKING_LANDING_DEFAULT_KEY = "booking.landing.default";
|
|
52
67
|
|
|
53
68
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
54
69
|
|
|
@@ -63,8 +78,28 @@ export type VisibilityDeps = {
|
|
|
63
78
|
* by the browser.
|
|
64
79
|
*/
|
|
65
80
|
protectedPageKeys: readonly string[];
|
|
81
|
+
/**
|
|
82
|
+
* The host's check of a booking-off notice link against its own routes:
|
|
83
|
+
* why the link may not be used (it leads to the host's booking page, or to a
|
|
84
|
+
* page hidden while booking is hidden), or null. Supplied by the host's
|
|
85
|
+
* server code. Without it only the platform's own rules apply (no booking
|
|
86
|
+
* site, safe address forms).
|
|
87
|
+
*/
|
|
88
|
+
noticeTargetProblem?: (href: string) => string | null | Promise<string | null>;
|
|
66
89
|
};
|
|
67
90
|
|
|
91
|
+
/** Refuse a notice whose link the host says leads to booking or to a hidden page. */
|
|
92
|
+
async function assertNoticeTargets(
|
|
93
|
+
deps: Pick<VisibilityDeps, "noticeTargetProblem">,
|
|
94
|
+
notice: NoticeContent | PageNoticeOverride,
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
if (!deps.noticeTargetProblem) return;
|
|
97
|
+
for (const href of noticeLinkHrefs(notice)) {
|
|
98
|
+
const problem = await deps.noticeTargetProblem(href);
|
|
99
|
+
if (problem) throw new NoticeValidationError(problem);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
68
103
|
/** Someone else changed the same decision since the editor last looked. */
|
|
69
104
|
export class VisibilityConflictError extends Error {
|
|
70
105
|
readonly code = "visibility_conflict";
|
|
@@ -96,10 +131,31 @@ export async function readVisibilitySettingsCore(client: any): Promise<Visibilit
|
|
|
96
131
|
booking: { enabled: true, version: null },
|
|
97
132
|
listings: new Map(),
|
|
98
133
|
exceptions: new Map(),
|
|
134
|
+
landings: new Map(),
|
|
135
|
+
landingDefault: { notice: null, version: null },
|
|
99
136
|
};
|
|
100
137
|
for (const row of (data ?? []) as SettingRow[]) {
|
|
101
138
|
const version = row.updated_at ?? null;
|
|
102
|
-
if (row.key ===
|
|
139
|
+
if (row.key === BOOKING_LANDING_DEFAULT_KEY) {
|
|
140
|
+
// Stored copy is read leniently: a field that no longer validates is
|
|
141
|
+
// left out rather than failing every public page.
|
|
142
|
+
const notice = record(row.value)["notice"];
|
|
143
|
+
settings.landingDefault = {
|
|
144
|
+
notice: notice == null ? null : normalizeNoticeContent(notice, false),
|
|
145
|
+
version,
|
|
146
|
+
};
|
|
147
|
+
} else if (row.key.startsWith(BOOKING_LANDING_PREFIX)) {
|
|
148
|
+
const pageId = row.key.slice(BOOKING_LANDING_PREFIX.length);
|
|
149
|
+
if (UUID.test(pageId)) {
|
|
150
|
+
const value = record(row.value);
|
|
151
|
+
settings.landings.set(pageId.toLowerCase(), {
|
|
152
|
+
// Only an explicit `true` makes a landing page.
|
|
153
|
+
landing: value["landing"] === true,
|
|
154
|
+
notice: normalizePageNoticeOverride(value["notice"], false),
|
|
155
|
+
version,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
} else if (row.key === BOOKING_SETTING_KEY) {
|
|
103
159
|
// Only an explicit `false` turns booking off; anything else is today's behaviour.
|
|
104
160
|
settings.booking = { enabled: record(row.value)["enabled"] !== false, version };
|
|
105
161
|
} else if (row.key.startsWith(PAGE_LISTING_PREFIX)) {
|
|
@@ -127,6 +183,8 @@ export type VisibilitySettings = {
|
|
|
127
183
|
booking: { enabled: boolean; version: VersionToken };
|
|
128
184
|
listings: Map<string, { listing: PageListing; version: VersionToken }>;
|
|
129
185
|
exceptions: Map<string, { exception: boolean; version: VersionToken }>;
|
|
186
|
+
landings: Map<string, { landing: boolean; notice: PageNoticeOverride; version: VersionToken }>;
|
|
187
|
+
landingDefault: { notice: NoticeContent | null; version: VersionToken };
|
|
130
188
|
};
|
|
131
189
|
|
|
132
190
|
/**
|
|
@@ -304,7 +362,14 @@ export async function setBookingExceptionCore(
|
|
|
304
362
|
if (!UUID.test(input.pageId)) throw new Error("Page not found");
|
|
305
363
|
// Adding needs a page that exists; removing works even for one in the trash.
|
|
306
364
|
let name = "this page";
|
|
307
|
-
if (input.exception)
|
|
365
|
+
if (input.exception) {
|
|
366
|
+
name = (await readLivePage(context.supabase, input.pageId)).name;
|
|
367
|
+
// A page cannot both keep its booking links and be a booking-off landing page.
|
|
368
|
+
const settings = await readVisibilitySettingsCore(context.supabase);
|
|
369
|
+
if (settings.landings.get(input.pageId.toLowerCase())?.landing === true) {
|
|
370
|
+
throw new BookingConflictError(name, "landing");
|
|
371
|
+
}
|
|
372
|
+
}
|
|
308
373
|
invalidatePublishedCache();
|
|
309
374
|
const version = await compareAndSwap(
|
|
310
375
|
context.supabase,
|
|
@@ -329,6 +394,114 @@ export async function setBookingExceptionCore(
|
|
|
329
394
|
return { exception: input.exception, version };
|
|
330
395
|
}
|
|
331
396
|
|
|
397
|
+
/** Thrown when one page would both keep its booking links and be a booking-off landing page. */
|
|
398
|
+
export class BookingConflictError extends Error {
|
|
399
|
+
readonly code = "booking_conflict";
|
|
400
|
+
constructor(name: string, already: "exception" | "landing") {
|
|
401
|
+
super(
|
|
402
|
+
already === "exception"
|
|
403
|
+
? `“${name}” keeps its booking links while booking is hidden, so it cannot also be hidden ` +
|
|
404
|
+
"from the website. Remove it from “Pages that keep their booking links” first."
|
|
405
|
+
: `“${name}” is hidden from the website while booking is hidden, so it cannot also keep ` +
|
|
406
|
+
"its booking links. Remove it from “Pages hidden from the website while booking is hidden” first.",
|
|
407
|
+
);
|
|
408
|
+
this.name = "BookingConflictError";
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Add a page to, or remove it from, the booking-off landing pages, or change
|
|
414
|
+
* its own notice.
|
|
415
|
+
*
|
|
416
|
+
* Adding needs a page that exists, is not protected (the start page and the
|
|
417
|
+
* contact page always stay in the menus) and does not keep its booking links.
|
|
418
|
+
* Removing works even for a page in the trash, and drops its own notice.
|
|
419
|
+
* Listing, publication, schedules and indexing are not touched: the page stays
|
|
420
|
+
* exactly as reachable and indexable as it was.
|
|
421
|
+
*/
|
|
422
|
+
export async function setBookingLandingCore(
|
|
423
|
+
deps: VisibilityDeps,
|
|
424
|
+
input: {
|
|
425
|
+
pageId: string;
|
|
426
|
+
landing: boolean;
|
|
427
|
+
notice?: unknown;
|
|
428
|
+
expectedVersion: VersionToken;
|
|
429
|
+
},
|
|
430
|
+
): Promise<{ landing: boolean; version: string }> {
|
|
431
|
+
const { context } = deps;
|
|
432
|
+
await assertStaff(context);
|
|
433
|
+
if (!UUID.test(input.pageId)) throw new Error("Page not found");
|
|
434
|
+
const pageId = input.pageId.toLowerCase();
|
|
435
|
+
let name = "this page";
|
|
436
|
+
let value: Record<string, unknown> = { landing: false };
|
|
437
|
+
if (input.landing) {
|
|
438
|
+
const page = await readLivePage(context.supabase, pageId);
|
|
439
|
+
name = page.name;
|
|
440
|
+
assertNotProtected(deps.protectedPageKeys, page, "hidden from the website while booking is hidden");
|
|
441
|
+
// Validated here as well as in the request schema: the core is the authority.
|
|
442
|
+
const notice = normalizePageNoticeOverride(input.notice ?? null, true);
|
|
443
|
+
await assertNoticeTargets(deps, notice);
|
|
444
|
+
const settings = await readVisibilitySettingsCore(context.supabase);
|
|
445
|
+
if (settings.exceptions.get(pageId)?.exception === true) {
|
|
446
|
+
throw new BookingConflictError(name, "exception");
|
|
447
|
+
}
|
|
448
|
+
value = Object.keys(notice).length > 0 ? { landing: true, notice } : { landing: true };
|
|
449
|
+
}
|
|
450
|
+
invalidatePublishedCache();
|
|
451
|
+
const version = await compareAndSwap(
|
|
452
|
+
context.supabase,
|
|
453
|
+
`${BOOKING_LANDING_PREFIX}${pageId}`,
|
|
454
|
+
value,
|
|
455
|
+
input.expectedVersion,
|
|
456
|
+
context.userId,
|
|
457
|
+
`whether ${input.landing ? `“${name}”` : name} is hidden while booking is hidden`,
|
|
458
|
+
);
|
|
459
|
+
invalidatePublishedCache();
|
|
460
|
+
await auditAs(context, {
|
|
461
|
+
action: "booking.landing",
|
|
462
|
+
entity: "pages",
|
|
463
|
+
entityId: pageId,
|
|
464
|
+
meta: {
|
|
465
|
+
landing: input.landing,
|
|
466
|
+
ownNotice: input.landing && "notice" in value,
|
|
467
|
+
description: input.landing
|
|
468
|
+
? `Set "${name}" to leave the website's menus and page lists while booking is hidden; it stays reachable and indexable.`
|
|
469
|
+
: "Removed a page from the pages hidden from the website while booking is hidden.",
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
return { landing: input.landing, version };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Save the site-wide booking-off notice. */
|
|
476
|
+
export async function setBookingLandingDefaultCore(
|
|
477
|
+
deps: Pick<VisibilityDeps, "context" | "noticeTargetProblem">,
|
|
478
|
+
input: { notice: unknown; expectedVersion: VersionToken },
|
|
479
|
+
): Promise<{ version: string }> {
|
|
480
|
+
const { context } = deps;
|
|
481
|
+
await assertStaff(context);
|
|
482
|
+
const notice = normalizeNoticeContent(input.notice, true);
|
|
483
|
+
await assertNoticeTargets(deps, notice);
|
|
484
|
+
invalidatePublishedCache();
|
|
485
|
+
const version = await compareAndSwap(
|
|
486
|
+
context.supabase,
|
|
487
|
+
BOOKING_LANDING_DEFAULT_KEY,
|
|
488
|
+
{ notice },
|
|
489
|
+
input.expectedVersion,
|
|
490
|
+
context.userId,
|
|
491
|
+
"the booking-off notice",
|
|
492
|
+
);
|
|
493
|
+
invalidatePublishedCache();
|
|
494
|
+
await auditAs(context, {
|
|
495
|
+
action: "booking.landing.default",
|
|
496
|
+
entity: SITE_SETTINGS,
|
|
497
|
+
entityId: BOOKING_LANDING_DEFAULT_KEY,
|
|
498
|
+
meta: {
|
|
499
|
+
description: "Changed the notice booking-off landing pages show while booking is hidden.",
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
return { version };
|
|
503
|
+
}
|
|
504
|
+
|
|
332
505
|
/** Booking setting and its exception pages, for the settings screen. */
|
|
333
506
|
export type BookingSettingsView = {
|
|
334
507
|
enabled: boolean;
|
|
@@ -346,6 +519,28 @@ export type BookingSettingsView = {
|
|
|
346
519
|
* ones: adding a page back has to send the version of the row it left.
|
|
347
520
|
*/
|
|
348
521
|
rowVersions: Record<string, VersionToken>;
|
|
522
|
+
/** Booking-off landing pages and their notice. */
|
|
523
|
+
landing: {
|
|
524
|
+
/** The stored default notice (empty fields use the built-in copy), and its version. */
|
|
525
|
+
default: { notice: NoticeContent | null; version: VersionToken };
|
|
526
|
+
pages: Array<{
|
|
527
|
+
pageId: string;
|
|
528
|
+
key: string | null;
|
|
529
|
+
name: string | null;
|
|
530
|
+
/** The page is in the trash or gone: the setting does nothing until restored. */
|
|
531
|
+
missing: boolean;
|
|
532
|
+
notice: PageNoticeOverride;
|
|
533
|
+
version: VersionToken;
|
|
534
|
+
/**
|
|
535
|
+
* Stored as a booking exception too. It is treated as an exception only
|
|
536
|
+
* — it keeps its booking links and stays in the menus — until somebody
|
|
537
|
+
* removes it from one of the lists.
|
|
538
|
+
*/
|
|
539
|
+
conflict: boolean;
|
|
540
|
+
}>;
|
|
541
|
+
/** Versions of every stored landing row by page id, removed ones included. */
|
|
542
|
+
rowVersions: Record<string, VersionToken>;
|
|
543
|
+
};
|
|
349
544
|
};
|
|
350
545
|
|
|
351
546
|
export async function readBookingSettingsCore(
|
|
@@ -355,17 +550,40 @@ export async function readBookingSettingsCore(
|
|
|
355
550
|
await assertStaff(context);
|
|
356
551
|
const settings = await readVisibilitySettingsCore(context.supabase);
|
|
357
552
|
const ids = [...settings.exceptions].filter(([, row]) => row.exception).map(([id]) => id);
|
|
553
|
+
const landingIds = [...settings.landings].filter(([, row]) => row.landing).map(([id]) => id);
|
|
358
554
|
const pages = new Map<string, PageRow>();
|
|
359
|
-
|
|
555
|
+
const wanted = [...new Set([...ids, ...landingIds])];
|
|
556
|
+
if (wanted.length > 0) {
|
|
360
557
|
const { data, error } = await context.supabase
|
|
361
558
|
.from("pages")
|
|
362
559
|
.select("id,key,name")
|
|
363
|
-
.in("id",
|
|
560
|
+
.in("id", wanted)
|
|
364
561
|
.is("deleted_at", null);
|
|
365
562
|
if (error) throw new Error(error.message);
|
|
366
563
|
for (const row of (data ?? []) as PageRow[]) pages.set(row.id.toLowerCase(), row);
|
|
367
564
|
}
|
|
565
|
+
const byName = (a: { name: string | null }, b: { name: string | null }) =>
|
|
566
|
+
(a.name ?? "~").localeCompare(b.name ?? "~");
|
|
368
567
|
return {
|
|
568
|
+
landing: {
|
|
569
|
+
default: settings.landingDefault,
|
|
570
|
+
pages: landingIds
|
|
571
|
+
.map((pageId) => {
|
|
572
|
+
const page = pages.get(pageId);
|
|
573
|
+
const row = settings.landings.get(pageId)!;
|
|
574
|
+
return {
|
|
575
|
+
pageId,
|
|
576
|
+
key: page?.key ?? null,
|
|
577
|
+
name: page?.name ?? null,
|
|
578
|
+
missing: !page,
|
|
579
|
+
notice: row.notice,
|
|
580
|
+
version: row.version,
|
|
581
|
+
conflict: settings.exceptions.get(pageId)?.exception === true,
|
|
582
|
+
};
|
|
583
|
+
})
|
|
584
|
+
.sort(byName),
|
|
585
|
+
rowVersions: Object.fromEntries([...settings.landings].map(([id, row]) => [id, row.version])),
|
|
586
|
+
},
|
|
369
587
|
enabled: settings.booking.enabled,
|
|
370
588
|
version: settings.booking.version,
|
|
371
589
|
exceptions: ids
|
|
@@ -443,8 +661,16 @@ async function readPublicPolicy(client: any): Promise<PublicPolicySnapshot> {
|
|
|
443
661
|
live: publicVersionId(byPage.get(row.id) ?? [], now) !== null,
|
|
444
662
|
listing: settings.listings.get(id)?.listing ?? "listed",
|
|
445
663
|
bookingException: settings.exceptions.get(id)?.exception === true,
|
|
664
|
+
bookingLanding: settings.landings.get(id)?.landing === true,
|
|
665
|
+
landingNotice: settings.landings.get(id)?.landing ? settings.landings.get(id)!.notice : null,
|
|
446
666
|
};
|
|
447
667
|
});
|
|
448
668
|
|
|
449
|
-
return {
|
|
669
|
+
return {
|
|
670
|
+
known: true,
|
|
671
|
+
bookingEnabled: settings.booking.enabled,
|
|
672
|
+
pages,
|
|
673
|
+
revalidateAt,
|
|
674
|
+
landingDefault: settings.landingDefault.notice,
|
|
675
|
+
};
|
|
450
676
|
}
|
package/src/server/schemas.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
|
|
14
|
+
import { NOTICE_LIMITS, parseNoticeCtaHref } from "../cms/booking-landing";
|
|
15
|
+
|
|
14
16
|
/* ------------------------------------------------------------------ shared */
|
|
15
17
|
|
|
16
18
|
export const uuid = z.string().uuid();
|
|
@@ -33,6 +35,37 @@ export const cmsKey = z
|
|
|
33
35
|
|
|
34
36
|
export const listLimit = z.object({ limit: z.number().min(1).max(500).default(200) });
|
|
35
37
|
|
|
38
|
+
/* ------------------------------------------------------- booking-off notice */
|
|
39
|
+
|
|
40
|
+
/** Text per language, bounded. Content rules (limits, links) are re-checked by the core. */
|
|
41
|
+
const noticeText = (max: number) =>
|
|
42
|
+
z.record(z.string().regex(/^[a-z]{2}(-[a-z0-9]{2,8})?$/i), z.string().max(max)).refine(
|
|
43
|
+
(value) => Object.keys(value).length <= 8,
|
|
44
|
+
"Too many languages",
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const noticeCtaSchema = z.object({
|
|
48
|
+
label: noticeText(NOTICE_LIMITS.ctaLabel),
|
|
49
|
+
href: z
|
|
50
|
+
.string()
|
|
51
|
+
.max(NOTICE_LIMITS.href)
|
|
52
|
+
.refine((href) => parseNoticeCtaHref(href).ok, {
|
|
53
|
+
message: "Use an address on this site (/…) or an https:// address that does not lead to booking.",
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const noticeSchema = z.object({
|
|
58
|
+
heading: noticeText(NOTICE_LIMITS.heading).optional(),
|
|
59
|
+
body: noticeText(NOTICE_LIMITS.body).optional(),
|
|
60
|
+
cta: noticeCtaSchema.nullable().optional(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const noticeOverrideSchema = z.object({
|
|
64
|
+
heading: noticeText(NOTICE_LIMITS.heading).optional(),
|
|
65
|
+
body: noticeText(NOTICE_LIMITS.body).optional(),
|
|
66
|
+
cta: z.union([noticeCtaSchema, z.literal("none")]).optional(),
|
|
67
|
+
});
|
|
68
|
+
|
|
36
69
|
/* ---------------------------------------------------------------- edit locks */
|
|
37
70
|
|
|
38
71
|
/**
|
|
@@ -148,6 +181,18 @@ export const pageSchemas = {
|
|
|
148
181
|
exception: z.boolean(),
|
|
149
182
|
expectedVersion: z.string().min(1).max(64).nullable(),
|
|
150
183
|
}),
|
|
184
|
+
/** A booking-off landing page, added, removed, or given its own notice. */
|
|
185
|
+
setBookingLanding: z.object({
|
|
186
|
+
pageId: uuid,
|
|
187
|
+
landing: z.boolean(),
|
|
188
|
+
notice: noticeOverrideSchema.nullable().optional(),
|
|
189
|
+
expectedVersion: z.string().min(1).max(64).nullable(),
|
|
190
|
+
}),
|
|
191
|
+
/** The site-wide booking-off notice. */
|
|
192
|
+
setBookingLandingDefault: z.object({
|
|
193
|
+
notice: noticeSchema,
|
|
194
|
+
expectedVersion: z.string().min(1).max(64).nullable(),
|
|
195
|
+
}),
|
|
151
196
|
duplicatePage: z.object({
|
|
152
197
|
pageId: uuid,
|
|
153
198
|
name: z.string().min(1).max(120),
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notice a booking-off landing page shows while booking is hidden.
|
|
3
|
+
*
|
|
4
|
+
* The page keeps its own content, title, H1 and address; this only says,
|
|
5
|
+
* honestly, that booking is not offered here right now, and offers an
|
|
6
|
+
* optional link somewhere that is not booking. A titled region with an `h2`,
|
|
7
|
+
* so it is found by heading and landmark navigation without interrupting a
|
|
8
|
+
* screen reader the way an alert would. Plain text only: the message is
|
|
9
|
+
* rendered as text with its line breaks, never as markup.
|
|
10
|
+
*
|
|
11
|
+
* Placed after a leading hero, or first. First, it can follow the fixed
|
|
12
|
+
* header directly (a route with no top padding and no hero): it then keeps
|
|
13
|
+
* the same offset the site's padded routes use, instead of sitting under the
|
|
14
|
+
* header. Anywhere else the rule does not match.
|
|
15
|
+
*
|
|
16
|
+
* Its link is offered only if the current policy allows it: never to booking
|
|
17
|
+
* (a booking site, or the host's own booking page in any spelling) and never
|
|
18
|
+
* to a page hidden while booking is hidden. The host's server already drops
|
|
19
|
+
* such a link before the notice is sent; this is the same rule where it is
|
|
20
|
+
* rendered, so a policy that changed since cannot bring one back.
|
|
21
|
+
*/
|
|
22
|
+
import { useId } from "react";
|
|
23
|
+
|
|
24
|
+
import { useT } from "../i18n";
|
|
25
|
+
import type { ResolvedNotice } from "../cms/booking-landing";
|
|
26
|
+
import { isNoticeLinkAllowed } from "../navigation/visibility";
|
|
27
|
+
import { useLinkVisibility } from "./link-visibility";
|
|
28
|
+
|
|
29
|
+
export function BookingOffNotice({ notice }: { notice: ResolvedNotice | null | undefined }) {
|
|
30
|
+
const { t } = useT();
|
|
31
|
+
const id = useId();
|
|
32
|
+
const policy = useLinkVisibility();
|
|
33
|
+
if (!notice) return null;
|
|
34
|
+
const heading = t(notice.heading);
|
|
35
|
+
const body = t(notice.body);
|
|
36
|
+
const cta = notice.cta && isNoticeLinkAllowed(notice.cta.href, policy) ? notice.cta : null;
|
|
37
|
+
const label = cta ? t(cta.label) : "";
|
|
38
|
+
if (!heading && !body) return null;
|
|
39
|
+
const external = cta ? /^https:\/\//i.test(cta.href) : false;
|
|
40
|
+
return (
|
|
41
|
+
<section
|
|
42
|
+
aria-labelledby={heading ? `${id}-title` : undefined}
|
|
43
|
+
aria-label={heading ? undefined : body}
|
|
44
|
+
data-booking-off-notice=""
|
|
45
|
+
className="container-editorial py-8 md:py-10 [header+&]:pt-24 md:[header+&]:pt-32"
|
|
46
|
+
>
|
|
47
|
+
<div className="max-w-3xl border-l-4 border-ink/70 bg-paper px-5 py-5 md:px-7 md:py-6">
|
|
48
|
+
{heading ? (
|
|
49
|
+
<h2
|
|
50
|
+
id={`${id}-title`}
|
|
51
|
+
className="font-serif text-[clamp(1.4rem,2.6vw,2rem)] leading-[1.15] text-ink [overflow-wrap:anywhere]"
|
|
52
|
+
>
|
|
53
|
+
{heading}
|
|
54
|
+
</h2>
|
|
55
|
+
) : null}
|
|
56
|
+
{body ? (
|
|
57
|
+
<p className="mt-3 whitespace-pre-line text-base leading-relaxed text-ink/80 [overflow-wrap:anywhere]">
|
|
58
|
+
{body}
|
|
59
|
+
</p>
|
|
60
|
+
) : null}
|
|
61
|
+
{cta && label ? (
|
|
62
|
+
<a
|
|
63
|
+
href={cta.href}
|
|
64
|
+
{...(external ? { rel: "noopener" } : {})}
|
|
65
|
+
className="mt-5 inline-flex min-h-11 max-w-full items-center justify-center bg-ink px-6 py-3 text-center text-xs uppercase tracking-[0.22em] text-paper hover:bg-ink/85 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink [overflow-wrap:anywhere]"
|
|
66
|
+
>
|
|
67
|
+
{label}
|
|
68
|
+
</a>
|
|
69
|
+
) : null}
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
);
|
|
73
|
+
}
|