@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.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 +241 -0
- package/README.md +23 -1
- package/docs/GOOGLE-SHEETS.md +145 -0
- package/docs/LAYOUT-BLOCKS.md +125 -0
- package/docs/MEDIA-FOLDERS.md +90 -0
- package/docs/NEW-SITE.md +35 -0
- package/docs/PAGES.md +452 -0
- package/docs/SCHEDULING.md +130 -0
- package/docs/TYPOGRAPHY.md +86 -0
- package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
- package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
- package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
- package/package.json +5 -1
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/FormEditorView.tsx +10 -0
- package/src/backoffice/FormSettingsView.tsx +8 -0
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +43 -0
- package/src/backoffice/PageBuilderView.tsx +193 -36
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionFormView.tsx +58 -0
- package/src/backoffice/StatusBadge.tsx +174 -0
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/TypographyPanel.tsx +308 -0
- package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
- package/src/backoffice/inspectors/blocks.tsx +149 -0
- package/src/backoffice/inspectors/fields.tsx +81 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +31 -0
- package/src/backoffice/routes/index.tsx +140 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
- package/src/backoffice/seo/page-seo-form.ts +11 -1
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/block-children.tsx +40 -0
- package/src/blocks/block-tree.ts +378 -0
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/registry.tsx +21 -0
- package/src/blocks/types.ts +40 -0
- package/src/cms/page-publication.ts +377 -0
- package/src/cms/page-status.ts +152 -0
- package/src/cms/timezone.ts +181 -0
- package/src/cms/use-publication-boundary.ts +84 -0
- package/src/cms/use-schedule-clock.ts +43 -0
- package/src/cms/visibility-schedule.ts +267 -0
- package/src/config/modules.ts +34 -10
- package/src/config/site-config.ts +32 -0
- package/src/config/typography.ts +143 -0
- package/src/http/cache-policy.ts +49 -2
- package/src/media/folders.ts +183 -0
- package/src/media/use-media-upload.ts +21 -4
- package/src/navigation/index.ts +34 -2
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +16 -0
- package/src/server/domains-core.ts +124 -107
- package/src/server/forms-delivery-core.ts +61 -10
- package/src/server/forms-submissions-core.ts +47 -0
- package/src/server/google-service-account.ts +325 -0
- package/src/server/google-sheets-core.ts +463 -0
- package/src/server/http/sitemap.ts +32 -8
- package/src/server/injections-core.ts +230 -49
- package/src/server/media-library-core.ts +27 -6
- package/src/server/pages-core.ts +680 -184
- package/src/server/published-cache.ts +25 -4
- package/src/server/resolve-globals.ts +6 -1
- package/src/server/schema-drift.ts +4 -0
- package/src/server/schemas.ts +15 -0
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +23 -2
- package/src/site/google-fonts.ts +191 -0
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes/public-page.tsx +12 -1
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/site/typography.tsx +75 -0
- package/src/tracking/injections.ts +52 -9
- package/src/ui/MobileCarousel.tsx +289 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useT } from "../../i18n";
|
|
2
2
|
import { Reveal } from "../../ui/Reveal";
|
|
3
|
+
import { MobileCarousel, clampAutoplayMs } from "../../ui/MobileCarousel";
|
|
3
4
|
import { resolveBlockIcon } from "../icons";
|
|
4
5
|
import type { IconGridProps } from "../types";
|
|
5
6
|
|
|
@@ -19,6 +20,11 @@ const TONE: Record<string, string> = {
|
|
|
19
20
|
/**
|
|
20
21
|
* Icon cards: icon + title + optional meta (e.g. price) and body text.
|
|
21
22
|
* One column on mobile, configurable 2–5 columns from `sm:`/`lg:` up.
|
|
23
|
+
*
|
|
24
|
+
* On phones the cards can instead be a swipeable carousel (`mobileLayout:
|
|
25
|
+
* "slider"`). The switch happens below `sm`, so the tablet and desktop grids
|
|
26
|
+
* are exactly what they were; a block that never sets `mobileLayout` keeps
|
|
27
|
+
* stacking.
|
|
22
28
|
*/
|
|
23
29
|
export function IconGridBlock({ props }: { props: IconGridProps }) {
|
|
24
30
|
const { t } = useT();
|
|
@@ -28,11 +34,57 @@ export function IconGridBlock({ props }: { props: IconGridProps }) {
|
|
|
28
34
|
const bare = props.cardStyle === "bare";
|
|
29
35
|
const plainIcon = props.iconStyle === "plain";
|
|
30
36
|
const iconAlign = props.iconAlign ?? "center";
|
|
37
|
+
const slider = props.mobileLayout === "slider";
|
|
31
38
|
const iconAlignClass =
|
|
32
39
|
iconAlign === "left" ? "self-start" : iconAlign === "right" ? "self-end" : "self-center";
|
|
33
40
|
const textAlignClass =
|
|
34
41
|
iconAlign === "left" ? "text-left" : iconAlign === "right" ? "text-right" : "text-center";
|
|
35
42
|
|
|
43
|
+
const card = (item: (typeof items)[number], i: number) => {
|
|
44
|
+
const Icon = resolveBlockIcon(item.icon);
|
|
45
|
+
const inner = (
|
|
46
|
+
<>
|
|
47
|
+
<span
|
|
48
|
+
className={`${iconAlignClass} ${
|
|
49
|
+
plainIcon
|
|
50
|
+
? "inline-flex text-sea"
|
|
51
|
+
: "inline-flex size-11 shrink-0 items-center justify-center rounded-full border border-sea/35 bg-sea/10 text-sea"
|
|
52
|
+
}`}
|
|
53
|
+
aria-hidden
|
|
54
|
+
>
|
|
55
|
+
<Icon className="size-5" strokeWidth={1.5} />
|
|
56
|
+
</span>
|
|
57
|
+
<div className="mt-5 min-w-0">
|
|
58
|
+
<h3 className="font-serif text-lg md:text-xl leading-snug text-ink whitespace-pre-line">
|
|
59
|
+
{item.title ? t(item.title) : null}
|
|
60
|
+
</h3>
|
|
61
|
+
{item.meta ? (
|
|
62
|
+
<div className="mt-1.5 text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
|
|
63
|
+
{t(item.meta)}
|
|
64
|
+
</div>
|
|
65
|
+
) : null}
|
|
66
|
+
{item.body ? (
|
|
67
|
+
<p className="mt-3 text-sm leading-relaxed text-ink/70 whitespace-pre-line">
|
|
68
|
+
{t(item.body)}
|
|
69
|
+
</p>
|
|
70
|
+
) : null}
|
|
71
|
+
</div>
|
|
72
|
+
</>
|
|
73
|
+
);
|
|
74
|
+
const cls = bare
|
|
75
|
+
? `flex h-full flex-col ${textAlignClass}`
|
|
76
|
+
: `flex h-full flex-col p-7 md:p-8 ${tone} ${textAlignClass} transition-colors hover:bg-ice/40`;
|
|
77
|
+
return item.href ? (
|
|
78
|
+
<a key={item.id ?? i} href={item.href} className={`${cls} group`}>
|
|
79
|
+
{inner}
|
|
80
|
+
</a>
|
|
81
|
+
) : (
|
|
82
|
+
<div key={item.id ?? i} className={cls}>
|
|
83
|
+
{inner}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
|
|
36
88
|
return (
|
|
37
89
|
<section id={props.anchor || undefined} className={`${tone} py-20 md:py-28`}>
|
|
38
90
|
<div className="container-editorial">
|
|
@@ -58,59 +110,32 @@ export function IconGridBlock({ props }: { props: IconGridProps }) {
|
|
|
58
110
|
</Reveal>
|
|
59
111
|
)}
|
|
60
112
|
|
|
61
|
-
|
|
62
|
-
className=
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
<div className="mt-5 min-w-0">
|
|
82
|
-
<h3 className="font-serif text-lg md:text-xl leading-snug text-ink whitespace-pre-line">
|
|
83
|
-
{item.title ? t(item.title) : null}
|
|
84
|
-
</h3>
|
|
85
|
-
{item.meta ? (
|
|
86
|
-
<div className="mt-1.5 text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
|
|
87
|
-
{t(item.meta)}
|
|
88
|
-
</div>
|
|
89
|
-
) : null}
|
|
90
|
-
{item.body ? (
|
|
91
|
-
<p className="mt-3 text-sm leading-relaxed text-ink/70 whitespace-pre-line">
|
|
92
|
-
{t(item.body)}
|
|
93
|
-
</p>
|
|
94
|
-
) : null}
|
|
95
|
-
</div>
|
|
96
|
-
</>
|
|
97
|
-
);
|
|
98
|
-
const cls = bare
|
|
99
|
-
? `flex h-full flex-col ${textAlignClass}`
|
|
100
|
-
: `flex h-full flex-col p-7 md:p-8 ${tone} ${textAlignClass} transition-colors hover:bg-ice/40`;
|
|
101
|
-
return (
|
|
113
|
+
{slider ? (
|
|
114
|
+
<div className="mt-12 md:mt-16">
|
|
115
|
+
<MobileCarousel
|
|
116
|
+
label={props.title ? t(props.title) : "Cards"}
|
|
117
|
+
gridClassName={`grid-cols-1 ${cols} ${bare ? "" : "sm:bg-ink/10"}`}
|
|
118
|
+
gapClassName={bare ? "gap-8 md:gap-10" : "gap-4 sm:gap-px"}
|
|
119
|
+
slideClassName={bare ? "" : "border border-ink/10 sm:border-0"}
|
|
120
|
+
autoplay={props.mobileAutoplay === true}
|
|
121
|
+
autoplayMs={clampAutoplayMs(props.mobileAutoplayMs)}
|
|
122
|
+
>
|
|
123
|
+
{items.map((item, i) => card(item, i))}
|
|
124
|
+
</MobileCarousel>
|
|
125
|
+
</div>
|
|
126
|
+
) : (
|
|
127
|
+
<div
|
|
128
|
+
className={`mt-12 md:mt-16 grid grid-cols-1 ${cols} ${
|
|
129
|
+
bare ? "gap-8 md:gap-10" : "gap-px bg-ink/10"
|
|
130
|
+
}`}
|
|
131
|
+
>
|
|
132
|
+
{items.map((item, i) => (
|
|
102
133
|
<Reveal key={item.id ?? i} delay={i * 50}>
|
|
103
|
-
{item
|
|
104
|
-
<a href={item.href} className={`${cls} group`}>
|
|
105
|
-
{inner}
|
|
106
|
-
</a>
|
|
107
|
-
) : (
|
|
108
|
-
<div className={cls}>{inner}</div>
|
|
109
|
-
)}
|
|
134
|
+
{card(item, i)}
|
|
110
135
|
</Reveal>
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
114
139
|
|
|
115
140
|
{props.note ? (
|
|
116
141
|
<Reveal>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useBlockChildren } from "../block-children";
|
|
2
|
+
import { readColumnsFromProps, layoutColumnCountFromProps } from "../block-tree";
|
|
3
|
+
import type { BlockProps, LayoutProps } from "../types";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Tailwind needs the finished class names in the source, so the responsive
|
|
7
|
+
* column counts are literal maps rather than generated strings.
|
|
8
|
+
*/
|
|
9
|
+
const TABLET_COLS: Record<number, string> = {
|
|
10
|
+
1: "sm:grid-cols-1",
|
|
11
|
+
2: "sm:grid-cols-2",
|
|
12
|
+
3: "sm:grid-cols-3",
|
|
13
|
+
4: "sm:grid-cols-4",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const DESKTOP_COLS: Record<number, string> = {
|
|
17
|
+
2: "lg:grid-cols-2",
|
|
18
|
+
3: "lg:grid-cols-3",
|
|
19
|
+
4: "lg:grid-cols-4",
|
|
20
|
+
5: "lg:grid-cols-5",
|
|
21
|
+
6: "lg:grid-cols-6",
|
|
22
|
+
7: "lg:grid-cols-7",
|
|
23
|
+
8: "lg:grid-cols-8",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const GAP: Record<string, string> = {
|
|
27
|
+
none: "gap-0",
|
|
28
|
+
sm: "gap-4",
|
|
29
|
+
md: "gap-8 md:gap-10",
|
|
30
|
+
lg: "gap-12 md:gap-16",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const ALIGN: Record<string, string> = {
|
|
34
|
+
start: "items-start",
|
|
35
|
+
center: "items-center",
|
|
36
|
+
stretch: "items-stretch",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const TONE: Record<string, string> = {
|
|
40
|
+
transparent: "",
|
|
41
|
+
white: "bg-white",
|
|
42
|
+
paper: "bg-paper",
|
|
43
|
+
ice: "bg-ice",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const PADDING: Record<string, string> = {
|
|
47
|
+
none: "",
|
|
48
|
+
compact: "py-8 md:py-10",
|
|
49
|
+
normal: "py-16 md:py-20",
|
|
50
|
+
roomy: "py-24 md:py-32",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A row of columns holding other blocks.
|
|
55
|
+
*
|
|
56
|
+
* It renders no content of its own: each column is a stack of child blocks,
|
|
57
|
+
* drawn through the page renderer so nested blocks behave exactly as they do
|
|
58
|
+
* at the top level. Columns always collapse to one on phones and every column
|
|
59
|
+
* is `min-w-0`, so a wide child (a table, a long word, an image) shrinks with
|
|
60
|
+
* its column instead of pushing the page sideways.
|
|
61
|
+
*/
|
|
62
|
+
export function LayoutBlock({ props }: { props: LayoutProps }) {
|
|
63
|
+
const { render, edit } = useBlockChildren();
|
|
64
|
+
const raw = props as unknown as BlockProps;
|
|
65
|
+
const columns = readColumnsFromProps(raw);
|
|
66
|
+
const desktop = layoutColumnCountFromProps(raw);
|
|
67
|
+
const tablet = Math.min(4, Math.max(1, props.columnsTablet ?? Math.min(2, desktop)));
|
|
68
|
+
|
|
69
|
+
const tone = TONE[props.tone ?? "transparent"] ?? "";
|
|
70
|
+
const padding = PADDING[props.padding ?? "none"] ?? "";
|
|
71
|
+
const gap = GAP[props.gap ?? "md"] ?? GAP["md"];
|
|
72
|
+
const align = ALIGN[props.align ?? "stretch"] ?? ALIGN["stretch"];
|
|
73
|
+
const contained = props.contained !== false;
|
|
74
|
+
|
|
75
|
+
const grid = (
|
|
76
|
+
<div
|
|
77
|
+
className={`grid w-full grid-cols-1 ${TABLET_COLS[tablet] ?? TABLET_COLS[2]} ${
|
|
78
|
+
DESKTOP_COLS[desktop] ?? DESKTOP_COLS[2]
|
|
79
|
+
} ${gap} ${align}`}
|
|
80
|
+
>
|
|
81
|
+
{columns.map((column, i) => (
|
|
82
|
+
<div key={i} className="min-w-0">
|
|
83
|
+
{column.length === 0 && edit ? (
|
|
84
|
+
<div className="flex min-h-24 items-center justify-center border border-dashed border-ink/20 p-4 text-[0.7rem] uppercase tracking-[0.18em] text-ink/35">
|
|
85
|
+
{`Column ${i + 1}`}
|
|
86
|
+
</div>
|
|
87
|
+
) : (
|
|
88
|
+
render(column)
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<section id={props.anchor || undefined} className={`${tone} ${padding}`.trim()}>
|
|
97
|
+
{contained ? <div className="container-editorial">{grid}</div> : grid}
|
|
98
|
+
</section>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -4,89 +4,34 @@
|
|
|
4
4
|
* The editor stores a wall-clock date/time (`2026-09-07T16:00`) plus an IANA
|
|
5
5
|
* timezone (`Europe/Stockholm`). That pair is resolved to one absolute instant,
|
|
6
6
|
* so every visitor — wherever they are — counts down to the same moment.
|
|
7
|
-
*
|
|
7
|
+
*
|
|
8
|
+
* The zone maths itself lives in `../cms/timezone`, which is the platform's one
|
|
9
|
+
* scheduling engine (countdowns, scheduled menu items and scheduled footer
|
|
10
|
+
* links all share it). The names below are kept as they were so existing
|
|
11
|
+
* imports keep working.
|
|
8
12
|
*/
|
|
9
13
|
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_TIMEZONE,
|
|
16
|
+
TIMEZONE_OPTIONS,
|
|
17
|
+
formatInZone,
|
|
18
|
+
timezoneOffsetMs,
|
|
19
|
+
zonedLocalToInstant,
|
|
20
|
+
} from "../cms/timezone";
|
|
21
|
+
|
|
10
22
|
/** A short list of zones offered in the editor; any IANA zone can be typed. */
|
|
11
|
-
export const COUNTDOWN_TIMEZONES =
|
|
12
|
-
"Europe/Stockholm",
|
|
13
|
-
"Europe/Oslo",
|
|
14
|
-
"Europe/Copenhagen",
|
|
15
|
-
"Europe/Helsinki",
|
|
16
|
-
"Europe/London",
|
|
17
|
-
"Europe/Berlin",
|
|
18
|
-
"Europe/Madrid",
|
|
19
|
-
"Europe/Athens",
|
|
20
|
-
"UTC",
|
|
21
|
-
"America/New_York",
|
|
22
|
-
"America/Chicago",
|
|
23
|
-
"America/Los_Angeles",
|
|
24
|
-
"Asia/Dubai",
|
|
25
|
-
"Asia/Bangkok",
|
|
26
|
-
"Asia/Tokyo",
|
|
27
|
-
"Australia/Sydney",
|
|
28
|
-
] as const;
|
|
23
|
+
export const COUNTDOWN_TIMEZONES = TIMEZONE_OPTIONS;
|
|
29
24
|
|
|
30
|
-
export const COUNTDOWN_DEFAULT_TIMEZONE =
|
|
25
|
+
export const COUNTDOWN_DEFAULT_TIMEZONE = DEFAULT_TIMEZONE;
|
|
31
26
|
|
|
32
|
-
|
|
33
|
-
const zone = tz?.trim() || COUNTDOWN_DEFAULT_TIMEZONE;
|
|
34
|
-
try {
|
|
35
|
-
new Intl.DateTimeFormat("en-GB", { timeZone: zone });
|
|
36
|
-
return zone;
|
|
37
|
-
} catch {
|
|
38
|
-
return "UTC";
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Offset (ms) of `tz` from UTC at the given instant. */
|
|
43
|
-
export function timezoneOffsetMs(instant: number, tz: string): number {
|
|
44
|
-
const zone = safeZone(tz);
|
|
45
|
-
const parts = new Intl.DateTimeFormat("en-GB", {
|
|
46
|
-
timeZone: zone,
|
|
47
|
-
hour12: false,
|
|
48
|
-
year: "numeric",
|
|
49
|
-
month: "2-digit",
|
|
50
|
-
day: "2-digit",
|
|
51
|
-
hour: "2-digit",
|
|
52
|
-
minute: "2-digit",
|
|
53
|
-
second: "2-digit",
|
|
54
|
-
}).formatToParts(new Date(instant));
|
|
55
|
-
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? "0");
|
|
56
|
-
const asUtc = Date.UTC(
|
|
57
|
-
get("year"),
|
|
58
|
-
get("month") - 1,
|
|
59
|
-
get("day"),
|
|
60
|
-
get("hour") % 24,
|
|
61
|
-
get("minute"),
|
|
62
|
-
get("second"),
|
|
63
|
-
);
|
|
64
|
-
return asUtc - instant;
|
|
65
|
-
}
|
|
27
|
+
export { timezoneOffsetMs, formatInZone };
|
|
66
28
|
|
|
67
29
|
/**
|
|
68
30
|
* Resolve `YYYY-MM-DDTHH:mm` in `tz` to an absolute timestamp (ms).
|
|
69
31
|
* Returns `null` when the value is empty or unparsable.
|
|
70
32
|
*/
|
|
71
33
|
export function resolveCountdownTarget(local?: string, tz?: string): number | null {
|
|
72
|
-
|
|
73
|
-
if (!raw) return null;
|
|
74
|
-
const m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/.exec(raw);
|
|
75
|
-
if (!m) return null;
|
|
76
|
-
const [, y, mo, d, h, mi, s] = m;
|
|
77
|
-
const naive = Date.UTC(
|
|
78
|
-
Number(y),
|
|
79
|
-
Number(mo) - 1,
|
|
80
|
-
Number(d),
|
|
81
|
-
Number(h ?? "0"),
|
|
82
|
-
Number(mi ?? "0"),
|
|
83
|
-
Number(s ?? "0"),
|
|
84
|
-
);
|
|
85
|
-
const zone = safeZone(tz);
|
|
86
|
-
// Two passes settle DST boundaries (the offset itself depends on the instant).
|
|
87
|
-
let instant = naive - timezoneOffsetMs(naive, zone);
|
|
88
|
-
instant = naive - timezoneOffsetMs(instant, zone);
|
|
89
|
-
return instant;
|
|
34
|
+
return zonedLocalToInstant(local, tz);
|
|
90
35
|
}
|
|
91
36
|
|
|
92
37
|
export type CountdownParts = {
|
|
@@ -111,18 +56,3 @@ export function countdownParts(target: number | null, now: number): CountdownPar
|
|
|
111
56
|
expired: target != null && total <= 0,
|
|
112
57
|
};
|
|
113
58
|
}
|
|
114
|
-
|
|
115
|
-
/** Human wall-clock text for an instant in a zone, e.g. "7 Sep 2026, 16:00 CEST". */
|
|
116
|
-
export function formatInZone(instant: number, tz?: string, locale = "en-GB"): string {
|
|
117
|
-
const zone = safeZone(tz);
|
|
118
|
-
return new Intl.DateTimeFormat(locale, {
|
|
119
|
-
timeZone: zone,
|
|
120
|
-
day: "numeric",
|
|
121
|
-
month: "short",
|
|
122
|
-
year: "numeric",
|
|
123
|
-
hour: "2-digit",
|
|
124
|
-
minute: "2-digit",
|
|
125
|
-
hour12: false,
|
|
126
|
-
timeZoneName: "short",
|
|
127
|
-
}).format(new Date(instant));
|
|
128
|
-
}
|
package/src/blocks/registry.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import { CabinTabsBlock } from "./components/CabinTabsBlock";
|
|
|
15
15
|
import { PriceTableBlock } from "./components/PriceTableBlock";
|
|
16
16
|
import { FormBlock } from "./components/FormBlock";
|
|
17
17
|
import { TripListBlock } from "./components/TripListBlock";
|
|
18
|
+
import { LayoutBlock } from "./components/LayoutBlock";
|
|
18
19
|
import type {
|
|
19
20
|
BlockProps,
|
|
20
21
|
FaqProps,
|
|
@@ -33,6 +34,7 @@ import type {
|
|
|
33
34
|
PriceTableProps,
|
|
34
35
|
FormProps,
|
|
35
36
|
TripListProps,
|
|
37
|
+
LayoutProps,
|
|
36
38
|
} from "./types";
|
|
37
39
|
import { BlockPropsProvider } from "./block-props-context";
|
|
38
40
|
|
|
@@ -66,6 +68,25 @@ export const wrapBlock = (C: ComponentType) =>
|
|
|
66
68
|
* Default copy here is intentionally brand-neutral.
|
|
67
69
|
*/
|
|
68
70
|
export const SHARED_BLOCK_DEFS: Record<string, BlockDef> = {
|
|
71
|
+
layout: {
|
|
72
|
+
type: "layout",
|
|
73
|
+
label: "Columns",
|
|
74
|
+
description:
|
|
75
|
+
"Container: puts other blocks side by side. 2\u20138 columns on desktop, configurable on tablet, stacked on phones.",
|
|
76
|
+
category: "section",
|
|
77
|
+
component: ({ props }) => <LayoutBlock props={props as LayoutProps} />,
|
|
78
|
+
defaultProps: {
|
|
79
|
+
columnsDesktop: 2,
|
|
80
|
+
columnsTablet: 2,
|
|
81
|
+
gap: "md",
|
|
82
|
+
align: "stretch",
|
|
83
|
+
tone: "transparent",
|
|
84
|
+
contained: true,
|
|
85
|
+
padding: "none",
|
|
86
|
+
columns: [[], []],
|
|
87
|
+
} satisfies LayoutProps as unknown as BlockProps,
|
|
88
|
+
hasInlineContent: false,
|
|
89
|
+
},
|
|
69
90
|
faq: {
|
|
70
91
|
type: "faq",
|
|
71
92
|
label: "FAQ",
|
package/src/blocks/types.ts
CHANGED
|
@@ -85,6 +85,15 @@ export type IconGridProps = {
|
|
|
85
85
|
/** Horizontal placement of the icon above the card text. Defaults to centred. */
|
|
86
86
|
iconAlign?: "left" | "center" | "right";
|
|
87
87
|
cardStyle?: "bordered" | "bare";
|
|
88
|
+
/**
|
|
89
|
+
* How the cards behave on phones. `stack` is the original single-column
|
|
90
|
+
* stack and stays the default, so existing pages are untouched.
|
|
91
|
+
*/
|
|
92
|
+
mobileLayout?: "stack" | "slider";
|
|
93
|
+
/** Advance the slider on its own. Off unless switched on. */
|
|
94
|
+
mobileAutoplay?: boolean;
|
|
95
|
+
/** Milliseconds between automatic advances. */
|
|
96
|
+
mobileAutoplayMs?: number;
|
|
88
97
|
anchor?: string;
|
|
89
98
|
items?: IconGridItem[];
|
|
90
99
|
};
|
|
@@ -519,3 +528,34 @@ export type CountdownProps = {
|
|
|
519
528
|
|
|
520
529
|
anchor?: string;
|
|
521
530
|
};
|
|
531
|
+
|
|
532
|
+
/** ---- Layout (columns container) ---- */
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* A container block: it holds other blocks in columns instead of rendering
|
|
536
|
+
* content of its own. Children live in `columns` — one entry per desktop
|
|
537
|
+
* column — and are walked with the helpers in `./block-tree`.
|
|
538
|
+
*/
|
|
539
|
+
export type LayoutProps = {
|
|
540
|
+
/** 2-8 columns side by side from the desktop breakpoint up. */
|
|
541
|
+
columnsDesktop?: 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
|
542
|
+
/** Columns from the tablet breakpoint up. */
|
|
543
|
+
columnsTablet?: 1 | 2 | 3 | 4;
|
|
544
|
+
/**
|
|
545
|
+
* Legacy. Phones always show one column — two columns on a 375px screen is
|
|
546
|
+
* two unreadable ones — so this is tolerated in stored payloads and ignored.
|
|
547
|
+
* There is no editor control for it.
|
|
548
|
+
*/
|
|
549
|
+
columnsMobile?: number;
|
|
550
|
+
/** Gap between columns, using the theme spacing scale. */
|
|
551
|
+
gap?: "none" | "sm" | "md" | "lg";
|
|
552
|
+
/** Vertical alignment of columns of unequal height. */
|
|
553
|
+
align?: "start" | "center" | "stretch";
|
|
554
|
+
tone?: "transparent" | "white" | "paper" | "ice";
|
|
555
|
+
/** Constrain the row to the site container width. */
|
|
556
|
+
contained?: boolean;
|
|
557
|
+
padding?: "none" | "compact" | "normal" | "roomy";
|
|
558
|
+
anchor?: string;
|
|
559
|
+
/** Child blocks, one array per column. Managed by the page builder. */
|
|
560
|
+
columns?: BlockInstance[][];
|
|
561
|
+
};
|