@bygghammar/cms-platform 0.72.0 → 0.74.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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.74.0
4
+
5
+ - `CountdownBlock`: typography controls for the heading, sub text and fine print
6
+ — size (small/medium/large/extra large), weight (normal → black), serif/sans,
7
+ letter spacing, capitals and line height, each resettable. Defaults reproduce
8
+ the previous appearance, so saved pages are unchanged.
9
+
10
+
11
+ ## 0.73.0
12
+
13
+ - New **Countdown** block: timezone-aware deadline (IANA zone + wall clock),
14
+ localized heading, sub text, fine print and unit labels, theme-token colours
15
+ with per-field overrides, configurable rounding, tile size, gaps, spacing,
16
+ alignment, separators, borders and shadows, and a choice of what happens at
17
+ zero (hide the block, show a global block instead, or schedule a hidden page
18
+ to go live at the deadline). The editor shows the current time, the resolved
19
+ deadline and the remaining time live.
20
+
21
+
3
22
  ## 0.72.0
4
23
 
5
24
  - `HeroFullBlock`: four new "Text colour" options for busy media — light/dark text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.72.0",
3
+ "version": "0.74.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -30,6 +30,7 @@
30
30
  "./host": "./src/host/index.tsx",
31
31
  "./blocks": "./src/blocks/index.ts",
32
32
  "./blocks/types": "./src/blocks/types.ts",
33
+ "./blocks/countdown-typography": "./src/blocks/countdown-typography.ts",
33
34
  "./blocks/icons": "./src/blocks/icons.ts",
34
35
  "./blocks/block-props-context": "./src/blocks/block-props-context.tsx",
35
36
  "./blocks/components/*": "./src/blocks/components/*.tsx",
@@ -6,6 +6,25 @@
6
6
  */
7
7
  import { useEffect, useMemo, useState } from "react";
8
8
  import { BUTTON_COLOR_DEFAULTS, type ButtonColors } from "../../blocks/button-colors";
9
+ import { COUNTDOWN_COLOR_DEFAULTS, type CountdownColors } from "../../blocks/countdown-colors";
10
+ import {
11
+ COUNTDOWN_TEXT_DEFAULTS,
12
+ COUNTDOWN_TEXT_FAMILY_OPTIONS,
13
+ COUNTDOWN_TEXT_LEADING_OPTIONS,
14
+ COUNTDOWN_TEXT_SIZE_OPTIONS,
15
+ COUNTDOWN_TEXT_TRACKING_OPTIONS,
16
+ COUNTDOWN_TEXT_TRANSFORM_OPTIONS,
17
+ COUNTDOWN_TEXT_WEIGHT_OPTIONS,
18
+ type CountdownTextSlot,
19
+ type CountdownTextStyle,
20
+ } from "../../blocks/countdown-typography";
21
+ import {
22
+ COUNTDOWN_DEFAULT_TIMEZONE,
23
+ COUNTDOWN_TIMEZONES,
24
+ countdownParts,
25
+ formatInZone,
26
+ resolveCountdownTarget,
27
+ } from "../../blocks/countdown-time";
9
28
  import type {
10
29
  BlockInstance,
11
30
  BlockProps,
@@ -37,6 +56,7 @@ import type {
37
56
  PriceTableProps,
38
57
  PriceGroup,
39
58
  IntroImageAspect,
59
+ CountdownProps,
40
60
  } from "../../blocks/types";
41
61
  import { INTRO_IMAGE_ASPECTS } from "../../blocks/types";
42
62
  import { BLOCK_ICON_NAMES, resolveBlockIcon } from "../../blocks/icons";
@@ -2391,3 +2411,351 @@ export {
2391
2411
  Toggle as InspectorToggle,
2392
2412
  uid as inspectorUid,
2393
2413
  };
2414
+
2415
+ /* ---------------- Countdown ---------------- */
2416
+ export function CountdownInspector({ block, patch }: BlockPatchProps) {
2417
+ const p = (block.props ?? {}) as CountdownProps;
2418
+ const update = (next: CountdownProps) => patch(next as unknown as BlockProps);
2419
+
2420
+ const timezone = p.timezone?.trim() || COUNTDOWN_DEFAULT_TIMEZONE;
2421
+ const target = resolveCountdownTarget(p.targetLocal, timezone);
2422
+
2423
+ // Live clock so the editor always shows the real current time next to the
2424
+ // deadline and the remaining time.
2425
+ const [now, setNow] = useState(() => Date.now());
2426
+ useEffect(() => {
2427
+ const id = setInterval(() => setNow(Date.now()), 1000);
2428
+ return () => clearInterval(id);
2429
+ }, []);
2430
+ const parts = countdownParts(target, now);
2431
+
2432
+ const colorRow = (label: string, key: keyof CountdownColors) => {
2433
+ const current = (p.colors?.[key] ?? "").trim();
2434
+ const swatch = current.startsWith("#") ? current : "#ffffff";
2435
+ return (
2436
+ <label key={key} className="block space-y-1.5">
2437
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
2438
+ <span className="flex items-center gap-2">
2439
+ <input
2440
+ type="color"
2441
+ value={swatch}
2442
+ onChange={(e) => update({ ...p, colors: { ...p.colors, [key]: e.target.value } })}
2443
+ className="h-9 w-12 shrink-0 cursor-pointer border border-ink/15 bg-white p-0.5"
2444
+ aria-label={`${label} colour picker`}
2445
+ />
2446
+ <input
2447
+ type="text"
2448
+ value={current}
2449
+ placeholder={COUNTDOWN_COLOR_DEFAULTS[key]}
2450
+ onChange={(e) => update({ ...p, colors: { ...p.colors, [key]: e.target.value } })}
2451
+ className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
2452
+ />
2453
+ </span>
2454
+ </label>
2455
+ );
2456
+ };
2457
+
2458
+ const select = <T extends string>(
2459
+ label: string,
2460
+ value: T | undefined,
2461
+ fallback: T,
2462
+ options: { value: T; label: string }[],
2463
+ onPick: (v: T) => void,
2464
+ ) => (
2465
+ <label className="block space-y-1.5">
2466
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
2467
+ <select
2468
+ value={value ?? fallback}
2469
+ onChange={(e) => onPick(e.target.value as T)}
2470
+ className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
2471
+ >
2472
+ {options.map((o) => (
2473
+ <option key={o.value} value={o.value}>
2474
+ {o.label}
2475
+ </option>
2476
+ ))}
2477
+ </select>
2478
+ </label>
2479
+ );
2480
+
2481
+ const textStyle = (
2482
+ title: string,
2483
+ slot: CountdownTextSlot,
2484
+ key: "titleType" | "bodyType" | "noteType",
2485
+ ) => {
2486
+ const current = (p[key] ?? {}) as CountdownTextStyle;
2487
+ const d = COUNTDOWN_TEXT_DEFAULTS[slot];
2488
+ const set = (patchStyle: CountdownTextStyle) =>
2489
+ update({ ...p, [key]: { ...current, ...patchStyle } });
2490
+ return (
2491
+ <div key={key} className="space-y-2 border border-ink/10 bg-paper/60 p-3">
2492
+ <p className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/70">{title}</p>
2493
+ <div className="grid grid-cols-2 gap-2">
2494
+ {select("Size", current.size, d.size, COUNTDOWN_TEXT_SIZE_OPTIONS, (v) => set({ size: v }))}
2495
+ {select("Weight", current.weight, d.weight, COUNTDOWN_TEXT_WEIGHT_OPTIONS, (v) =>
2496
+ set({ weight: v }),
2497
+ )}
2498
+ {select("Style", current.family, d.family, COUNTDOWN_TEXT_FAMILY_OPTIONS, (v) =>
2499
+ set({ family: v }),
2500
+ )}
2501
+ {select("Letter spacing", current.tracking, d.tracking, COUNTDOWN_TEXT_TRACKING_OPTIONS, (v) =>
2502
+ set({ tracking: v }),
2503
+ )}
2504
+ {select("Capitals", current.transform, d.transform, COUNTDOWN_TEXT_TRANSFORM_OPTIONS, (v) =>
2505
+ set({ transform: v }),
2506
+ )}
2507
+ {select("Line height", current.leading, d.leading, COUNTDOWN_TEXT_LEADING_OPTIONS, (v) =>
2508
+ set({ leading: v }),
2509
+ )}
2510
+ </div>
2511
+ <button
2512
+ type="button"
2513
+ onClick={() => update({ ...p, [key]: {} })}
2514
+ className="text-xs uppercase tracking-[0.18em] text-ink/60 underline hover:text-ink"
2515
+ >
2516
+ Reset to default
2517
+ </button>
2518
+ </div>
2519
+ );
2520
+ };
2521
+
2522
+ const toggle = (label: string, value: boolean, onPick: (v: boolean) => void) => (
2523
+ <label className="flex items-center gap-2 text-sm text-ink">
2524
+ <input type="checkbox" checked={value} onChange={(e) => onPick(e.target.checked)} />
2525
+ {label}
2526
+ </label>
2527
+ );
2528
+
2529
+ return (
2530
+ <>
2531
+ <InspectorSection title="Text above the timer">
2532
+ <LocalizedField label="Heading" multiline value={p.title} onChange={(v) => update({ ...p, title: v })} />
2533
+ <LocalizedField label="Sub text" multiline value={p.body} onChange={(v) => update({ ...p, body: v })} />
2534
+ <LocalizedField label="Fine print (under the timer)" value={p.note} onChange={(v) => update({ ...p, note: v })} />
2535
+ </InspectorSection>
2536
+
2537
+ <InspectorSection title="Typography">
2538
+ <p className="text-xs leading-relaxed text-ink/60">
2539
+ Size and weight for the three texts above and under the timer.
2540
+ </p>
2541
+ {textStyle("Heading", "heading", "titleType")}
2542
+ {textStyle("Sub text", "sub", "bodyType")}
2543
+ {textStyle("Fine print", "note", "noteType")}
2544
+ </InspectorSection>
2545
+
2546
+ <InspectorSection title="Deadline">
2547
+ <label className="block space-y-1.5">
2548
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
2549
+ Counts down to (date and time)
2550
+ </span>
2551
+ <input
2552
+ type="datetime-local"
2553
+ value={p.targetLocal ?? ""}
2554
+ onChange={(e) => update({ ...p, targetLocal: e.target.value })}
2555
+ className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
2556
+ />
2557
+ </label>
2558
+ <label className="block space-y-1.5">
2559
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Timezone</span>
2560
+ <select
2561
+ value={timezone}
2562
+ onChange={(e) => update({ ...p, timezone: e.target.value })}
2563
+ className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
2564
+ >
2565
+ {COUNTDOWN_TIMEZONES.map((tz) => (
2566
+ <option key={tz} value={tz}>
2567
+ {tz}
2568
+ </option>
2569
+ ))}
2570
+ </select>
2571
+ </label>
2572
+ <div className="space-y-1 border border-ink/10 bg-paper/60 px-3 py-2.5 text-xs leading-relaxed text-ink/70">
2573
+ <p>Time right now: {formatInZone(now, timezone)}</p>
2574
+ <p>
2575
+ Deadline:{" "}
2576
+ {target == null ? "not set yet" : formatInZone(target, timezone)}
2577
+ </p>
2578
+ <p>
2579
+ Time left:{" "}
2580
+ {target == null
2581
+ ? "—"
2582
+ : parts.expired
2583
+ ? "the timer has already reached zero"
2584
+ : `${parts.days}d ${parts.hours}h ${parts.minutes}m ${parts.seconds}s`}
2585
+ </p>
2586
+ </div>
2587
+ </InspectorSection>
2588
+
2589
+ <InspectorSection title="Unit labels">
2590
+ <LocalizedField label="Days" value={p.labelDays} onChange={(v) => update({ ...p, labelDays: v })} />
2591
+ <LocalizedField label="Hours" value={p.labelHours} onChange={(v) => update({ ...p, labelHours: v })} />
2592
+ <LocalizedField label="Minutes" value={p.labelMinutes} onChange={(v) => update({ ...p, labelMinutes: v })} />
2593
+ <LocalizedField label="Seconds" value={p.labelSeconds} onChange={(v) => update({ ...p, labelSeconds: v })} />
2594
+ {toggle("Show days", p.showDays ?? true, (v) => update({ ...p, showDays: v }))}
2595
+ {toggle("Hide days when less than a day is left", p.hideDaysWhenZero !== false, (v) =>
2596
+ update({ ...p, hideDaysWhenZero: v }),
2597
+ )}
2598
+ {toggle("Show seconds", p.showSeconds ?? true, (v) => update({ ...p, showSeconds: v }))}
2599
+ </InspectorSection>
2600
+
2601
+ <InspectorSection title="Colours">
2602
+ <p className="text-xs leading-relaxed text-ink/60">
2603
+ Leave a field empty to use the site theme. Accepts hex values such as #ff4f9a.
2604
+ </p>
2605
+ {colorRow("Section background", "sectionBg")}
2606
+ {colorRow("Heading", "heading")}
2607
+ {colorRow("Sub text", "subText")}
2608
+ {colorRow("Number tile background", "tileBg")}
2609
+ {colorRow("Numbers", "digit")}
2610
+ {colorRow("Unit labels", "unit")}
2611
+ {colorRow("Tile border", "border")}
2612
+ <button
2613
+ type="button"
2614
+ onClick={() => update({ ...p, colors: {} })}
2615
+ className="text-xs uppercase tracking-[0.18em] text-ink/60 underline hover:text-ink"
2616
+ >
2617
+ Reset colours to theme defaults
2618
+ </button>
2619
+ </InspectorSection>
2620
+
2621
+ <InspectorSection title="Layout">
2622
+ {select(
2623
+ "Corner rounding",
2624
+ p.rounding,
2625
+ "soft",
2626
+ [
2627
+ { value: "square", label: "Square" },
2628
+ { value: "soft", label: "Soft" },
2629
+ { value: "rounded", label: "Rounded" },
2630
+ { value: "pill", label: "Pill" },
2631
+ { value: "custom", label: "Custom value" },
2632
+ ],
2633
+ (v) => update({ ...p, rounding: v as CountdownProps["rounding"] }),
2634
+ )}
2635
+ {p.rounding === "custom" ? (
2636
+ <TextField
2637
+ label="Custom radius (e.g. 12px or 1.5rem)"
2638
+ value={p.radius ?? ""}
2639
+ onChange={(v) => update({ ...p, radius: v })}
2640
+ />
2641
+ ) : null}
2642
+ {select(
2643
+ "Tile size",
2644
+ p.tileSize,
2645
+ "md",
2646
+ [
2647
+ { value: "sm", label: "Small" },
2648
+ { value: "md", label: "Medium" },
2649
+ { value: "lg", label: "Large" },
2650
+ ],
2651
+ (v) => update({ ...p, tileSize: v as CountdownProps["tileSize"] }),
2652
+ )}
2653
+ {select(
2654
+ "Space between tiles",
2655
+ p.gap,
2656
+ "md",
2657
+ [
2658
+ { value: "sm", label: "Tight" },
2659
+ { value: "md", label: "Normal" },
2660
+ { value: "lg", label: "Wide" },
2661
+ ],
2662
+ (v) => update({ ...p, gap: v as CountdownProps["gap"] }),
2663
+ )}
2664
+ {select(
2665
+ "Section spacing",
2666
+ p.padding,
2667
+ "normal",
2668
+ [
2669
+ { value: "compact", label: "Compact" },
2670
+ { value: "normal", label: "Normal" },
2671
+ { value: "roomy", label: "Roomy" },
2672
+ ],
2673
+ (v) => update({ ...p, padding: v as CountdownProps["padding"] }),
2674
+ )}
2675
+ {select(
2676
+ "Alignment",
2677
+ p.align,
2678
+ "center",
2679
+ [
2680
+ { value: "left", label: "Left" },
2681
+ { value: "center", label: "Centered" },
2682
+ { value: "right", label: "Right" },
2683
+ ],
2684
+ (v) => update({ ...p, align: v as CountdownProps["align"] }),
2685
+ )}
2686
+ {select(
2687
+ "Separator between tiles",
2688
+ p.separator,
2689
+ "none",
2690
+ [
2691
+ { value: "none", label: "None" },
2692
+ { value: "colon", label: "Colon" },
2693
+ ],
2694
+ (v) => update({ ...p, separator: v as CountdownProps["separator"] }),
2695
+ )}
2696
+ {toggle("Border around tiles", Boolean(p.showBorder), (v) => update({ ...p, showBorder: v }))}
2697
+ {toggle("Shadow under tiles", Boolean(p.showShadow), (v) => update({ ...p, showShadow: v }))}
2698
+ <TextField label="Anchor id" value={p.anchor ?? ""} onChange={(v) => update({ ...p, anchor: v })} />
2699
+ </InspectorSection>
2700
+
2701
+ <InspectorSection title="When the timer reaches zero">
2702
+ {select(
2703
+ "Then",
2704
+ p.onExpire,
2705
+ "hide",
2706
+ [
2707
+ { value: "hide", label: "Stop showing this block" },
2708
+ { value: "replace", label: "Show another (global) block instead" },
2709
+ { value: "revealPage", label: "Publish a hidden page at the deadline" },
2710
+ ],
2711
+ (v) => update({ ...p, onExpire: v as CountdownProps["onExpire"] }),
2712
+ )}
2713
+ {p.onExpire === "replace" ? (
2714
+ <TextField
2715
+ label="Global block key to show instead"
2716
+ value={p.expiredSectionKey ?? ""}
2717
+ onChange={(v) => update({ ...p, expiredSectionKey: v })}
2718
+ />
2719
+ ) : null}
2720
+ {p.onExpire === "revealPage" ? (
2721
+ <>
2722
+ <TextField
2723
+ label="Page key to publish at the deadline"
2724
+ value={p.revealPageKey ?? ""}
2725
+ onChange={(v) => update({ ...p, revealPageKey: v })}
2726
+ />
2727
+ <p className="text-xs leading-relaxed text-ink/60">
2728
+ The page is scheduled to go live at the deadline the next time you publish this page.
2729
+ Nothing is deleted or unpublished.
2730
+ </p>
2731
+ </>
2732
+ ) : null}
2733
+ {p.onExpire !== "hide" ? (
2734
+ <>
2735
+ <LocalizedField
2736
+ label="Message heading after the deadline"
2737
+ value={p.expiredTitle}
2738
+ onChange={(v) => update({ ...p, expiredTitle: v })}
2739
+ />
2740
+ <LocalizedField
2741
+ label="Message text after the deadline"
2742
+ multiline
2743
+ value={p.expiredBody}
2744
+ onChange={(v) => update({ ...p, expiredBody: v })}
2745
+ />
2746
+ <LocalizedField
2747
+ label="Button label"
2748
+ value={p.expiredCtaLabel}
2749
+ onChange={(v) => update({ ...p, expiredCtaLabel: v })}
2750
+ />
2751
+ <TextField
2752
+ label="Button link"
2753
+ value={p.expiredCtaHref ?? ""}
2754
+ onChange={(v) => update({ ...p, expiredCtaHref: v })}
2755
+ />
2756
+ </>
2757
+ ) : null}
2758
+ </InspectorSection>
2759
+ </>
2760
+ );
2761
+ }
@@ -40,6 +40,7 @@ import {
40
40
  IconGridInspector,
41
41
  ChecklistInspector,
42
42
  CtaBannerInspector,
43
+ CountdownInspector,
43
44
  MediaSplitInspector,
44
45
  type BlockPatchProps,
45
46
  } from "./blocks";
@@ -748,6 +749,8 @@ export function BlockInspector({
748
749
  case "tripList":
749
750
  case "courseList":
750
751
  return <TripListInspector block={block} patch={patch} />;
752
+ case "countdown":
753
+ return <CountdownInspector block={block} patch={patch} />;
751
754
  case "ctaBanner":
752
755
  return <CtaBannerInspector block={block} patch={patch} />;
753
756
  case "mediaSplit":
@@ -0,0 +1,193 @@
1
+ import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
2
+
3
+ import { useT } from "../../i18n";
4
+ import type { LocalizedString } from "../../i18n";
5
+ import { Reveal } from "../../ui/Reveal";
6
+ import { countdownColorStyle, countdownRadius } from "../countdown-colors";
7
+ import { countdownParts, resolveCountdownTarget } from "../countdown-time";
8
+ import { countdownTextClasses } from "../countdown-typography";
9
+ import { useCountdownReplacement } from "../countdown-replacement";
10
+ import type { CountdownProps } from "../types";
11
+
12
+ const TILE_SIZE = {
13
+ sm: { box: "min-w-14 px-3 py-2.5", digit: "text-2xl md:text-3xl", unit: "text-[0.55rem]" },
14
+ md: { box: "min-w-20 px-4 py-3.5", digit: "text-3xl md:text-5xl", unit: "text-[0.6rem]" },
15
+ lg: { box: "min-w-28 px-6 py-5", digit: "text-4xl md:text-6xl", unit: "text-[0.68rem]" },
16
+ } as const;
17
+
18
+ const GAP = { sm: "gap-2", md: "gap-3 md:gap-4", lg: "gap-5 md:gap-7" } as const;
19
+ const PADDING = {
20
+ compact: "py-8 md:py-10",
21
+ normal: "py-14 md:py-20",
22
+ roomy: "py-20 md:py-32",
23
+ } as const;
24
+ const ALIGN = {
25
+ left: { wrap: "items-start text-left", row: "justify-start" },
26
+ center: { wrap: "items-center text-center", row: "justify-center" },
27
+ right: { wrap: "items-end text-right", row: "justify-end" },
28
+ } as const;
29
+
30
+ const pad = (n: number) => String(n).padStart(2, "0");
31
+
32
+ /**
33
+ * Localized, timezone-aware countdown with fully configurable colours, corner
34
+ * rounding and expiry behaviour.
35
+ */
36
+ export function CountdownBlock({ props: p }: { props: CountdownProps }) {
37
+ const { t } = useT();
38
+ const replacement = useCountdownReplacement();
39
+
40
+ const target = useMemo(
41
+ () => resolveCountdownTarget(p.targetLocal, p.timezone),
42
+ [p.targetLocal, p.timezone],
43
+ );
44
+
45
+ const [now, setNow] = useState(() => Date.now());
46
+ useEffect(() => {
47
+ if (target == null) return;
48
+ let timer: ReturnType<typeof setInterval> | undefined;
49
+ const tick = () => setNow(Date.now());
50
+ const start = () => {
51
+ stop();
52
+ tick();
53
+ timer = setInterval(tick, 1000);
54
+ };
55
+ const stop = () => {
56
+ if (timer) clearInterval(timer);
57
+ timer = undefined;
58
+ };
59
+ const onVisibility = () => (document.hidden ? stop() : start());
60
+ start();
61
+ document.addEventListener("visibilitychange", onVisibility);
62
+ return () => {
63
+ stop();
64
+ document.removeEventListener("visibilitychange", onVisibility);
65
+ };
66
+ }, [target]);
67
+
68
+ const parts = countdownParts(target, now);
69
+ const expired = parts.expired;
70
+ const onExpire = p.onExpire ?? "hide";
71
+
72
+ const size = TILE_SIZE[p.tileSize ?? "md"];
73
+ const align = ALIGN[p.align ?? "center"];
74
+ const radius = countdownRadius(p.rounding, p.radius);
75
+ const style = {
76
+ ...countdownColorStyle(p.colors),
77
+ ["--cd-radius" as string]: radius,
78
+ } as CSSProperties;
79
+
80
+ const section = (children: ReactNode) => (
81
+ <section
82
+ id={p.anchor || undefined}
83
+ style={style}
84
+ className={`bg-[var(--cd-section-bg)] ${PADDING[p.padding ?? "normal"]}`}
85
+ >
86
+ <div className="container-editorial">
87
+ <Reveal>
88
+ <div className={`flex flex-col ${align.wrap}`}>{children}</div>
89
+ </Reveal>
90
+ </div>
91
+ </section>
92
+ );
93
+
94
+ const heading = (title?: Partial<LocalizedString>, body?: Partial<LocalizedString>) => (
95
+ <>
96
+ {t(title) ? (
97
+ <h2
98
+ className={`${countdownTextClasses("heading", p.titleType)} whitespace-pre-line text-[var(--cd-heading)]`}
99
+ >
100
+ {t(title)}
101
+ </h2>
102
+ ) : null}
103
+ {t(body) ? (
104
+ <p
105
+ className={`mt-4 max-w-2xl ${countdownTextClasses("sub", p.bodyType)} whitespace-pre-line text-[var(--cd-sub)]`}
106
+ >
107
+ {t(body)}
108
+ </p>
109
+ ) : null}
110
+ </>
111
+ );
112
+
113
+ /* ---------- Expired ---------- */
114
+ if (expired) {
115
+ if (onExpire === "hide") return null;
116
+ if (onExpire === "replace" && replacement) return <>{replacement}</>;
117
+ // "replace" without a global block, and "revealPage", both show the
118
+ // configured expired message (plus an optional link to the revealed page).
119
+ const href = p.expiredCtaHref?.trim() || (p.revealPageKey?.trim() ? `/${p.revealPageKey.trim()}` : "");
120
+ const label = t(p.expiredCtaLabel);
121
+ return section(
122
+ <>
123
+ {heading(p.expiredTitle, p.expiredBody)}
124
+ {href && label ? (
125
+ <a
126
+ href={href}
127
+ 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"
128
+ >
129
+ {label}
130
+ </a>
131
+ ) : null}
132
+ </>,
133
+ );
134
+ }
135
+
136
+ /* ---------- Running ---------- */
137
+ const showDays = (p.showDays ?? true) && !(p.hideDaysWhenZero !== false && parts.days === 0);
138
+ const units: { value: string; label?: Partial<LocalizedString> }[] = [
139
+ ...(showDays ? [{ value: pad(parts.days), label: p.labelDays }] : []),
140
+ { value: pad(parts.hours), label: p.labelHours },
141
+ { value: pad(parts.minutes), label: p.labelMinutes },
142
+ ...((p.showSeconds ?? true) ? [{ value: pad(parts.seconds), label: p.labelSeconds }] : []),
143
+ ];
144
+
145
+ const border = p.showBorder ? "border border-[var(--cd-border)]" : "";
146
+ const shadow = p.showShadow ? "shadow-lg shadow-black/10" : "";
147
+
148
+ return section(
149
+ <>
150
+ {heading(p.title, p.body)}
151
+ <div
152
+ className={`mt-8 flex flex-wrap items-stretch ${GAP[p.gap ?? "md"]} ${align.row}`}
153
+ aria-live="off"
154
+ >
155
+ {units.map((u, i) => (
156
+ <div key={i} className="flex items-stretch">
157
+ <div
158
+ className={`flex flex-col items-center justify-center bg-[var(--cd-tile-bg)] ${size.box} ${border} ${shadow}`}
159
+ style={{ borderRadius: "var(--cd-radius)" }}
160
+ >
161
+ <span
162
+ suppressHydrationWarning
163
+ className={`font-semibold tabular-nums leading-none text-[var(--cd-digit)] ${size.digit}`}
164
+ >
165
+ {u.value}
166
+ </span>
167
+ {t(u.label) ? (
168
+ <span
169
+ className={`mt-1.5 uppercase tracking-[0.18em] text-[var(--cd-unit)] ${size.unit}`}
170
+ >
171
+ {t(u.label)}
172
+ </span>
173
+ ) : null}
174
+ </div>
175
+ {p.separator === "colon" && i < units.length - 1 ? (
176
+ <span
177
+ className={`self-center px-1 font-semibold text-[var(--cd-heading)] ${size.digit}`}
178
+ aria-hidden="true"
179
+ >
180
+ :
181
+ </span>
182
+ ) : null}
183
+ </div>
184
+ ))}
185
+ </div>
186
+ {t(p.note) ? (
187
+ <p className={`mt-5 ${countdownTextClasses("note", p.noteType)} text-[var(--cd-sub)]`}>
188
+ {t(p.note)}
189
+ </p>
190
+ ) : null}
191
+ </>,
192
+ );
193
+ }
@@ -0,0 +1,71 @@
1
+ import type { CSSProperties } from "react";
2
+
3
+ /**
4
+ * Editable colours for the countdown block. Every value is a plain CSS colour
5
+ * string (hex, rgb(), oklch(), var(--token)). Empty/undefined values fall back
6
+ * to the site theme tokens below, so a fresh site looks right with no setup.
7
+ */
8
+ export type CountdownColors = {
9
+ /** Section background. */
10
+ sectionBg?: string;
11
+ /** Heading colour. */
12
+ heading?: string;
13
+ /** Sub-text (and fine print) colour. */
14
+ subText?: string;
15
+ /** Digit tile background. */
16
+ tileBg?: string;
17
+ /** Digit colour. */
18
+ digit?: string;
19
+ /** Unit label colour (DAYS/HOURS…). */
20
+ unit?: string;
21
+ /** Tile border colour (only visible when the border is switched on). */
22
+ border?: string;
23
+ };
24
+
25
+ export const COUNTDOWN_COLOR_DEFAULTS: Required<CountdownColors> = {
26
+ sectionBg: "transparent",
27
+ heading: "var(--sea)",
28
+ subText: "var(--ink)",
29
+ tileBg: "var(--sea)",
30
+ digit: "#ffffff",
31
+ unit: "#ffffff",
32
+ border: "var(--ink)",
33
+ };
34
+
35
+ /** Inline CSS variables for the countdown colours, with theme defaults applied. */
36
+ export function countdownColorStyle(colors?: CountdownColors): CSSProperties {
37
+ const pick = (key: keyof CountdownColors) =>
38
+ colors?.[key]?.trim() || COUNTDOWN_COLOR_DEFAULTS[key];
39
+ return {
40
+ ["--cd-section-bg" as string]: pick("sectionBg"),
41
+ ["--cd-heading" as string]: pick("heading"),
42
+ ["--cd-sub" as string]: pick("subText"),
43
+ ["--cd-tile-bg" as string]: pick("tileBg"),
44
+ ["--cd-digit" as string]: pick("digit"),
45
+ ["--cd-unit" as string]: pick("unit"),
46
+ ["--cd-border" as string]: pick("border"),
47
+ } as CSSProperties;
48
+ }
49
+
50
+ /** Corner rounding presets; `custom` uses the block's `radius` value. */
51
+ export const COUNTDOWN_ROUNDING = ["square", "soft", "rounded", "pill", "custom"] as const;
52
+ export type CountdownRounding = (typeof COUNTDOWN_ROUNDING)[number];
53
+
54
+ export function countdownRadius(
55
+ rounding: CountdownRounding | undefined,
56
+ custom: string | undefined,
57
+ ): string {
58
+ switch (rounding ?? "soft") {
59
+ case "square":
60
+ return "0px";
61
+ case "rounded":
62
+ return "1rem";
63
+ case "pill":
64
+ return "9999px";
65
+ case "custom":
66
+ return custom?.trim() || "0.5rem";
67
+ case "soft":
68
+ default:
69
+ return "0.5rem";
70
+ }
71
+ }
@@ -0,0 +1,24 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+
3
+ /**
4
+ * Replacement content for an expired countdown.
5
+ *
6
+ * The page renderer resolves the global block chosen in the inspector and puts
7
+ * the rendered nodes here, so the countdown block itself stays free of any
8
+ * data-fetching.
9
+ */
10
+ const Ctx = createContext<ReactNode>(null);
11
+
12
+ export function CountdownReplacementProvider({
13
+ value,
14
+ children,
15
+ }: {
16
+ value: ReactNode;
17
+ children: ReactNode;
18
+ }) {
19
+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
20
+ }
21
+
22
+ export function useCountdownReplacement(): ReactNode {
23
+ return useContext(Ctx);
24
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Timezone-aware countdown maths.
3
+ *
4
+ * The editor stores a wall-clock date/time (`2026-09-07T16:00`) plus an IANA
5
+ * timezone (`Europe/Stockholm`). That pair is resolved to one absolute instant,
6
+ * so every visitor — wherever they are — counts down to the same moment.
7
+ * Only `Intl` is used, no extra dependency.
8
+ */
9
+
10
+ /** 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;
29
+
30
+ export const COUNTDOWN_DEFAULT_TIMEZONE = "Europe/Stockholm";
31
+
32
+ function safeZone(tz?: string): string {
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
+ }
66
+
67
+ /**
68
+ * Resolve `YYYY-MM-DDTHH:mm` in `tz` to an absolute timestamp (ms).
69
+ * Returns `null` when the value is empty or unparsable.
70
+ */
71
+ export function resolveCountdownTarget(local?: string, tz?: string): number | null {
72
+ const raw = local?.trim();
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;
90
+ }
91
+
92
+ export type CountdownParts = {
93
+ total: number;
94
+ days: number;
95
+ hours: number;
96
+ minutes: number;
97
+ seconds: number;
98
+ expired: boolean;
99
+ };
100
+
101
+ /** Remaining time between `now` and `target`, clamped at zero. */
102
+ export function countdownParts(target: number | null, now: number): CountdownParts {
103
+ const total = target == null ? 0 : Math.max(0, target - now);
104
+ const secs = Math.floor(total / 1000);
105
+ return {
106
+ total,
107
+ days: Math.floor(secs / 86400),
108
+ hours: Math.floor((secs % 86400) / 3600),
109
+ minutes: Math.floor((secs % 3600) / 60),
110
+ seconds: secs % 60,
111
+ expired: target != null && total <= 0,
112
+ };
113
+ }
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
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Typography options for the Countdown block's texts (heading, sub text and
3
+ * fine print). Every value is optional; leaving a field unset keeps the
4
+ * original theme-driven look, so saved pages never change appearance.
5
+ */
6
+
7
+ export type CountdownTextSize = "sm" | "md" | "lg" | "xl";
8
+ export type CountdownTextWeight = "normal" | "medium" | "semibold" | "bold" | "black";
9
+ export type CountdownTextFamily = "serif" | "sans";
10
+ export type CountdownTextTracking = "tight" | "normal" | "wide" | "wider";
11
+ export type CountdownTextTransform = "none" | "uppercase" | "lowercase";
12
+ export type CountdownTextLeading = "tight" | "normal" | "relaxed";
13
+
14
+ export type CountdownTextStyle = {
15
+ size?: CountdownTextSize;
16
+ weight?: CountdownTextWeight;
17
+ family?: CountdownTextFamily;
18
+ tracking?: CountdownTextTracking;
19
+ transform?: CountdownTextTransform;
20
+ leading?: CountdownTextLeading;
21
+ };
22
+
23
+ export type CountdownTextSlot = "heading" | "sub" | "note";
24
+
25
+ /** Responsive sizes per slot; `md` reproduces the original hardcoded size. */
26
+ const SIZE: Record<CountdownTextSlot, Record<CountdownTextSize, string>> = {
27
+ heading: {
28
+ sm: "text-[clamp(1.2rem,2.4vw,1.75rem)]",
29
+ md: "text-[clamp(1.6rem,3.6vw,2.9rem)]",
30
+ lg: "text-[clamp(2rem,5vw,4rem)]",
31
+ xl: "text-[clamp(2.4rem,6.5vw,5.5rem)]",
32
+ },
33
+ sub: {
34
+ sm: "text-sm md:text-base",
35
+ md: "text-base md:text-lg",
36
+ lg: "text-lg md:text-2xl",
37
+ xl: "text-xl md:text-3xl",
38
+ },
39
+ note: {
40
+ sm: "text-[0.7rem]",
41
+ md: "text-xs",
42
+ lg: "text-sm md:text-base",
43
+ xl: "text-base md:text-lg",
44
+ },
45
+ };
46
+
47
+ const WEIGHT: Record<CountdownTextWeight, string> = {
48
+ normal: "font-normal",
49
+ medium: "font-medium",
50
+ semibold: "font-semibold",
51
+ bold: "font-bold",
52
+ black: "font-black",
53
+ };
54
+
55
+ const FAMILY: Record<CountdownTextFamily, string> = {
56
+ serif: "font-serif",
57
+ sans: "font-sans",
58
+ };
59
+
60
+ const TRACKING: Record<CountdownTextTracking, string> = {
61
+ tight: "tracking-[-0.02em]",
62
+ normal: "tracking-normal",
63
+ wide: "tracking-[0.08em]",
64
+ wider: "tracking-[0.22em]",
65
+ };
66
+
67
+ const TRANSFORM: Record<CountdownTextTransform, string> = {
68
+ none: "normal-case",
69
+ uppercase: "uppercase",
70
+ lowercase: "lowercase",
71
+ };
72
+
73
+ const LEADING: Record<CountdownTextLeading, string> = {
74
+ tight: "leading-[1.1]",
75
+ normal: "leading-snug",
76
+ relaxed: "leading-relaxed",
77
+ };
78
+
79
+ /** Defaults matching the block's original appearance per slot. */
80
+ export const COUNTDOWN_TEXT_DEFAULTS: Record<CountdownTextSlot, Required<CountdownTextStyle>> = {
81
+ heading: {
82
+ size: "md",
83
+ weight: "normal",
84
+ family: "serif",
85
+ tracking: "tight",
86
+ transform: "none",
87
+ leading: "tight",
88
+ },
89
+ sub: {
90
+ size: "md",
91
+ weight: "normal",
92
+ family: "sans",
93
+ tracking: "normal",
94
+ transform: "none",
95
+ leading: "relaxed",
96
+ },
97
+ note: {
98
+ size: "md",
99
+ weight: "normal",
100
+ family: "sans",
101
+ tracking: "wider",
102
+ transform: "uppercase",
103
+ leading: "normal",
104
+ },
105
+ };
106
+
107
+ /** Tailwind classes for one countdown text, falling back to the slot default. */
108
+ export function countdownTextClasses(slot: CountdownTextSlot, style?: CountdownTextStyle): string {
109
+ const d = COUNTDOWN_TEXT_DEFAULTS[slot];
110
+ const s = style ?? {};
111
+ return [
112
+ FAMILY[s.family ?? d.family],
113
+ SIZE[slot][s.size ?? d.size],
114
+ WEIGHT[s.weight ?? d.weight],
115
+ TRACKING[s.tracking ?? d.tracking],
116
+ TRANSFORM[s.transform ?? d.transform],
117
+ LEADING[s.leading ?? d.leading],
118
+ ].join(" ");
119
+ }
120
+
121
+ export const COUNTDOWN_TEXT_SIZE_OPTIONS: { value: CountdownTextSize; label: string }[] = [
122
+ { value: "sm", label: "Small" },
123
+ { value: "md", label: "Medium" },
124
+ { value: "lg", label: "Large" },
125
+ { value: "xl", label: "Extra large" },
126
+ ];
127
+
128
+ export const COUNTDOWN_TEXT_WEIGHT_OPTIONS: { value: CountdownTextWeight; label: string }[] = [
129
+ { value: "normal", label: "Normal" },
130
+ { value: "medium", label: "Medium" },
131
+ { value: "semibold", label: "Semibold" },
132
+ { value: "bold", label: "Bold" },
133
+ { value: "black", label: "Black" },
134
+ ];
135
+
136
+ export const COUNTDOWN_TEXT_FAMILY_OPTIONS: { value: CountdownTextFamily; label: string }[] = [
137
+ { value: "serif", label: "Serif (display)" },
138
+ { value: "sans", label: "Sans (body)" },
139
+ ];
140
+
141
+ export const COUNTDOWN_TEXT_TRACKING_OPTIONS: { value: CountdownTextTracking; label: string }[] = [
142
+ { value: "tight", label: "Tight" },
143
+ { value: "normal", label: "Normal" },
144
+ { value: "wide", label: "Wide" },
145
+ { value: "wider", label: "Extra wide" },
146
+ ];
147
+
148
+ export const COUNTDOWN_TEXT_TRANSFORM_OPTIONS: { value: CountdownTextTransform; label: string }[] = [
149
+ { value: "none", label: "As typed" },
150
+ { value: "uppercase", label: "UPPERCASE" },
151
+ { value: "lowercase", label: "lowercase" },
152
+ ];
153
+
154
+ export const COUNTDOWN_TEXT_LEADING_OPTIONS: { value: CountdownTextLeading; label: string }[] = [
155
+ { value: "tight", label: "Tight" },
156
+ { value: "normal", label: "Normal" },
157
+ { value: "relaxed", label: "Relaxed" },
158
+ ];
@@ -1,6 +1,7 @@
1
1
  export { CabinTabsBlock } from "./components/CabinTabsBlock";
2
2
  export { ChecklistBlock } from "./components/ChecklistBlock";
3
3
  export { CtaBannerBlock } from "./components/CtaBannerBlock";
4
+ export { CountdownBlock } from "./components/CountdownBlock";
4
5
  export { FaqBlock } from "./components/FaqBlock";
5
6
  export { HeroFullBlock } from "./components/HeroFullBlock";
6
7
  export { IconGridBlock } from "./components/IconGridBlock";
@@ -17,3 +18,26 @@ export {
17
18
  buttonColorStyle,
18
19
  type ButtonColors,
19
20
  } from "./button-colors";
21
+ export {
22
+ COUNTDOWN_COLOR_DEFAULTS,
23
+ COUNTDOWN_ROUNDING,
24
+ countdownColorStyle,
25
+ countdownRadius,
26
+ type CountdownColors,
27
+ type CountdownRounding,
28
+ } from "./countdown-colors";
29
+ export {
30
+ COUNTDOWN_DEFAULT_TIMEZONE,
31
+ COUNTDOWN_TIMEZONES,
32
+ countdownParts,
33
+ formatInZone,
34
+ resolveCountdownTarget,
35
+ timezoneOffsetMs,
36
+ type CountdownParts,
37
+ } from "./countdown-time";
38
+ export {
39
+ CountdownReplacementProvider,
40
+ useCountdownReplacement,
41
+ } from "./countdown-replacement";
42
+
43
+ export * from "./countdown-typography";
@@ -6,6 +6,7 @@ import { RichTextBlock } from "./components/RichTextBlock";
6
6
  import { ChecklistBlock } from "./components/ChecklistBlock";
7
7
  import { IconGridBlock } from "./components/IconGridBlock";
8
8
  import { CtaBannerBlock } from "./components/CtaBannerBlock";
9
+ import { CountdownBlock } from "./components/CountdownBlock";
9
10
  import { MediaSplitBlock } from "./components/MediaSplitBlock";
10
11
  import { HeroFullBlock } from "./components/HeroFullBlock";
11
12
  import { ImageMarqueeBlock } from "./components/ImageMarqueeBlock";
@@ -23,6 +24,7 @@ import type {
23
24
  ChecklistProps,
24
25
  IconGridProps,
25
26
  CtaBannerProps,
27
+ CountdownProps,
26
28
  MediaSplitProps,
27
29
  HeroFullProps,
28
30
  ImageMarqueeProps,
@@ -167,6 +169,35 @@ export const SHARED_BLOCK_DEFS: Record<string, BlockDef> = {
167
169
  } satisfies IconGridProps as unknown as BlockProps,
168
170
  hasInlineContent: true,
169
171
  },
172
+ countdown: {
173
+ type: "countdown",
174
+ label: "Countdown",
175
+ description:
176
+ "Timezone-aware countdown with heading, translated unit labels, fully configurable colours and a choice of what happens at zero.",
177
+ category: "content",
178
+ component: ({ props }) => <CountdownBlock props={props as CountdownProps} />,
179
+ defaultProps: {
180
+ title: { sv: "BOKNINGEN \u00d6PPNAR", en: "BOOKING OPENS" },
181
+ body: { sv: "", en: "" },
182
+ targetLocal: "",
183
+ timezone: "Europe/Stockholm",
184
+ labelDays: { sv: "DAGAR", en: "DAYS" },
185
+ labelHours: { sv: "TIMMAR", en: "HOURS" },
186
+ labelMinutes: { sv: "MINUTER", en: "MINUTES" },
187
+ labelSeconds: { sv: "SEKUNDER", en: "SECONDS" },
188
+ showDays: true,
189
+ hideDaysWhenZero: true,
190
+ showSeconds: true,
191
+ rounding: "soft",
192
+ tileSize: "md",
193
+ gap: "md",
194
+ padding: "normal",
195
+ align: "center",
196
+ separator: "none",
197
+ onExpire: "hide",
198
+ } satisfies CountdownProps as unknown as BlockProps,
199
+ hasInlineContent: true,
200
+ },
170
201
  ctaBanner: {
171
202
  type: "ctaBanner",
172
203
  label: "CTA banner",
@@ -1,4 +1,6 @@
1
1
  import type { ButtonColors } from "./button-colors";
2
+ import type { CountdownColors, CountdownRounding } from "./countdown-colors";
3
+ import type { CountdownTextStyle } from "./countdown-typography";
2
4
  import type { LocalizedString } from "../i18n";
3
5
 
4
6
  /**
@@ -448,3 +450,66 @@ export type FormProps = {
448
450
  tone?: "white" | "paper" | "ink";
449
451
  anchor?: string;
450
452
  };
453
+
454
+ /** ---- Countdown (timezone-aware timer with configurable expiry) ---- */
455
+
456
+ /** What happens once the countdown reaches zero. */
457
+ export type CountdownExpiryAction = "hide" | "replace" | "revealPage";
458
+
459
+ export type CountdownProps = {
460
+ /** Heading above the timer. */
461
+ title?: Partial<LocalizedString>;
462
+ /** Sub text under the heading. */
463
+ body?: Partial<LocalizedString>;
464
+ /** Fine print under the timer. */
465
+ note?: Partial<LocalizedString>;
466
+
467
+ /** Typography for the heading, sub text and fine print. */
468
+ titleType?: CountdownTextStyle;
469
+ bodyType?: CountdownTextStyle;
470
+ noteType?: CountdownTextStyle;
471
+
472
+ /** Deadline as wall-clock time, `YYYY-MM-DDTHH:mm`. */
473
+ targetLocal?: string;
474
+ /** IANA timezone the deadline is expressed in, e.g. `Europe/Stockholm`. */
475
+ timezone?: string;
476
+
477
+ /** Unit labels, translated per language. */
478
+ labelDays?: Partial<LocalizedString>;
479
+ labelHours?: Partial<LocalizedString>;
480
+ labelMinutes?: Partial<LocalizedString>;
481
+ labelSeconds?: Partial<LocalizedString>;
482
+ showDays?: boolean;
483
+ /** Drop the days tile once fewer than 24 hours remain (default true). */
484
+ hideDaysWhenZero?: boolean;
485
+ showSeconds?: boolean;
486
+
487
+ /** Colours; empty values fall back to the site theme. */
488
+ colors?: CountdownColors;
489
+
490
+ /** Layout. */
491
+ rounding?: CountdownRounding;
492
+ /** Custom corner radius, used when `rounding` is `custom`. */
493
+ radius?: string;
494
+ tileSize?: "sm" | "md" | "lg";
495
+ gap?: "sm" | "md" | "lg";
496
+ padding?: "compact" | "normal" | "roomy";
497
+ align?: "left" | "center" | "right";
498
+ separator?: "none" | "colon";
499
+ showBorder?: boolean;
500
+ showShadow?: boolean;
501
+
502
+ /** Expiry behaviour. */
503
+ onExpire?: CountdownExpiryAction;
504
+ /** Global block rendered instead of the countdown when it expires. */
505
+ expiredSectionKey?: string;
506
+ /** Fallback message when no global block is chosen. */
507
+ expiredTitle?: Partial<LocalizedString>;
508
+ expiredBody?: Partial<LocalizedString>;
509
+ expiredCtaLabel?: Partial<LocalizedString>;
510
+ expiredCtaHref?: string;
511
+ /** Page key scheduled to go live at the deadline (`revealPage`). */
512
+ revealPageKey?: string;
513
+
514
+ anchor?: string;
515
+ };
@@ -16,6 +16,7 @@ import {
16
16
  invalidatePublishedCache,
17
17
  withPublishedCache,
18
18
  } from "./published-cache";
19
+ import { resolveCountdownTarget } from "../blocks/countdown-time";
19
20
  import { resolveGlobalsDeep } from "./resolve-globals";
20
21
 
21
22
  /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -179,10 +180,75 @@ export async function savePageVersionCore(
179
180
  published_by: context.userId,
180
181
  });
181
182
  await context.supabase.from("pages").update({ status: "published" }).eq("id", input.pageId);
183
+ await scheduleCountdownReveals(context, input.blocks);
182
184
  }
183
185
  return { versionId: inserted.id as string, versionNo: nextNo };
184
186
  }
185
187
 
188
+ /**
189
+ * A countdown set to "reveal a hidden page" schedules that page to publish at
190
+ * the deadline, reusing the normal time-based publication window. Nothing is
191
+ * ever unpublished or deleted here.
192
+ */
193
+ async function scheduleCountdownReveals(
194
+ context: PagesDeps["context"],
195
+ blocks: BlockInstance[],
196
+ ) {
197
+ const reveals = blocks
198
+ .filter((b) => b.type === "countdown")
199
+ .map((b) => b.props as { onExpire?: string; revealPageKey?: string; targetLocal?: string; timezone?: string } | undefined)
200
+ .filter((p): p is { onExpire: string; revealPageKey: string; targetLocal: string; timezone?: string } =>
201
+ Boolean(p && p.onExpire === "revealPage" && p.revealPageKey?.trim() && p.targetLocal?.trim()),
202
+ );
203
+
204
+ for (const reveal of reveals) {
205
+ const at = resolveCountdownTarget(reveal.targetLocal, reveal.timezone);
206
+ if (at == null) continue;
207
+ const publishAt = new Date(at).toISOString();
208
+ const { data: target } = await context.supabase
209
+ .from("pages")
210
+ .select("id, current_version_id")
211
+ .eq("key", reveal.revealPageKey.trim())
212
+ .is("deleted_at", null)
213
+ .maybeSingle();
214
+ if (!target?.current_version_id) continue;
215
+
216
+ const { data: existing } = await context.supabase
217
+ .from("page_publications")
218
+ .select("id")
219
+ .eq("page_id", target.id)
220
+ .eq("version_id", target.current_version_id)
221
+ .eq("publish_at", publishAt)
222
+ .eq("is_active", true)
223
+ .maybeSingle();
224
+ if (existing) continue;
225
+
226
+ await context.supabase
227
+ .from("page_publications")
228
+ .update({ is_active: false })
229
+ .eq("page_id", target.id)
230
+ .eq("is_active", true);
231
+ await context.supabase.from("page_publications").insert({
232
+ page_id: target.id,
233
+ version_id: target.current_version_id,
234
+ is_active: true,
235
+ publish_at: publishAt,
236
+ published_by: context.userId,
237
+ });
238
+ await context.supabase.from("pages").update({ status: "published" }).eq("id", target.id);
239
+ await context.supabase.from("audit_logs").insert({
240
+ actor_id: context.userId,
241
+ action: "page.schedule",
242
+ entity: "pages",
243
+ entity_id: target.id,
244
+ meta: {
245
+ description: `Scheduled the page "${reveal.revealPageKey.trim()}" to go live at ${publishAt} because a countdown block reveals it when the timer reaches zero.`,
246
+ publish_at: publishAt,
247
+ },
248
+ });
249
+ }
250
+ }
251
+
186
252
  export async function listPageVersionsCore(deps: PagesDeps, pageId: string) {
187
253
  const { context, admin } = deps;
188
254
  await assertStaff(context);
@@ -18,8 +18,13 @@ export type GlobalsReadClient = {
18
18
 
19
19
  function refKeysOf(blocks: BlockInstance[]): string[] {
20
20
  return blocks
21
- .filter((b) => b.type === "globalRef")
22
- .map((b) => String((b.props as BlockProps | undefined)?.sectionKey ?? ""))
21
+ .map((b) => {
22
+ const props = b.props as (BlockProps & { expiredSectionKey?: string }) | undefined;
23
+ if (b.type === "globalRef") return String(props?.sectionKey ?? "");
24
+ // An expired countdown may render a global block in its place.
25
+ if (b.type === "countdown") return String(props?.expiredSectionKey ?? "");
26
+ return "";
27
+ })
23
28
  .filter(Boolean);
24
29
  }
25
30
 
@@ -8,6 +8,7 @@
8
8
  import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
9
9
 
10
10
  import { PriorityMediaProvider } from "../media/priority";
11
+ import { CountdownReplacementProvider } from "../blocks/countdown-replacement";
11
12
  import type { BlockDef } from "../blocks/registry";
12
13
  import type { BlockInstance, BlockProps } from "../blocks/types";
13
14
 
@@ -62,7 +63,35 @@ function RenderBlock({
62
63
  const def = registry[block.type];
63
64
  if (!def) return null;
64
65
  const Cmp = def.component;
65
- const rendered = <Cmp props={(block.props ?? {}) as BlockProps} />;
66
+ let rendered = <Cmp props={(block.props ?? {}) as BlockProps} />;
67
+
68
+ // An expired countdown can show a global block instead of itself; resolve it
69
+ // here so the block component stays free of data access.
70
+ if (block.type === "countdown") {
71
+ const props = (block.props ?? {}) as BlockProps & {
72
+ onExpire?: string;
73
+ expiredSectionKey?: string;
74
+ };
75
+ const key = props.onExpire === "replace" ? String(props.expiredSectionKey ?? "") : "";
76
+ const inner = key ? (globals[key] ?? []) : [];
77
+ if (inner.length > 0) {
78
+ rendered = (
79
+ <CountdownReplacementProvider
80
+ value={inner.map((b) => (
81
+ <RenderBlock
82
+ key={b.id}
83
+ block={b}
84
+ globals={globals}
85
+ registry={registry}
86
+ edit={false}
87
+ />
88
+ ))}
89
+ >
90
+ {rendered}
91
+ </CountdownReplacementProvider>
92
+ );
93
+ }
94
+ }
66
95
 
67
96
  if (!edit) return rendered;
68
97