@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.
@@ -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
 
@@ -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: (pagesQ.data as Array<{ id: string; key: string; name: string }> | undefined)?.map(
260
- (page) => ({ id: page.id, key: page.key, name: page.name }),
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((l): l is { cta: NonNullable<typeof l.cta>; className: string } => l.cta !== null);
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((c): c is { cta: NonNullable<typeof c.cta>; className: string } => c.cta !== null);
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
- return item.href ? (
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 profileHref =
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 items = props.items ?? [];
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
- {s.href ? (
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
- // An empty label or link renders nothing rather than a bare button.
26
- const cta = resolveCta(
27
- { label: props.ctaLabel, href: props.ctaHref },
28
- locale,
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"