@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
|
@@ -11,6 +11,7 @@ import { toast } from "sonner";
|
|
|
11
11
|
import { Plus, Trash2 } from "lucide-react";
|
|
12
12
|
|
|
13
13
|
import { ConfirmDialog } from "./media/ConfirmDialog";
|
|
14
|
+
import { BookingLandingSection, type BookingLandingData, type BookingLandingDeps } from "./BookingLandingSection";
|
|
14
15
|
|
|
15
16
|
export type BookingSettingsData = {
|
|
16
17
|
enabled: boolean;
|
|
@@ -24,6 +25,8 @@ export type BookingSettingsData = {
|
|
|
24
25
|
}>;
|
|
25
26
|
/** Versions of every stored exception row, removed ones included. */
|
|
26
27
|
rowVersions?: Record<string, string | null>;
|
|
28
|
+
/** Booking-off landing pages; absent from a server without them. */
|
|
29
|
+
landing?: BookingLandingData;
|
|
27
30
|
};
|
|
28
31
|
|
|
29
32
|
export type BookingSettingsDeps = {
|
|
@@ -31,8 +34,8 @@ export type BookingSettingsDeps = {
|
|
|
31
34
|
settings: BookingSettingsData | undefined;
|
|
32
35
|
/** A failed read: the card says so and offers nothing it cannot back up. */
|
|
33
36
|
error: Error | null;
|
|
34
|
-
/** Pages an exception can be added for. */
|
|
35
|
-
pages: Array<{ id: string; key: string; name: string }> | undefined;
|
|
37
|
+
/** Pages an exception or a landing page can be added for. */
|
|
38
|
+
pages: Array<{ id: string; key: string; name: string; protected?: boolean }> | undefined;
|
|
36
39
|
/** Public path of a page key, for the list. */
|
|
37
40
|
pathFor: (key: string) => string;
|
|
38
41
|
setEnabled: (input: { enabled: boolean; expectedVersion: string | null }) => Promise<unknown>;
|
|
@@ -42,6 +45,14 @@ export type BookingSettingsDeps = {
|
|
|
42
45
|
expectedVersion: string | null;
|
|
43
46
|
}) => Promise<unknown>;
|
|
44
47
|
refresh: () => void;
|
|
48
|
+
/**
|
|
49
|
+
* Booking-off landing pages. Optional: without them the subsection is not
|
|
50
|
+
* shown, and the card is what it was.
|
|
51
|
+
*/
|
|
52
|
+
setLanding?: BookingLandingDeps["setLanding"];
|
|
53
|
+
setLandingDefault?: BookingLandingDeps["setLandingDefault"];
|
|
54
|
+
/** Locales the notice is written in; the site's own. */
|
|
55
|
+
locales?: readonly string[];
|
|
45
56
|
};
|
|
46
57
|
|
|
47
58
|
const cardClass = "border border-ink/10 bg-white p-6";
|
|
@@ -67,8 +78,10 @@ export function BookingSettingsCard({ deps }: { deps: BookingSettingsDeps }): Re
|
|
|
67
78
|
};
|
|
68
79
|
|
|
69
80
|
const exceptionIds = new Set((settings?.exceptions ?? []).map((row) => row.pageId));
|
|
81
|
+
// A booking-off landing page cannot also keep its booking links.
|
|
82
|
+
const landingIds = new Set((settings?.landing?.pages ?? []).map((row) => row.pageId));
|
|
70
83
|
const candidates = (pages ?? [])
|
|
71
|
-
.filter((page) => !exceptionIds.has(page.id.toLowerCase()))
|
|
84
|
+
.filter((page) => !exceptionIds.has(page.id.toLowerCase()) && !landingIds.has(page.id.toLowerCase()))
|
|
72
85
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
73
86
|
|
|
74
87
|
return (
|
|
@@ -127,7 +140,8 @@ export function BookingSettingsCard({ deps }: { deps: BookingSettingsDeps }): Re
|
|
|
127
140
|
<p className="mt-2 max-w-2xl text-sm text-ink/60">
|
|
128
141
|
While booking is hidden, these pages still show the booking links in their own
|
|
129
142
|
content — for example a link-only page for one group. The booking button in the menu
|
|
130
|
-
stays hidden on them too.
|
|
143
|
+
stays hidden on them too. A page hidden from the website while booking is hidden (see
|
|
144
|
+
below) cannot also be one of these.
|
|
131
145
|
</p>
|
|
132
146
|
<ul className="mt-4 divide-y divide-ink/10 border border-ink/10">
|
|
133
147
|
{settings.exceptions.length === 0 ? (
|
|
@@ -216,6 +230,22 @@ export function BookingSettingsCard({ deps }: { deps: BookingSettingsDeps }): Re
|
|
|
216
230
|
</button>
|
|
217
231
|
</div>
|
|
218
232
|
</div>
|
|
233
|
+
|
|
234
|
+
{settings.landing && deps.setLanding && deps.setLandingDefault ? (
|
|
235
|
+
<BookingLandingSection
|
|
236
|
+
deps={{
|
|
237
|
+
data: settings.landing,
|
|
238
|
+
bookingEnabled: settings.enabled,
|
|
239
|
+
pages,
|
|
240
|
+
exceptionIds,
|
|
241
|
+
pathFor,
|
|
242
|
+
locales: deps.locales && deps.locales.length > 0 ? deps.locales : ["sv", "en"],
|
|
243
|
+
setLanding: deps.setLanding,
|
|
244
|
+
setLandingDefault: deps.setLandingDefault,
|
|
245
|
+
refresh: deps.refresh,
|
|
246
|
+
}}
|
|
247
|
+
/>
|
|
248
|
+
) : null}
|
|
219
249
|
</>
|
|
220
250
|
)}
|
|
221
251
|
|
|
@@ -56,6 +56,12 @@ import {
|
|
|
56
56
|
type InsertRefusal,
|
|
57
57
|
} from "../blocks/block-tree";
|
|
58
58
|
import { subscribeSharedDrafts } from "../cms/shared-draft-store";
|
|
59
|
+
import {
|
|
60
|
+
acceptPreviewMessage,
|
|
61
|
+
EDITOR_SOURCE,
|
|
62
|
+
newPreviewSession,
|
|
63
|
+
previewOriginOf,
|
|
64
|
+
} from "../site/preview-messaging";
|
|
59
65
|
import { SaveGlobalDialog, type SaveGlobalResult } from "./SaveGlobalDialog";
|
|
60
66
|
|
|
61
67
|
function uid(prefix = "b") {
|
|
@@ -570,6 +576,27 @@ export function PageEditorView({
|
|
|
570
576
|
);
|
|
571
577
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
572
578
|
const [previewReady, setPreviewReady] = useState(false);
|
|
579
|
+
// The canvas is spoken to only at its own origin, in one session, in order
|
|
580
|
+
// (see preview-messaging); its answers count only from its own window.
|
|
581
|
+
const previewSession = useMemo(() => newPreviewSession(), []);
|
|
582
|
+
const previewSeq = useRef(0);
|
|
583
|
+
const previewOrigin = useMemo(
|
|
584
|
+
() =>
|
|
585
|
+
typeof window === "undefined" ? null : previewOriginOf(previewUrl(pageKey), window.location.href),
|
|
586
|
+
[previewUrl, pageKey],
|
|
587
|
+
);
|
|
588
|
+
const postToPreview = useCallback(
|
|
589
|
+
(message: Record<string, unknown>) => {
|
|
590
|
+
const frame = iframeRef.current?.contentWindow;
|
|
591
|
+
if (!frame || !previewOrigin) return;
|
|
592
|
+
previewSeq.current += 1;
|
|
593
|
+
frame.postMessage(
|
|
594
|
+
{ source: EDITOR_SOURCE, session: previewSession, seq: previewSeq.current, ...message },
|
|
595
|
+
previewOrigin,
|
|
596
|
+
);
|
|
597
|
+
},
|
|
598
|
+
[previewOrigin, previewSession],
|
|
599
|
+
);
|
|
573
600
|
|
|
574
601
|
const currentBlocks = controlledBlocks ?? blocks;
|
|
575
602
|
|
|
@@ -581,19 +608,15 @@ export function PageEditorView({
|
|
|
581
608
|
// Push draft to iframe whenever blocks/locale/selection change.
|
|
582
609
|
useEffect(() => {
|
|
583
610
|
if (!previewReady) return;
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
},
|
|
594
|
-
"*",
|
|
595
|
-
);
|
|
596
|
-
}, [currentBlocks, globals, selectedId, locale, previewReady, sharedDrafts]);
|
|
611
|
+
postToPreview({
|
|
612
|
+
type: "draft",
|
|
613
|
+
blocks: currentBlocks,
|
|
614
|
+
globals,
|
|
615
|
+
selectedId,
|
|
616
|
+
locale,
|
|
617
|
+
sharedContent: sharedDrafts,
|
|
618
|
+
});
|
|
619
|
+
}, [currentBlocks, globals, selectedId, locale, previewReady, sharedDrafts, postToPreview]);
|
|
597
620
|
|
|
598
621
|
useEffect(() => {
|
|
599
622
|
const t = setTimeout(() => {
|
|
@@ -604,27 +627,27 @@ export function PageEditorView({
|
|
|
604
627
|
}, [pageKey, currentBlocks, globals, locale]);
|
|
605
628
|
|
|
606
629
|
/** Select a block from the outline and scroll the preview to it. */
|
|
607
|
-
const selectAndReveal = useCallback(
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
{
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
630
|
+
const selectAndReveal = useCallback(
|
|
631
|
+
(id: string) => {
|
|
632
|
+
setSelectedId(id);
|
|
633
|
+
postToPreview({ type: "scrollTo", blockId: id });
|
|
634
|
+
},
|
|
635
|
+
[postToPreview],
|
|
636
|
+
);
|
|
614
637
|
|
|
615
638
|
// Listen for iframe messages.
|
|
616
639
|
useEffect(() => {
|
|
617
640
|
const onMsg = (ev: MessageEvent) => {
|
|
618
|
-
const d = ev
|
|
619
|
-
if (d
|
|
641
|
+
const d = acceptPreviewMessage(ev, { frame: iframeRef.current?.contentWindow, previewOrigin });
|
|
642
|
+
if (!d) return;
|
|
620
643
|
if (d.type === "ready") setPreviewReady(true);
|
|
621
|
-
if (d.type === "select"
|
|
644
|
+
if (d.type === "select") {
|
|
622
645
|
if (findBlockDeep(currentBlocks, d.blockId)) setSelectedId(d.blockId);
|
|
623
646
|
}
|
|
624
647
|
};
|
|
625
648
|
window.addEventListener("message", onMsg);
|
|
626
649
|
return () => window.removeEventListener("message", onMsg);
|
|
627
|
-
}, [currentBlocks]);
|
|
650
|
+
}, [currentBlocks, previewOrigin]);
|
|
628
651
|
|
|
629
652
|
// Keep the outline highlight visible whenever the selection changes.
|
|
630
653
|
useEffect(() => {
|
|
@@ -319,6 +319,13 @@ export type BackofficeHost = {
|
|
|
319
319
|
getBookingSettings: HostServerFn;
|
|
320
320
|
setBookingEnabled: HostServerFn;
|
|
321
321
|
setBookingException: HostServerFn;
|
|
322
|
+
/**
|
|
323
|
+
* Booking-off landing pages (`setBookingLandingCore`,
|
|
324
|
+
* `setBookingLandingDefaultCore`). Optional: without both, the card has
|
|
325
|
+
* no landing-page subsection.
|
|
326
|
+
*/
|
|
327
|
+
setBookingLanding?: HostServerFn;
|
|
328
|
+
setBookingLandingDefault?: HostServerFn;
|
|
322
329
|
};
|
|
323
330
|
};
|
|
324
331
|
/**
|
|
@@ -238,6 +238,11 @@ function BookingSettingsSection() {
|
|
|
238
238
|
const load = useServerFn(booking?.fns.getBookingSettings ?? notRegistered);
|
|
239
239
|
const setEnabled = useServerFn(booking?.fns.setBookingEnabled ?? notRegistered);
|
|
240
240
|
const setException = useServerFn(booking?.fns.setBookingException ?? notRegistered);
|
|
241
|
+
const setLanding = useServerFn(booking?.fns.setBookingLanding ?? notRegistered);
|
|
242
|
+
const setLandingDefault = useServerFn(booking?.fns.setBookingLandingDefault ?? notRegistered);
|
|
243
|
+
const landingRegistered = Boolean(
|
|
244
|
+
booking?.fns.setBookingLanding && booking?.fns.setBookingLandingDefault,
|
|
245
|
+
);
|
|
241
246
|
const listPages = useServerFn(host.pages?.fns.listPages ?? notRegistered);
|
|
242
247
|
const q = useQuery({
|
|
243
248
|
queryKey: ["admin", "booking-settings"],
|
|
@@ -256,13 +261,27 @@ function BookingSettingsSection() {
|
|
|
256
261
|
deps={{
|
|
257
262
|
settings: q.data as BookingSettingsData | undefined,
|
|
258
263
|
error: (q.error as Error | null) ?? null,
|
|
259
|
-
pages: (
|
|
260
|
-
|
|
261
|
-
|
|
264
|
+
pages: (
|
|
265
|
+
pagesQ.data as
|
|
266
|
+
| Array<{ id: string; key: string; name: string; protected?: boolean }>
|
|
267
|
+
| undefined
|
|
268
|
+
)?.map((page) => ({
|
|
269
|
+
id: page.id,
|
|
270
|
+
key: page.key,
|
|
271
|
+
name: page.name,
|
|
272
|
+
protected: page.protected === true,
|
|
273
|
+
})),
|
|
262
274
|
pathFor: (key) =>
|
|
263
275
|
resolveUrl ? resolveUrl(key, host.siteConfig.defaultLocale).path : `/${key}`,
|
|
264
276
|
setEnabled: (input) => setEnabled({ data: input } as never),
|
|
265
277
|
setException: (input) => setException({ data: input } as never),
|
|
278
|
+
...(landingRegistered
|
|
279
|
+
? {
|
|
280
|
+
setLanding: (input) => setLanding({ data: input } as never),
|
|
281
|
+
setLandingDefault: (input) => setLandingDefault({ data: input } as never),
|
|
282
|
+
}
|
|
283
|
+
: {}),
|
|
284
|
+
locales: host.locales,
|
|
266
285
|
refresh: () => {
|
|
267
286
|
void q.refetch();
|
|
268
287
|
void qc.invalidateQueries({ queryKey: ["admin", "link-visibility"] });
|
|
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from
|
|
|
3
3
|
import { useT } from "../../i18n";
|
|
4
4
|
import type { LocalizedString } from "../../i18n";
|
|
5
5
|
import { Reveal } from "../../ui/Reveal";
|
|
6
|
+
import { useDiscoverableLink } from "../../site/link-visibility";
|
|
6
7
|
import { countdownColorStyle, countdownRadius } from "../countdown-colors";
|
|
7
8
|
import { countdownParts, resolveCountdownTarget } from "../countdown-time";
|
|
8
9
|
import { countdownTextClasses } from "../countdown-typography";
|
|
@@ -51,6 +52,7 @@ const pad = (n: number) => String(n).padStart(2, "0");
|
|
|
51
52
|
*/
|
|
52
53
|
export function CountdownBlock({ props: p }: { props: CountdownProps }) {
|
|
53
54
|
const { t } = useT();
|
|
55
|
+
const discoverable = useDiscoverableLink();
|
|
54
56
|
const replacement = useCountdownReplacement();
|
|
55
57
|
|
|
56
58
|
const target = useMemo(
|
|
@@ -137,7 +139,7 @@ export function CountdownBlock({ props: p }: { props: CountdownProps }) {
|
|
|
137
139
|
return section(
|
|
138
140
|
<>
|
|
139
141
|
{heading(p.expiredTitle, p.expiredBody)}
|
|
140
|
-
{href && label ? (
|
|
142
|
+
{href && label && discoverable(href) ? (
|
|
141
143
|
<a
|
|
142
144
|
href={href}
|
|
143
145
|
className="mt-8 inline-flex items-center gap-2 rounded-[var(--cd-radius)] bg-[var(--cd-tile-bg)] px-7 py-4 text-xs uppercase tracking-[0.22em] text-[var(--cd-digit)] transition-opacity hover:opacity-90"
|
|
@@ -2,11 +2,13 @@ import { ArrowRight } from "lucide-react";
|
|
|
2
2
|
import { useT } from "../../i18n";
|
|
3
3
|
import { resolveCta, resolvedText } from "../optional-content";
|
|
4
4
|
import { Reveal } from "../../ui/Reveal";
|
|
5
|
+
import { useDiscoverableLink } from "../../site/link-visibility";
|
|
5
6
|
import type { CtaBannerProps } from "../types";
|
|
6
7
|
|
|
7
8
|
/** Localized call-to-action banner with up to two links. */
|
|
8
9
|
export function CtaBannerBlock({ props }: { props: CtaBannerProps }) {
|
|
9
10
|
const { locale } = useT();
|
|
11
|
+
const discoverable = useDiscoverableLink();
|
|
10
12
|
const tone = props.tone ?? "ink";
|
|
11
13
|
const dark = tone === "ink";
|
|
12
14
|
const bg = dark ? "bg-ink text-paper" : tone === "sea" ? "bg-ice text-ink" : "bg-paper text-ink";
|
|
@@ -23,11 +25,15 @@ export function CtaBannerBlock({ props }: { props: CtaBannerProps }) {
|
|
|
23
25
|
const title = resolvedText(props.title, locale);
|
|
24
26
|
const body = resolvedText(props.body, locale);
|
|
25
27
|
|
|
26
|
-
// Both a visible label and a usable link, or the whole element goes
|
|
28
|
+
// Both a visible label and a usable link, or the whole element goes — as
|
|
29
|
+
// does a button to a page hidden while booking is hidden.
|
|
27
30
|
const links = [
|
|
28
31
|
{ cta: resolveCta(props.cta, locale), className: primary },
|
|
29
32
|
{ cta: resolveCta(props.secondaryCta, locale), className: secondary },
|
|
30
|
-
].filter(
|
|
33
|
+
].filter(
|
|
34
|
+
(l): l is { cta: NonNullable<typeof l.cta>; className: string } =>
|
|
35
|
+
l.cta !== null && discoverable(l.cta.href),
|
|
36
|
+
);
|
|
31
37
|
|
|
32
38
|
return (
|
|
33
39
|
<section className={`${bg} py-20 md:py-28`}>
|
|
@@ -4,6 +4,7 @@ import { resolveCta, resolvedText } from "../optional-content";
|
|
|
4
4
|
import { FocalImage } from "../../host";
|
|
5
5
|
import { LazyVideo } from "../../host";
|
|
6
6
|
import { useClaimPriority } from "../../media/priority";
|
|
7
|
+
import { useDiscoverableLink } from "../../site/link-visibility";
|
|
7
8
|
import type { HeroFullProps } from "../types";
|
|
8
9
|
|
|
9
10
|
const HEIGHTS: Record<NonNullable<HeroFullProps["height"]>, string> = {
|
|
@@ -43,6 +44,7 @@ const OVERLAYS: Record<NonNullable<HeroFullProps["overlay"]>, string> = {
|
|
|
43
44
|
*/
|
|
44
45
|
export function HeroFullBlock({ props: p }: { props: HeroFullProps }) {
|
|
45
46
|
const { t, locale } = useT();
|
|
47
|
+
const discoverable = useDiscoverableLink();
|
|
46
48
|
|
|
47
49
|
const align = p.textAlign ?? "left";
|
|
48
50
|
const vertical = p.verticalAlign ?? "center";
|
|
@@ -113,10 +115,14 @@ export function HeroFullBlock({ props: p }: { props: HeroFullProps }) {
|
|
|
113
115
|
// Both halves or nothing: a label with no link is not a button, and a link
|
|
114
116
|
// with no label is an empty target. Dropped entirely, so no border, arrow,
|
|
115
117
|
// anchor or gap is left where the call to action would have been.
|
|
118
|
+
// A button to a page hidden while booking is hidden is dropped the same way.
|
|
116
119
|
const ctas = [
|
|
117
120
|
{ cta: resolveCta(p.primaryCta, locale), className: primaryBtn },
|
|
118
121
|
{ cta: resolveCta(p.secondaryCta, locale), className: secondaryBtn },
|
|
119
|
-
].filter(
|
|
122
|
+
].filter(
|
|
123
|
+
(c): c is { cta: NonNullable<typeof c.cta>; className: string } =>
|
|
124
|
+
c.cta !== null && discoverable(c.cta.href),
|
|
125
|
+
);
|
|
120
126
|
|
|
121
127
|
return (
|
|
122
128
|
<section
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useT } from "../../i18n";
|
|
2
2
|
import { Reveal } from "../../ui/Reveal";
|
|
3
|
+
import { useDiscoverableLink } from "../../site/link-visibility";
|
|
3
4
|
import { MobileCarousel, clampAutoplayMs } from "../../ui/MobileCarousel";
|
|
4
5
|
import { resolveBlockIcon } from "../icons";
|
|
5
6
|
import type { IconGridProps } from "../types";
|
|
@@ -28,6 +29,7 @@ const TONE: Record<string, string> = {
|
|
|
28
29
|
*/
|
|
29
30
|
export function IconGridBlock({ props }: { props: IconGridProps }) {
|
|
30
31
|
const { t } = useT();
|
|
32
|
+
const discoverable = useDiscoverableLink();
|
|
31
33
|
const items = props.items ?? [];
|
|
32
34
|
const tone = TONE[props.tone ?? "white"] ?? "bg-white";
|
|
33
35
|
const cols = COLS[props.columns ?? 3] ?? COLS[3];
|
|
@@ -74,7 +76,9 @@ export function IconGridBlock({ props }: { props: IconGridProps }) {
|
|
|
74
76
|
const cls = bare
|
|
75
77
|
? `flex h-full flex-col ${textAlignClass}`
|
|
76
78
|
: `flex h-full flex-col p-7 md:p-8 ${tone} ${textAlignClass} transition-colors hover:bg-ice/40`;
|
|
77
|
-
|
|
79
|
+
// A card keeps its content; only a link to a page hidden while booking
|
|
80
|
+
// is hidden is taken off it.
|
|
81
|
+
return item.href && discoverable(item.href) ? (
|
|
78
82
|
<a key={item.id ?? i} href={item.href} className={`${cls} group`}>
|
|
79
83
|
{inner}
|
|
80
84
|
</a>
|
|
@@ -4,6 +4,7 @@ import { useT } from "../../i18n";
|
|
|
4
4
|
import type { InstagramProps } from "../types";
|
|
5
5
|
import { NearViewport, ChunkErrorBoundary } from "../../ui/NearViewport";
|
|
6
6
|
import { ASPECT_CLASS } from "./InstagramTiles";
|
|
7
|
+
import { useDiscoverableLink } from "../../site/link-visibility";
|
|
7
8
|
|
|
8
9
|
const GAP_CLASS: Record<NonNullable<InstagramProps["gap"]>, string> = {
|
|
9
10
|
none: "gap-0",
|
|
@@ -24,6 +25,7 @@ function clamp(n: number | undefined, min: number, max: number, fallback: number
|
|
|
24
25
|
|
|
25
26
|
export function InstagramBlock({ props }: { props: InstagramProps }) {
|
|
26
27
|
const { t } = useT();
|
|
28
|
+
const discoverable = useDiscoverableLink();
|
|
27
29
|
const layout = props.layout ?? "grid";
|
|
28
30
|
const cM = clamp(props.columnsMobile, 1, 4, 2);
|
|
29
31
|
const cT = clamp(props.columnsTablet, 2, 6, 3);
|
|
@@ -39,8 +41,10 @@ export function InstagramBlock({ props }: { props: InstagramProps }) {
|
|
|
39
41
|
const items = allItems.slice(0, limit);
|
|
40
42
|
|
|
41
43
|
const handle = (props.handle ?? "").replace(/^@/, "");
|
|
42
|
-
const
|
|
44
|
+
const configuredProfile =
|
|
43
45
|
props.profileHref ?? (handle ? `https://instagram.com/${handle}` : undefined);
|
|
46
|
+
// Normally Instagram; never a page hidden while booking is hidden.
|
|
47
|
+
const profileHref = configuredProfile && discoverable(configuredProfile) ? configuredProfile : undefined;
|
|
44
48
|
|
|
45
49
|
// Reserve the tile wall's height so nothing moves when the chunk arrives:
|
|
46
50
|
// the same aspect ratio, the same number of rows, the same gaps.
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { Instagram as InstagramIcon } from "lucide-react";
|
|
10
10
|
import { useT } from "../../i18n";
|
|
11
11
|
import { FocalImage } from "../../host";
|
|
12
|
+
import { useDiscoverableLink } from "../../site/link-visibility";
|
|
12
13
|
import type { InstagramProps, InstagramItem } from "../types";
|
|
13
14
|
|
|
14
15
|
export const ASPECT_CLASS: Record<NonNullable<InstagramProps["aspect"]>, string> = {
|
|
@@ -40,6 +41,7 @@ function Tile({
|
|
|
40
41
|
sizes: string;
|
|
41
42
|
}) {
|
|
42
43
|
const { t } = useT();
|
|
44
|
+
const discoverable = useDiscoverableLink();
|
|
43
45
|
const alt = item.alt ? t(item.alt as Parameters<typeof t>[0]) : "";
|
|
44
46
|
const inner = (
|
|
45
47
|
<div className={`relative ${ASPECT_CLASS[aspect]} bg-ink/5 overflow-hidden group`}>
|
|
@@ -63,7 +65,7 @@ function Tile({
|
|
|
63
65
|
</div>
|
|
64
66
|
);
|
|
65
67
|
|
|
66
|
-
if (item.href) {
|
|
68
|
+
if (item.href && discoverable(item.href)) {
|
|
67
69
|
return (
|
|
68
70
|
<a href={item.href} target="_blank" rel="noreferrer noopener" className="block">
|
|
69
71
|
{inner}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useT } from "../../i18n";
|
|
2
|
+
import { useDiscoverableLink } from "../../site/link-visibility";
|
|
2
3
|
import { Reveal } from "../../ui/Reveal";
|
|
3
4
|
import { FocalImage } from "../../host";
|
|
4
5
|
import type { LinkTilesProps, LinkTilesItem } from "../types";
|
|
@@ -120,10 +121,16 @@ function Tile({
|
|
|
120
121
|
/**
|
|
121
122
|
* Row of linked image tiles with an overlay label.
|
|
122
123
|
* Every tile's image, text and link is editable in the backoffice.
|
|
124
|
+
*
|
|
125
|
+
* Tiles are page cards: one leading to a booking-off landing page is left out
|
|
126
|
+
* while booking is hidden, like its menu entry, and returns when booking is
|
|
127
|
+
* shown again. Nothing stored changes. Outside the public site (the builder,
|
|
128
|
+
* the backoffice) no policy is provided and every tile is shown.
|
|
123
129
|
*/
|
|
124
130
|
export function LinkTilesBlock({ props }: { props: LinkTilesProps }) {
|
|
125
131
|
const { t } = useT();
|
|
126
|
-
const
|
|
132
|
+
const discoverable = useDiscoverableLink();
|
|
133
|
+
const items = (props.items ?? []).filter((item) => discoverable(item.href));
|
|
127
134
|
const tone = TONE[props.tone ?? "white"] ?? "bg-white";
|
|
128
135
|
const dark = (props.tone ?? "white") === "ink";
|
|
129
136
|
const gap = GAP[props.gap ?? "md"] ?? GAP.md;
|
|
@@ -15,6 +15,7 @@ import { Lightbox } from "../../ui/Lightbox";
|
|
|
15
15
|
import { FocalImage } from "../../host";
|
|
16
16
|
import { LazyVideo } from "../../host";
|
|
17
17
|
import { useClaimPriority } from "../../media/priority";
|
|
18
|
+
import { useDiscoverableLink } from "../../site/link-visibility";
|
|
18
19
|
import type { MediaSplitProps, MediaSplitSlide } from "../types";
|
|
19
20
|
|
|
20
21
|
const PADDING: Record<NonNullable<MediaSplitProps["padding"]>, string> = {
|
|
@@ -55,6 +56,7 @@ function Slideshow({
|
|
|
55
56
|
radius: string;
|
|
56
57
|
}) {
|
|
57
58
|
const { t, locale } = useT();
|
|
59
|
+
const discoverable = useDiscoverableLink();
|
|
58
60
|
// The first slide is the only slide visible on load, so it is this block's
|
|
59
61
|
// priority candidate — but only if no earlier block already claimed it.
|
|
60
62
|
const slidePriority = useClaimPriority(slides[0]?.image ?? null);
|
|
@@ -154,7 +156,8 @@ function Slideshow({
|
|
|
154
156
|
);
|
|
155
157
|
return (
|
|
156
158
|
<div key={s.id ?? i} className="w-full shrink-0 snap-center">
|
|
157
|
-
{
|
|
159
|
+
{/* A slide linking to a page hidden while booking is hidden keeps its picture, not the link. */}
|
|
160
|
+
{s.href && discoverable(s.href) ? (
|
|
158
161
|
<a
|
|
159
162
|
href={s.href}
|
|
160
163
|
target="_blank"
|
|
@@ -230,6 +233,7 @@ export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
|
|
|
230
233
|
: (props.image ?? null),
|
|
231
234
|
);
|
|
232
235
|
const { t, locale } = useT();
|
|
236
|
+
const discoverable = useDiscoverableLink();
|
|
233
237
|
const side = props.mediaSide ?? "left";
|
|
234
238
|
const tone = props.tone ?? "white";
|
|
235
239
|
const dark = tone === "ink";
|
|
@@ -355,13 +359,14 @@ export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
|
|
|
355
359
|
const body = resolvedText(props.body, locale);
|
|
356
360
|
const hasHead = Boolean(eyebrow || title);
|
|
357
361
|
|
|
358
|
-
// Both a visible label and a usable link, or the whole element goes
|
|
362
|
+
// Both a visible label and a usable link, or the whole element goes — as
|
|
363
|
+
// does a button to a page hidden while booking is hidden.
|
|
359
364
|
const links = [
|
|
360
365
|
{ cta: resolveCta(props.cta, locale), className: primaryBtn },
|
|
361
366
|
{ cta: resolveCta(props.secondaryCta, locale), className: secondaryBtn },
|
|
362
367
|
].filter(
|
|
363
368
|
(l): l is { cta: NonNullable<typeof l.cta>; className: string } =>
|
|
364
|
-
l.cta !== null,
|
|
369
|
+
l.cta !== null && discoverable(l.cta.href),
|
|
365
370
|
);
|
|
366
371
|
|
|
367
372
|
const inner = (
|
|
@@ -2,6 +2,7 @@ import { useT } from "../../i18n";
|
|
|
2
2
|
import { resolveCta, resolvedText } from "../optional-content";
|
|
3
3
|
import { BUTTON_COLOR_CLASS, buttonColorStyle } from "../button-colors";
|
|
4
4
|
import { Reveal } from "../../ui/Reveal";
|
|
5
|
+
import { useDiscoverableLink } from "../../site/link-visibility";
|
|
5
6
|
import type { PriceTableProps } from "../types";
|
|
6
7
|
|
|
7
8
|
const TONE: Record<string, string> = {
|
|
@@ -22,11 +23,11 @@ export function PriceTableBlock({ props }: { props: PriceTableProps }) {
|
|
|
22
23
|
const lead = resolvedText(props.lead, locale);
|
|
23
24
|
const note = resolvedText(props.note, locale);
|
|
24
25
|
const unitLabel = resolvedText(props.unitLabel, locale);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
);
|
|
26
|
+
const discoverable = useDiscoverableLink();
|
|
27
|
+
// An empty label or link renders nothing rather than a bare button, and
|
|
28
|
+
// neither does a button to a page hidden while booking is hidden.
|
|
29
|
+
const resolved = resolveCta({ label: props.ctaLabel, href: props.ctaHref }, locale);
|
|
30
|
+
const cta = resolved && discoverable(resolved.href) ? resolved : null;
|
|
30
31
|
const groups = props.groups ?? [];
|
|
31
32
|
const dark = (props.tone ?? "paper") === "ink";
|
|
32
33
|
const tone = TONE[props.tone ?? "paper"] ?? "bg-paper";
|
|
@@ -3,6 +3,7 @@ import { ArrowRight, Sparkles } from "lucide-react";
|
|
|
3
3
|
import { useT } from "../../i18n";
|
|
4
4
|
import { Reveal } from "../../ui/Reveal";
|
|
5
5
|
import { useHostSiteActions, useHostTripSource } from "../../host";
|
|
6
|
+
import { useDiscoverableLink } from "../../site/link-visibility";
|
|
6
7
|
import { tripNumber, type TripItem, type TripListProps } from "../../trips";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -17,6 +18,9 @@ import { tripNumber, type TripItem, type TripListProps } from "../../trips";
|
|
|
17
18
|
*/
|
|
18
19
|
export function TripListBlock({ props }: { props: TripListProps }) {
|
|
19
20
|
const { t } = useT();
|
|
21
|
+
// A trip's button normally goes to booking (stripped while booking is
|
|
22
|
+
// hidden); never to a page hidden while booking is hidden either.
|
|
23
|
+
const discoverable = useDiscoverableLink();
|
|
20
24
|
const actions = useHostSiteActions();
|
|
21
25
|
const source = useHostTripSource() as TripItem[];
|
|
22
26
|
const globalItems = (source ?? []).map((c, i) => ({ ...c, id: c.id ?? `global-${i}` }));
|
|
@@ -165,7 +169,7 @@ export function TripListBlock({ props }: { props: TripListProps }) {
|
|
|
165
169
|
<span className="text-sm text-ink/65 line-through">{t(c.oldPrice)}</span>
|
|
166
170
|
) : null}
|
|
167
171
|
</div>
|
|
168
|
-
{c.bookUrl ? (
|
|
172
|
+
{c.bookUrl && discoverable(c.bookUrl) ? (
|
|
169
173
|
<a
|
|
170
174
|
href={c.bookUrl}
|
|
171
175
|
target="_blank"
|