@bygghammar/cms-platform 0.95.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 +121 -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/PageEditorView.tsx +47 -24
- 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/forms/form-audit.ts +22 -2
- package/src/html/html-tokenizer.ts +254 -0
- package/src/html/sanitize-html.ts +71 -36
- package/src/navigation/visibility.ts +65 -3
- package/src/popups/runtime.tsx +75 -32
- package/src/server/forms-core.ts +9 -0
- package/src/server/forms-delivery-core.ts +29 -22
- package/src/server/forms-submissions-core.ts +8 -5
- package/src/server/media-usage-core.ts +53 -13
- package/src/server/outbound-address.ts +131 -0
- package/src/server/outbound-http.ts +565 -0
- package/src/server/page-visibility-core.ts +231 -5
- package/src/server/popup-optin-webhook-core.ts +79 -140
- 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/site/preview-messaging.ts +232 -0
- package/src/site/routes/preview-page.tsx +37 -29
- package/src/util/redact-url.ts +18 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site settings → Booking → Pages hidden from the website while booking is
|
|
3
|
+
* hidden.
|
|
4
|
+
*
|
|
5
|
+
* The distinction the screen has to make clear: these pages leave the
|
|
6
|
+
* website's menus and page lists while booking is hidden, but they are not
|
|
7
|
+
* taken offline and not hidden from search engines — someone arriving from
|
|
8
|
+
* Google still gets the page, without its booking links and with the notice
|
|
9
|
+
* set here. Everything is checked again on the server; the screen only avoids
|
|
10
|
+
* offering what the server would refuse.
|
|
11
|
+
*/
|
|
12
|
+
import { useEffect, useId, useRef, useState, type ReactNode, type Ref } from "react";
|
|
13
|
+
import { toast } from "sonner";
|
|
14
|
+
import { Pencil, Plus, Trash2 } from "lucide-react";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
BUILT_IN_NOTICE,
|
|
18
|
+
NOTICE_LIMITS,
|
|
19
|
+
ctaHrefProblemMessage,
|
|
20
|
+
overriddenFields,
|
|
21
|
+
parseNoticeCtaHref,
|
|
22
|
+
type NoticeContent,
|
|
23
|
+
type PageNoticeOverride,
|
|
24
|
+
} from "../cms/booking-landing";
|
|
25
|
+
import type { LocalizedString } from "../i18n/types";
|
|
26
|
+
|
|
27
|
+
export type BookingLandingData = {
|
|
28
|
+
default: { notice: NoticeContent | null; version: string | null };
|
|
29
|
+
pages: Array<{
|
|
30
|
+
pageId: string;
|
|
31
|
+
key: string | null;
|
|
32
|
+
name: string | null;
|
|
33
|
+
missing: boolean;
|
|
34
|
+
notice: PageNoticeOverride;
|
|
35
|
+
version: string | null;
|
|
36
|
+
conflict: boolean;
|
|
37
|
+
}>;
|
|
38
|
+
rowVersions?: Record<string, string | null>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type BookingLandingDeps = {
|
|
42
|
+
data: BookingLandingData;
|
|
43
|
+
bookingEnabled: boolean;
|
|
44
|
+
/** Every page; the add list leaves out the ones the server would refuse. */
|
|
45
|
+
pages: Array<{ id: string; key: string; name: string; protected?: boolean; listing?: string }> | undefined;
|
|
46
|
+
/** Page ids that keep their booking links: they cannot be added here. */
|
|
47
|
+
exceptionIds: ReadonlySet<string>;
|
|
48
|
+
pathFor: (key: string) => string;
|
|
49
|
+
locales: readonly string[];
|
|
50
|
+
setLanding: (input: {
|
|
51
|
+
pageId: string;
|
|
52
|
+
landing: boolean;
|
|
53
|
+
notice?: PageNoticeOverride | null;
|
|
54
|
+
expectedVersion: string | null;
|
|
55
|
+
}) => Promise<unknown>;
|
|
56
|
+
setLandingDefault: (input: { notice: NoticeContent; expectedVersion: string | null }) => Promise<unknown>;
|
|
57
|
+
refresh: () => void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const LOCALE_NAMES: Record<string, string> = { sv: "Swedish", en: "English" };
|
|
61
|
+
const localeName = (locale: string) => LOCALE_NAMES[locale] ?? locale.toUpperCase();
|
|
62
|
+
|
|
63
|
+
const inputClass =
|
|
64
|
+
"block w-full min-w-0 border border-ink/15 bg-white px-3 py-2 text-sm text-ink aria-[invalid=true]:border-red-600";
|
|
65
|
+
const labelClass = "mb-1 block text-xs uppercase tracking-wider text-ink/60";
|
|
66
|
+
|
|
67
|
+
/** Form state: every text per locale as typed, plus the link. */
|
|
68
|
+
type Draft = {
|
|
69
|
+
heading: Record<string, string>;
|
|
70
|
+
body: Record<string, string>;
|
|
71
|
+
ctaLabel: Record<string, string>;
|
|
72
|
+
ctaHref: string;
|
|
73
|
+
/** Page form only: use the default link, an own link, or none. */
|
|
74
|
+
ctaMode: "default" | "own" | "none";
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const toRecord = (value: LocalizedString | undefined | null, locales: readonly string[]) =>
|
|
78
|
+
Object.fromEntries(locales.map((locale) => [locale, value?.[locale as keyof LocalizedString] ?? ""]));
|
|
79
|
+
|
|
80
|
+
function draftFrom(
|
|
81
|
+
value: { heading?: LocalizedString; body?: LocalizedString; cta?: NoticeContent["cta"] | "none" } | null,
|
|
82
|
+
locales: readonly string[],
|
|
83
|
+
page: boolean,
|
|
84
|
+
): Draft {
|
|
85
|
+
const cta = value?.cta && value.cta !== "none" ? value.cta : null;
|
|
86
|
+
return {
|
|
87
|
+
heading: toRecord(value?.heading, locales),
|
|
88
|
+
body: toRecord(value?.body, locales),
|
|
89
|
+
ctaLabel: toRecord(cta?.label, locales),
|
|
90
|
+
ctaHref: cta?.href ?? "",
|
|
91
|
+
ctaMode: !page ? (cta ? "own" : "none") : value?.cta === "none" ? "none" : cta ? "own" : "default",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const cleaned = (texts: Record<string, string>): LocalizedString | undefined => {
|
|
96
|
+
const out = Object.fromEntries(
|
|
97
|
+
Object.entries(texts)
|
|
98
|
+
.map(([locale, text]) => [locale, text.trim()] as const)
|
|
99
|
+
.filter(([, text]) => text !== ""),
|
|
100
|
+
);
|
|
101
|
+
return Object.keys(out).length > 0 ? (out as LocalizedString) : undefined;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
type Problems = Partial<Record<string, string>>;
|
|
105
|
+
|
|
106
|
+
/** The same rules the server applies, said next to the field. */
|
|
107
|
+
function check(draft: Draft, locales: readonly string[], linkWanted: boolean): Problems {
|
|
108
|
+
const problems: Problems = {};
|
|
109
|
+
for (const locale of locales) {
|
|
110
|
+
if ((draft.heading[locale] ?? "").trim().length > NOTICE_LIMITS.heading) {
|
|
111
|
+
problems[`heading.${locale}`] = `At most ${NOTICE_LIMITS.heading} characters.`;
|
|
112
|
+
}
|
|
113
|
+
if ((draft.body[locale] ?? "").trim().length > NOTICE_LIMITS.body) {
|
|
114
|
+
problems[`body.${locale}`] = `At most ${NOTICE_LIMITS.body} characters.`;
|
|
115
|
+
}
|
|
116
|
+
if ((draft.ctaLabel[locale] ?? "").trim().length > NOTICE_LIMITS.ctaLabel) {
|
|
117
|
+
problems[`ctaLabel.${locale}`] = `At most ${NOTICE_LIMITS.ctaLabel} characters.`;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (linkWanted) {
|
|
121
|
+
const href = parseNoticeCtaHref(draft.ctaHref);
|
|
122
|
+
if (!href.ok) problems["ctaHref"] = ctaHrefProblemMessage(href.reason);
|
|
123
|
+
if (!cleaned(draft.ctaLabel)) problems["ctaLabel"] = "Write the link's text in at least one language.";
|
|
124
|
+
}
|
|
125
|
+
return problems;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The fields of one notice: default or one page's own. */
|
|
129
|
+
function NoticeFields({
|
|
130
|
+
draft,
|
|
131
|
+
setDraft,
|
|
132
|
+
problems,
|
|
133
|
+
locales,
|
|
134
|
+
page,
|
|
135
|
+
disabled,
|
|
136
|
+
firstFieldRef,
|
|
137
|
+
}: {
|
|
138
|
+
draft: Draft;
|
|
139
|
+
setDraft: (next: Draft) => void;
|
|
140
|
+
problems: Problems;
|
|
141
|
+
locales: readonly string[];
|
|
142
|
+
page: boolean;
|
|
143
|
+
disabled: boolean;
|
|
144
|
+
firstFieldRef?: Ref<HTMLInputElement>;
|
|
145
|
+
}): ReactNode {
|
|
146
|
+
const id = useId();
|
|
147
|
+
const field = (name: string) => `${id}-${name}`;
|
|
148
|
+
const error = (name: string) =>
|
|
149
|
+
problems[name] ? (
|
|
150
|
+
<span id={field(`${name}-error`)} className="mt-1 block text-xs text-red-700">
|
|
151
|
+
{problems[name]}
|
|
152
|
+
</span>
|
|
153
|
+
) : null;
|
|
154
|
+
const a11y = (name: string) => ({
|
|
155
|
+
"aria-invalid": problems[name] ? true : undefined,
|
|
156
|
+
"aria-describedby": problems[name] ? field(`${name}-error`) : undefined,
|
|
157
|
+
});
|
|
158
|
+
const setText = (part: "heading" | "body" | "ctaLabel", locale: string, text: string) =>
|
|
159
|
+
setDraft({ ...draft, [part]: { ...draft[part], [locale]: text } });
|
|
160
|
+
const linkShown = draft.ctaMode === "own";
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<div className="grid gap-4">
|
|
164
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
165
|
+
{locales.map((locale, i) => (
|
|
166
|
+
<div key={locale} className="min-w-0">
|
|
167
|
+
<label className="block" htmlFor={field(`heading-${locale}`)}>
|
|
168
|
+
<span className={labelClass}>Heading — {localeName(locale)}</span>
|
|
169
|
+
</label>
|
|
170
|
+
<input
|
|
171
|
+
id={field(`heading-${locale}`)}
|
|
172
|
+
ref={i === 0 ? firstFieldRef : undefined}
|
|
173
|
+
type="text"
|
|
174
|
+
lang={locale}
|
|
175
|
+
disabled={disabled}
|
|
176
|
+
maxLength={NOTICE_LIMITS.heading + 40}
|
|
177
|
+
value={draft.heading[locale] ?? ""}
|
|
178
|
+
placeholder={page ? "Use the default heading" : (BUILT_IN_NOTICE.heading[locale as "sv"] ?? "")}
|
|
179
|
+
onChange={(e) => setText("heading", locale, e.target.value)}
|
|
180
|
+
className={inputClass}
|
|
181
|
+
{...a11y(`heading.${locale}`)}
|
|
182
|
+
/>
|
|
183
|
+
{error(`heading.${locale}`)}
|
|
184
|
+
<label className="mt-3 block" htmlFor={field(`body-${locale}`)}>
|
|
185
|
+
<span className={labelClass}>Message — {localeName(locale)}</span>
|
|
186
|
+
</label>
|
|
187
|
+
<textarea
|
|
188
|
+
id={field(`body-${locale}`)}
|
|
189
|
+
lang={locale}
|
|
190
|
+
rows={3}
|
|
191
|
+
disabled={disabled}
|
|
192
|
+
maxLength={NOTICE_LIMITS.body + 200}
|
|
193
|
+
value={draft.body[locale] ?? ""}
|
|
194
|
+
placeholder={page ? "Use the default message" : (BUILT_IN_NOTICE.body[locale as "sv"] ?? "")}
|
|
195
|
+
onChange={(e) => setText("body", locale, e.target.value)}
|
|
196
|
+
className={inputClass}
|
|
197
|
+
{...a11y(`body.${locale}`)}
|
|
198
|
+
/>
|
|
199
|
+
{error(`body.${locale}`)}
|
|
200
|
+
</div>
|
|
201
|
+
))}
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<fieldset className="min-w-0 border border-ink/10 p-4">
|
|
205
|
+
<legend className="px-1 text-xs uppercase tracking-wider text-ink/60">Link (optional)</legend>
|
|
206
|
+
<p className="text-xs text-ink/60">
|
|
207
|
+
For example a contact page or an interest form — never booking.
|
|
208
|
+
</p>
|
|
209
|
+
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-2 text-sm text-ink">
|
|
210
|
+
{(page ? (["default", "own", "none"] as const) : (["none", "own"] as const)).map((mode) => (
|
|
211
|
+
<label key={mode} className="inline-flex items-center gap-2">
|
|
212
|
+
<input
|
|
213
|
+
type="radio"
|
|
214
|
+
name={field("cta-mode")}
|
|
215
|
+
value={mode}
|
|
216
|
+
disabled={disabled}
|
|
217
|
+
checked={draft.ctaMode === mode}
|
|
218
|
+
onChange={() => setDraft({ ...draft, ctaMode: mode })}
|
|
219
|
+
/>
|
|
220
|
+
{mode === "default" ? "Use the default link" : mode === "own" ? (page ? "Own link" : "Show a link") : "No link"}
|
|
221
|
+
</label>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
{linkShown ? (
|
|
225
|
+
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
|
226
|
+
{locales.map((locale) => (
|
|
227
|
+
<div key={locale} className="min-w-0">
|
|
228
|
+
<label className="block" htmlFor={field(`cta-${locale}`)}>
|
|
229
|
+
<span className={labelClass}>Link text — {localeName(locale)}</span>
|
|
230
|
+
</label>
|
|
231
|
+
<input
|
|
232
|
+
id={field(`cta-${locale}`)}
|
|
233
|
+
type="text"
|
|
234
|
+
lang={locale}
|
|
235
|
+
disabled={disabled}
|
|
236
|
+
maxLength={NOTICE_LIMITS.ctaLabel + 20}
|
|
237
|
+
value={draft.ctaLabel[locale] ?? ""}
|
|
238
|
+
onChange={(e) => setText("ctaLabel", locale, e.target.value)}
|
|
239
|
+
className={inputClass}
|
|
240
|
+
{...a11y(`ctaLabel.${locale}`)}
|
|
241
|
+
/>
|
|
242
|
+
{error(`ctaLabel.${locale}`)}
|
|
243
|
+
</div>
|
|
244
|
+
))}
|
|
245
|
+
{problems["ctaLabel"] ? (
|
|
246
|
+
<p className="text-xs text-red-700 sm:col-span-2" id={field("ctaLabel-error")}>
|
|
247
|
+
{problems["ctaLabel"]}
|
|
248
|
+
</p>
|
|
249
|
+
) : null}
|
|
250
|
+
<div className="min-w-0 sm:col-span-2">
|
|
251
|
+
<label className="block" htmlFor={field("cta-href")}>
|
|
252
|
+
<span className={labelClass}>Link address</span>
|
|
253
|
+
</label>
|
|
254
|
+
<input
|
|
255
|
+
id={field("cta-href")}
|
|
256
|
+
type="text"
|
|
257
|
+
inputMode="url"
|
|
258
|
+
autoComplete="off"
|
|
259
|
+
spellCheck={false}
|
|
260
|
+
disabled={disabled}
|
|
261
|
+
maxLength={NOTICE_LIMITS.href + 1}
|
|
262
|
+
value={draft.ctaHref}
|
|
263
|
+
placeholder="/kontakt or https://…"
|
|
264
|
+
onChange={(e) => setDraft({ ...draft, ctaHref: e.target.value })}
|
|
265
|
+
className={`${inputClass} font-mono`}
|
|
266
|
+
{...a11y("ctaHref")}
|
|
267
|
+
/>
|
|
268
|
+
{error("ctaHref")}
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
) : null}
|
|
272
|
+
</fieldset>
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** One notice form with its own save. */
|
|
278
|
+
function NoticeForm({
|
|
279
|
+
initial,
|
|
280
|
+
locales,
|
|
281
|
+
page,
|
|
282
|
+
busy,
|
|
283
|
+
saveLabel,
|
|
284
|
+
onSave,
|
|
285
|
+
onCancel,
|
|
286
|
+
focusOnOpen,
|
|
287
|
+
}: {
|
|
288
|
+
initial: Draft;
|
|
289
|
+
locales: readonly string[];
|
|
290
|
+
page: boolean;
|
|
291
|
+
busy: boolean;
|
|
292
|
+
saveLabel: string;
|
|
293
|
+
onSave: (draft: Draft) => Promise<boolean>;
|
|
294
|
+
onCancel?: () => void;
|
|
295
|
+
focusOnOpen?: boolean;
|
|
296
|
+
}): ReactNode {
|
|
297
|
+
const [draft, setDraft] = useState(initial);
|
|
298
|
+
const [problems, setProblems] = useState<Problems>({});
|
|
299
|
+
const firstField = useRef<HTMLInputElement>(null);
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
if (focusOnOpen) firstField.current?.focus();
|
|
302
|
+
}, [focusOnOpen]);
|
|
303
|
+
const count = Object.keys(problems).length;
|
|
304
|
+
return (
|
|
305
|
+
<form
|
|
306
|
+
noValidate
|
|
307
|
+
className="grid gap-4"
|
|
308
|
+
onSubmit={(e) => {
|
|
309
|
+
e.preventDefault();
|
|
310
|
+
const found = check(draft, locales, draft.ctaMode === "own");
|
|
311
|
+
setProblems(found);
|
|
312
|
+
if (Object.keys(found).length > 0) return;
|
|
313
|
+
void onSave(draft);
|
|
314
|
+
}}
|
|
315
|
+
>
|
|
316
|
+
{count > 0 ? (
|
|
317
|
+
<p role="alert" className="text-sm text-red-700">
|
|
318
|
+
Nothing was saved: {count === 1 ? "one field needs" : `${count} fields need`} attention.
|
|
319
|
+
</p>
|
|
320
|
+
) : null}
|
|
321
|
+
<NoticeFields
|
|
322
|
+
draft={draft}
|
|
323
|
+
setDraft={setDraft}
|
|
324
|
+
problems={problems}
|
|
325
|
+
locales={locales}
|
|
326
|
+
page={page}
|
|
327
|
+
disabled={busy}
|
|
328
|
+
firstFieldRef={firstField}
|
|
329
|
+
/>
|
|
330
|
+
<div className="flex flex-wrap gap-3">
|
|
331
|
+
<button
|
|
332
|
+
type="submit"
|
|
333
|
+
disabled={busy}
|
|
334
|
+
className="inline-flex items-center gap-2 bg-ink px-4 py-2 text-xs uppercase tracking-[0.18em] text-paper hover:bg-ink/85 disabled:opacity-40"
|
|
335
|
+
>
|
|
336
|
+
{saveLabel}
|
|
337
|
+
</button>
|
|
338
|
+
{onCancel ? (
|
|
339
|
+
<button
|
|
340
|
+
type="button"
|
|
341
|
+
disabled={busy}
|
|
342
|
+
onClick={onCancel}
|
|
343
|
+
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"
|
|
344
|
+
>
|
|
345
|
+
Cancel
|
|
346
|
+
</button>
|
|
347
|
+
) : null}
|
|
348
|
+
</div>
|
|
349
|
+
</form>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function behaviourOf(row: BookingLandingData["pages"][number]): string {
|
|
354
|
+
const own = overriddenFields(row.notice);
|
|
355
|
+
return own.length === 0 ? "Shows the default message" : `Own ${own.join(", ")}`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function BookingLandingSection({ deps }: { deps: BookingLandingDeps }): ReactNode {
|
|
359
|
+
const { data, pages, pathFor, locales } = deps;
|
|
360
|
+
const [busy, setBusy] = useState(false);
|
|
361
|
+
const [adding, setAdding] = useState("");
|
|
362
|
+
const [editing, setEditing] = useState<string | null>(null);
|
|
363
|
+
const [status, setStatus] = useState("");
|
|
364
|
+
/**
|
|
365
|
+
* The version a form was saving against when its save failed, by page id
|
|
366
|
+
* (and `"default"`). When the refresh then brings a different version,
|
|
367
|
+
* somebody else saved in between: the form is rebuilt from what is stored
|
|
368
|
+
* now (it is keyed by the version), so the old draft can never be sent
|
|
369
|
+
* against the new version unseen, and the editor is told why.
|
|
370
|
+
*/
|
|
371
|
+
const [failedAt, setFailedAt] = useState<Record<string, string | null>>({});
|
|
372
|
+
const editButtons = useRef(new Map<string, HTMLButtonElement | null>());
|
|
373
|
+
const changedSince = (id: string, version: string | null) =>
|
|
374
|
+
id in failedAt && failedAt[id] !== version;
|
|
375
|
+
const remember = (id: string, version: string | null | undefined) =>
|
|
376
|
+
setFailedAt((all) => {
|
|
377
|
+
const next = { ...all };
|
|
378
|
+
if (version === undefined) delete next[id];
|
|
379
|
+
else next[id] = version;
|
|
380
|
+
return next;
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const run = async (action: () => Promise<unknown>, done: string): Promise<boolean> => {
|
|
384
|
+
setBusy(true);
|
|
385
|
+
try {
|
|
386
|
+
await action();
|
|
387
|
+
toast.success(done);
|
|
388
|
+
setStatus(done);
|
|
389
|
+
return true;
|
|
390
|
+
} catch (e) {
|
|
391
|
+
const message = e instanceof Error ? e.message : "Failed to save";
|
|
392
|
+
toast.error(message);
|
|
393
|
+
setStatus(`Not saved: ${message}`);
|
|
394
|
+
return false;
|
|
395
|
+
} finally {
|
|
396
|
+
setBusy(false);
|
|
397
|
+
// Success or conflict alike: show what is stored now.
|
|
398
|
+
deps.refresh();
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const closeEditor = (pageId: string) => {
|
|
403
|
+
setEditing(null);
|
|
404
|
+
remember(pageId, undefined);
|
|
405
|
+
// Give focus back to the button that opened the editor.
|
|
406
|
+
queueMicrotask(() => editButtons.current.get(pageId)?.focus());
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const selected = new Set(data.pages.map((row) => row.pageId));
|
|
410
|
+
const candidates = (pages ?? [])
|
|
411
|
+
.filter((page) => {
|
|
412
|
+
const id = page.id.toLowerCase();
|
|
413
|
+
return !selected.has(id) && !deps.exceptionIds.has(id) && !page.protected;
|
|
414
|
+
})
|
|
415
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
416
|
+
const conflicts = data.pages.filter((row) => row.conflict);
|
|
417
|
+
|
|
418
|
+
const pageNotice = (draft: Draft): PageNoticeOverride => {
|
|
419
|
+
const out: PageNoticeOverride = {};
|
|
420
|
+
const heading = cleaned(draft.heading);
|
|
421
|
+
const body = cleaned(draft.body);
|
|
422
|
+
if (heading) out.heading = heading;
|
|
423
|
+
if (body) out.body = body;
|
|
424
|
+
if (draft.ctaMode === "none") out.cta = "none";
|
|
425
|
+
else if (draft.ctaMode === "own") out.cta = { label: cleaned(draft.ctaLabel) ?? {}, href: draft.ctaHref.trim() };
|
|
426
|
+
return out;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
return (
|
|
430
|
+
<div className="mt-8 border-t border-ink/10 pt-5" role="group" aria-labelledby="booking-landing-title">
|
|
431
|
+
<h3 id="booking-landing-title" className="text-xs uppercase tracking-wider text-ink/60">
|
|
432
|
+
Pages hidden from the website while booking is hidden
|
|
433
|
+
</h3>
|
|
434
|
+
<div className="mt-2 max-w-2xl space-y-2 text-sm text-ink/60">
|
|
435
|
+
<p>
|
|
436
|
+
While booking is hidden, these pages are removed from the website’s menus, footer, page
|
|
437
|
+
cards, buttons and popups. Their addresses stay live, indexable and in the sitemap, so
|
|
438
|
+
visitors arriving from a search result can still read them.
|
|
439
|
+
</p>
|
|
440
|
+
<p>
|
|
441
|
+
They remain eligible to appear in search results, but rankings are not guaranteed: removing
|
|
442
|
+
links to them from the website can affect how search engines value them.
|
|
443
|
+
</p>
|
|
444
|
+
<p>
|
|
445
|
+
On the page itself the booking links are removed and the message below is shown instead.
|
|
446
|
+
Links written into a page’s own text are left as they are. When booking is shown again,
|
|
447
|
+
everything returns and the message disappears.
|
|
448
|
+
</p>
|
|
449
|
+
</div>
|
|
450
|
+
<p className="mt-3 text-sm text-ink">
|
|
451
|
+
{deps.bookingEnabled
|
|
452
|
+
? "Booking is shown right now, so these pages are listed as usual."
|
|
453
|
+
: "Booking is hidden right now, so these pages are hidden from the menus and show the message."}
|
|
454
|
+
</p>
|
|
455
|
+
|
|
456
|
+
{conflicts.length > 0 ? (
|
|
457
|
+
<div role="alert" className="mt-4 border border-amber-400 bg-amber-50 px-4 py-3 text-sm text-ink">
|
|
458
|
+
<p className="font-medium">Some pages are in both lists.</p>
|
|
459
|
+
<p className="mt-1">
|
|
460
|
+
{conflicts.map((row) => row.name ?? "A page").join(", ")}{" "}
|
|
461
|
+
{conflicts.length === 1 ? "is" : "are"} also set to keep booking links. Until removed from
|
|
462
|
+
one list, {conflicts.length === 1 ? "it keeps" : "they keep"} the booking links and{" "}
|
|
463
|
+
{conflicts.length === 1 ? "stays" : "stay"} in the menus, and no message is shown.
|
|
464
|
+
</p>
|
|
465
|
+
</div>
|
|
466
|
+
) : null}
|
|
467
|
+
|
|
468
|
+
<ul className="mt-4 divide-y divide-ink/10 border border-ink/10">
|
|
469
|
+
{data.pages.length === 0 ? (
|
|
470
|
+
<li className="px-4 py-3 text-sm text-ink/50">No pages.</li>
|
|
471
|
+
) : (
|
|
472
|
+
data.pages.map((row) => {
|
|
473
|
+
const label = row.name ?? "Page no longer exists";
|
|
474
|
+
const isEditing = editing === row.pageId;
|
|
475
|
+
const panelId = `booking-landing-edit-${row.pageId}`;
|
|
476
|
+
return (
|
|
477
|
+
<li key={row.pageId} className="px-4 py-3 text-sm">
|
|
478
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
479
|
+
<span className="min-w-0">
|
|
480
|
+
<span className="text-ink">{label}</span>
|
|
481
|
+
{row.key ? (
|
|
482
|
+
<span className="ml-2 break-all font-mono text-xs text-ink/50">{pathFor(row.key)}</span>
|
|
483
|
+
) : null}
|
|
484
|
+
<span className="mt-1 block text-xs text-ink/60">
|
|
485
|
+
{row.conflict
|
|
486
|
+
? "Not hidden: it also keeps its booking links (see the warning above)."
|
|
487
|
+
: behaviourOf(row)}
|
|
488
|
+
{row.missing ? " · In the trash or deleted — has no effect." : ""}
|
|
489
|
+
</span>
|
|
490
|
+
</span>
|
|
491
|
+
<span className="flex flex-wrap items-center gap-4">
|
|
492
|
+
{!row.missing ? (
|
|
493
|
+
<button
|
|
494
|
+
type="button"
|
|
495
|
+
ref={(node) => {
|
|
496
|
+
editButtons.current.set(row.pageId, node);
|
|
497
|
+
}}
|
|
498
|
+
disabled={busy}
|
|
499
|
+
aria-expanded={isEditing}
|
|
500
|
+
aria-controls={panelId}
|
|
501
|
+
onClick={() => (isEditing ? closeEditor(row.pageId) : setEditing(row.pageId))}
|
|
502
|
+
className="inline-flex items-center gap-1 text-xs text-ink hover:underline disabled:opacity-40"
|
|
503
|
+
>
|
|
504
|
+
<Pencil className="size-3.5" aria-hidden="true" /> Edit message
|
|
505
|
+
<span className="sr-only"> for {label}</span>
|
|
506
|
+
</button>
|
|
507
|
+
) : null}
|
|
508
|
+
<button
|
|
509
|
+
type="button"
|
|
510
|
+
disabled={busy}
|
|
511
|
+
onClick={() =>
|
|
512
|
+
void run(
|
|
513
|
+
() =>
|
|
514
|
+
deps.setLanding({
|
|
515
|
+
pageId: row.pageId,
|
|
516
|
+
landing: false,
|
|
517
|
+
expectedVersion: row.version,
|
|
518
|
+
}),
|
|
519
|
+
`${row.name ?? "The page"} is no longer hidden while booking is hidden`,
|
|
520
|
+
).then((ok) => {
|
|
521
|
+
if (ok && isEditing) setEditing(null);
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
className="inline-flex items-center gap-1 text-xs text-red-700 hover:underline disabled:opacity-40"
|
|
525
|
+
>
|
|
526
|
+
<Trash2 className="size-3.5" aria-hidden="true" /> Remove
|
|
527
|
+
<span className="sr-only"> {label} from the list</span>
|
|
528
|
+
</button>
|
|
529
|
+
</span>
|
|
530
|
+
</div>
|
|
531
|
+
{isEditing ? (
|
|
532
|
+
<div
|
|
533
|
+
id={panelId}
|
|
534
|
+
role="region"
|
|
535
|
+
aria-label={`Message for ${label}`}
|
|
536
|
+
className="mt-4 border-t border-ink/10 pt-4"
|
|
537
|
+
>
|
|
538
|
+
{changedSince(row.pageId, row.version) ? (
|
|
539
|
+
<p role="alert" className="mb-3 border border-amber-400 bg-amber-50 px-3 py-2 text-sm text-ink">
|
|
540
|
+
This message was changed by someone else while you were editing. Your changes
|
|
541
|
+
were not saved; their version is shown below. Review it and edit again if needed.
|
|
542
|
+
</p>
|
|
543
|
+
) : null}
|
|
544
|
+
<p className="mb-3 text-xs text-ink/60">
|
|
545
|
+
Leave a field empty to use the default message for it.
|
|
546
|
+
</p>
|
|
547
|
+
<NoticeForm
|
|
548
|
+
key={row.version ?? "none"}
|
|
549
|
+
initial={draftFrom(row.notice, locales, true)}
|
|
550
|
+
locales={locales}
|
|
551
|
+
page
|
|
552
|
+
busy={busy}
|
|
553
|
+
focusOnOpen
|
|
554
|
+
saveLabel="Save page message"
|
|
555
|
+
onCancel={() => closeEditor(row.pageId)}
|
|
556
|
+
onSave={async (draft) => {
|
|
557
|
+
const ok = await run(
|
|
558
|
+
() =>
|
|
559
|
+
deps.setLanding({
|
|
560
|
+
pageId: row.pageId,
|
|
561
|
+
landing: true,
|
|
562
|
+
notice: pageNotice(draft),
|
|
563
|
+
expectedVersion: row.version,
|
|
564
|
+
}),
|
|
565
|
+
`Saved the message for ${label}`,
|
|
566
|
+
);
|
|
567
|
+
if (ok) closeEditor(row.pageId);
|
|
568
|
+
else remember(row.pageId, row.version);
|
|
569
|
+
return ok;
|
|
570
|
+
}}
|
|
571
|
+
/>
|
|
572
|
+
</div>
|
|
573
|
+
) : null}
|
|
574
|
+
</li>
|
|
575
|
+
);
|
|
576
|
+
})
|
|
577
|
+
)}
|
|
578
|
+
</ul>
|
|
579
|
+
|
|
580
|
+
<div className="mt-4 flex flex-wrap items-end gap-3">
|
|
581
|
+
<label className="block min-w-0 flex-1 sm:max-w-sm">
|
|
582
|
+
<span className={labelClass}>Add a page</span>
|
|
583
|
+
<select
|
|
584
|
+
value={adding}
|
|
585
|
+
onChange={(e) => setAdding(e.target.value)}
|
|
586
|
+
disabled={busy || !pages}
|
|
587
|
+
className="w-full border border-ink/15 bg-white px-3 py-2 text-sm text-ink"
|
|
588
|
+
>
|
|
589
|
+
<option value="">Choose a page…</option>
|
|
590
|
+
{candidates.map((page) => (
|
|
591
|
+
<option key={page.id} value={page.id}>
|
|
592
|
+
{page.name} — {pathFor(page.key)}
|
|
593
|
+
</option>
|
|
594
|
+
))}
|
|
595
|
+
</select>
|
|
596
|
+
</label>
|
|
597
|
+
<button
|
|
598
|
+
type="button"
|
|
599
|
+
disabled={busy || !adding}
|
|
600
|
+
onClick={() => {
|
|
601
|
+
const page = candidates.find((p) => p.id === adding);
|
|
602
|
+
// A page removed earlier leaves its row behind; send its version.
|
|
603
|
+
const previous = data.rowVersions?.[adding.toLowerCase()] ?? null;
|
|
604
|
+
void run(
|
|
605
|
+
() => deps.setLanding({ pageId: adding, landing: true, expectedVersion: previous }),
|
|
606
|
+
`${page?.name ?? "The page"} is hidden from the website while booking is hidden`,
|
|
607
|
+
).then((ok) => {
|
|
608
|
+
if (ok) setAdding("");
|
|
609
|
+
});
|
|
610
|
+
}}
|
|
611
|
+
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"
|
|
612
|
+
>
|
|
613
|
+
<Plus className="size-4" aria-hidden="true" /> Add
|
|
614
|
+
</button>
|
|
615
|
+
</div>
|
|
616
|
+
<p className="mt-2 max-w-2xl text-xs text-ink/50">
|
|
617
|
+
The start page and the contact page always stay in the menus, and a page that keeps its
|
|
618
|
+
booking links cannot be added, so they are not offered.
|
|
619
|
+
</p>
|
|
620
|
+
|
|
621
|
+
<div className="mt-6 border border-ink/10 p-4" role="group" aria-labelledby="booking-landing-default-title">
|
|
622
|
+
<h4 id="booking-landing-default-title" className="text-sm text-ink">
|
|
623
|
+
Default message
|
|
624
|
+
</h4>
|
|
625
|
+
<p className="mt-1 max-w-2xl text-xs text-ink/60">
|
|
626
|
+
Shown on every page in the list unless the page has its own. Empty fields use the built-in
|
|
627
|
+
text shown in grey. Plain text only.
|
|
628
|
+
</p>
|
|
629
|
+
{changedSince("default", data.default.version) ? (
|
|
630
|
+
<p role="alert" className="mt-3 border border-amber-400 bg-amber-50 px-3 py-2 text-sm text-ink">
|
|
631
|
+
The default message was changed by someone else while you were editing. Your changes were
|
|
632
|
+
not saved; their version is shown below. Review it and edit again if needed.
|
|
633
|
+
</p>
|
|
634
|
+
) : null}
|
|
635
|
+
<div className="mt-4">
|
|
636
|
+
<NoticeForm
|
|
637
|
+
key={data.default.version ?? "none"}
|
|
638
|
+
initial={draftFrom(data.default.notice, locales, false)}
|
|
639
|
+
locales={locales}
|
|
640
|
+
page={false}
|
|
641
|
+
busy={busy}
|
|
642
|
+
focusOnOpen={changedSince("default", data.default.version)}
|
|
643
|
+
saveLabel="Save default message"
|
|
644
|
+
onSave={async (draft) => {
|
|
645
|
+
const ok = await run(
|
|
646
|
+
() =>
|
|
647
|
+
deps.setLandingDefault({
|
|
648
|
+
notice: {
|
|
649
|
+
heading: cleaned(draft.heading) ?? {},
|
|
650
|
+
body: cleaned(draft.body) ?? {},
|
|
651
|
+
cta:
|
|
652
|
+
draft.ctaMode === "own"
|
|
653
|
+
? { label: cleaned(draft.ctaLabel) ?? {}, href: draft.ctaHref.trim() }
|
|
654
|
+
: null,
|
|
655
|
+
},
|
|
656
|
+
expectedVersion: data.default.version,
|
|
657
|
+
}),
|
|
658
|
+
"Saved the default message",
|
|
659
|
+
);
|
|
660
|
+
remember("default", ok ? undefined : data.default.version);
|
|
661
|
+
return ok;
|
|
662
|
+
}}
|
|
663
|
+
/>
|
|
664
|
+
</div>
|
|
665
|
+
</div>
|
|
666
|
+
|
|
667
|
+
<p role="status" className="sr-only">
|
|
668
|
+
{status}
|
|
669
|
+
</p>
|
|
670
|
+
</div>
|
|
671
|
+
);
|
|
672
|
+
}
|