@bygghammar/cms-platform 0.72.0 → 0.73.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 +11 -0
- package/package.json +1 -1
- package/src/backoffice/inspectors/blocks.tsx +307 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/blocks/components/CountdownBlock.tsx +188 -0
- package/src/blocks/countdown-colors.ts +71 -0
- package/src/blocks/countdown-replacement.tsx +24 -0
- package/src/blocks/countdown-time.ts +128 -0
- package/src/blocks/index.ts +22 -0
- package/src/blocks/registry.tsx +31 -0
- package/src/blocks/types.ts +59 -0
- package/src/server/pages-core.ts +66 -0
- package/src/server/resolve-globals.ts +7 -2
- package/src/site/PageRenderer.tsx +30 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.73.0
|
|
4
|
+
|
|
5
|
+
- New **Countdown** block: timezone-aware deadline (IANA zone + wall clock),
|
|
6
|
+
localized heading, sub text, fine print and unit labels, theme-token colours
|
|
7
|
+
with per-field overrides, configurable rounding, tile size, gaps, spacing,
|
|
8
|
+
alignment, separators, borders and shadows, and a choice of what happens at
|
|
9
|
+
zero (hide the block, show a global block instead, or schedule a hidden page
|
|
10
|
+
to go live at the deadline). The editor shows the current time, the resolved
|
|
11
|
+
deadline and the remaining time live.
|
|
12
|
+
|
|
13
|
+
|
|
3
14
|
## 0.72.0
|
|
4
15
|
|
|
5
16
|
- `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.
|
|
3
|
+
"version": "0.73.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": [
|
|
@@ -6,6 +6,14 @@
|
|
|
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_DEFAULT_TIMEZONE,
|
|
12
|
+
COUNTDOWN_TIMEZONES,
|
|
13
|
+
countdownParts,
|
|
14
|
+
formatInZone,
|
|
15
|
+
resolveCountdownTarget,
|
|
16
|
+
} from "../../blocks/countdown-time";
|
|
9
17
|
import type {
|
|
10
18
|
BlockInstance,
|
|
11
19
|
BlockProps,
|
|
@@ -37,6 +45,7 @@ import type {
|
|
|
37
45
|
PriceTableProps,
|
|
38
46
|
PriceGroup,
|
|
39
47
|
IntroImageAspect,
|
|
48
|
+
CountdownProps,
|
|
40
49
|
} from "../../blocks/types";
|
|
41
50
|
import { INTRO_IMAGE_ASPECTS } from "../../blocks/types";
|
|
42
51
|
import { BLOCK_ICON_NAMES, resolveBlockIcon } from "../../blocks/icons";
|
|
@@ -2391,3 +2400,301 @@ export {
|
|
|
2391
2400
|
Toggle as InspectorToggle,
|
|
2392
2401
|
uid as inspectorUid,
|
|
2393
2402
|
};
|
|
2403
|
+
|
|
2404
|
+
/* ---------------- Countdown ---------------- */
|
|
2405
|
+
export function CountdownInspector({ block, patch }: BlockPatchProps) {
|
|
2406
|
+
const p = (block.props ?? {}) as CountdownProps;
|
|
2407
|
+
const update = (next: CountdownProps) => patch(next as unknown as BlockProps);
|
|
2408
|
+
|
|
2409
|
+
const timezone = p.timezone?.trim() || COUNTDOWN_DEFAULT_TIMEZONE;
|
|
2410
|
+
const target = resolveCountdownTarget(p.targetLocal, timezone);
|
|
2411
|
+
|
|
2412
|
+
// Live clock so the editor always shows the real current time next to the
|
|
2413
|
+
// deadline and the remaining time.
|
|
2414
|
+
const [now, setNow] = useState(() => Date.now());
|
|
2415
|
+
useEffect(() => {
|
|
2416
|
+
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
2417
|
+
return () => clearInterval(id);
|
|
2418
|
+
}, []);
|
|
2419
|
+
const parts = countdownParts(target, now);
|
|
2420
|
+
|
|
2421
|
+
const colorRow = (label: string, key: keyof CountdownColors) => {
|
|
2422
|
+
const current = (p.colors?.[key] ?? "").trim();
|
|
2423
|
+
const swatch = current.startsWith("#") ? current : "#ffffff";
|
|
2424
|
+
return (
|
|
2425
|
+
<label key={key} className="block space-y-1.5">
|
|
2426
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
|
|
2427
|
+
<span className="flex items-center gap-2">
|
|
2428
|
+
<input
|
|
2429
|
+
type="color"
|
|
2430
|
+
value={swatch}
|
|
2431
|
+
onChange={(e) => update({ ...p, colors: { ...p.colors, [key]: e.target.value } })}
|
|
2432
|
+
className="h-9 w-12 shrink-0 cursor-pointer border border-ink/15 bg-white p-0.5"
|
|
2433
|
+
aria-label={`${label} colour picker`}
|
|
2434
|
+
/>
|
|
2435
|
+
<input
|
|
2436
|
+
type="text"
|
|
2437
|
+
value={current}
|
|
2438
|
+
placeholder={COUNTDOWN_COLOR_DEFAULTS[key]}
|
|
2439
|
+
onChange={(e) => update({ ...p, colors: { ...p.colors, [key]: e.target.value } })}
|
|
2440
|
+
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"
|
|
2441
|
+
/>
|
|
2442
|
+
</span>
|
|
2443
|
+
</label>
|
|
2444
|
+
);
|
|
2445
|
+
};
|
|
2446
|
+
|
|
2447
|
+
const select = <T extends string>(
|
|
2448
|
+
label: string,
|
|
2449
|
+
value: T | undefined,
|
|
2450
|
+
fallback: T,
|
|
2451
|
+
options: { value: T; label: string }[],
|
|
2452
|
+
onPick: (v: T) => void,
|
|
2453
|
+
) => (
|
|
2454
|
+
<label className="block space-y-1.5">
|
|
2455
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
|
|
2456
|
+
<select
|
|
2457
|
+
value={value ?? fallback}
|
|
2458
|
+
onChange={(e) => onPick(e.target.value as T)}
|
|
2459
|
+
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"
|
|
2460
|
+
>
|
|
2461
|
+
{options.map((o) => (
|
|
2462
|
+
<option key={o.value} value={o.value}>
|
|
2463
|
+
{o.label}
|
|
2464
|
+
</option>
|
|
2465
|
+
))}
|
|
2466
|
+
</select>
|
|
2467
|
+
</label>
|
|
2468
|
+
);
|
|
2469
|
+
|
|
2470
|
+
const toggle = (label: string, value: boolean, onPick: (v: boolean) => void) => (
|
|
2471
|
+
<label className="flex items-center gap-2 text-sm text-ink">
|
|
2472
|
+
<input type="checkbox" checked={value} onChange={(e) => onPick(e.target.checked)} />
|
|
2473
|
+
{label}
|
|
2474
|
+
</label>
|
|
2475
|
+
);
|
|
2476
|
+
|
|
2477
|
+
return (
|
|
2478
|
+
<>
|
|
2479
|
+
<InspectorSection title="Text above the timer">
|
|
2480
|
+
<LocalizedField label="Heading" multiline value={p.title} onChange={(v) => update({ ...p, title: v })} />
|
|
2481
|
+
<LocalizedField label="Sub text" multiline value={p.body} onChange={(v) => update({ ...p, body: v })} />
|
|
2482
|
+
<LocalizedField label="Fine print (under the timer)" value={p.note} onChange={(v) => update({ ...p, note: v })} />
|
|
2483
|
+
</InspectorSection>
|
|
2484
|
+
|
|
2485
|
+
<InspectorSection title="Deadline">
|
|
2486
|
+
<label className="block space-y-1.5">
|
|
2487
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
|
|
2488
|
+
Counts down to (date and time)
|
|
2489
|
+
</span>
|
|
2490
|
+
<input
|
|
2491
|
+
type="datetime-local"
|
|
2492
|
+
value={p.targetLocal ?? ""}
|
|
2493
|
+
onChange={(e) => update({ ...p, targetLocal: e.target.value })}
|
|
2494
|
+
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"
|
|
2495
|
+
/>
|
|
2496
|
+
</label>
|
|
2497
|
+
<label className="block space-y-1.5">
|
|
2498
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Timezone</span>
|
|
2499
|
+
<select
|
|
2500
|
+
value={timezone}
|
|
2501
|
+
onChange={(e) => update({ ...p, timezone: e.target.value })}
|
|
2502
|
+
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"
|
|
2503
|
+
>
|
|
2504
|
+
{COUNTDOWN_TIMEZONES.map((tz) => (
|
|
2505
|
+
<option key={tz} value={tz}>
|
|
2506
|
+
{tz}
|
|
2507
|
+
</option>
|
|
2508
|
+
))}
|
|
2509
|
+
</select>
|
|
2510
|
+
</label>
|
|
2511
|
+
<div className="space-y-1 border border-ink/10 bg-paper/60 px-3 py-2.5 text-xs leading-relaxed text-ink/70">
|
|
2512
|
+
<p>Time right now: {formatInZone(now, timezone)}</p>
|
|
2513
|
+
<p>
|
|
2514
|
+
Deadline:{" "}
|
|
2515
|
+
{target == null ? "not set yet" : formatInZone(target, timezone)}
|
|
2516
|
+
</p>
|
|
2517
|
+
<p>
|
|
2518
|
+
Time left:{" "}
|
|
2519
|
+
{target == null
|
|
2520
|
+
? "—"
|
|
2521
|
+
: parts.expired
|
|
2522
|
+
? "the timer has already reached zero"
|
|
2523
|
+
: `${parts.days}d ${parts.hours}h ${parts.minutes}m ${parts.seconds}s`}
|
|
2524
|
+
</p>
|
|
2525
|
+
</div>
|
|
2526
|
+
</InspectorSection>
|
|
2527
|
+
|
|
2528
|
+
<InspectorSection title="Unit labels">
|
|
2529
|
+
<LocalizedField label="Days" value={p.labelDays} onChange={(v) => update({ ...p, labelDays: v })} />
|
|
2530
|
+
<LocalizedField label="Hours" value={p.labelHours} onChange={(v) => update({ ...p, labelHours: v })} />
|
|
2531
|
+
<LocalizedField label="Minutes" value={p.labelMinutes} onChange={(v) => update({ ...p, labelMinutes: v })} />
|
|
2532
|
+
<LocalizedField label="Seconds" value={p.labelSeconds} onChange={(v) => update({ ...p, labelSeconds: v })} />
|
|
2533
|
+
{toggle("Show days", p.showDays ?? true, (v) => update({ ...p, showDays: v }))}
|
|
2534
|
+
{toggle("Hide days when less than a day is left", p.hideDaysWhenZero !== false, (v) =>
|
|
2535
|
+
update({ ...p, hideDaysWhenZero: v }),
|
|
2536
|
+
)}
|
|
2537
|
+
{toggle("Show seconds", p.showSeconds ?? true, (v) => update({ ...p, showSeconds: v }))}
|
|
2538
|
+
</InspectorSection>
|
|
2539
|
+
|
|
2540
|
+
<InspectorSection title="Colours">
|
|
2541
|
+
<p className="text-xs leading-relaxed text-ink/60">
|
|
2542
|
+
Leave a field empty to use the site theme. Accepts hex values such as #ff4f9a.
|
|
2543
|
+
</p>
|
|
2544
|
+
{colorRow("Section background", "sectionBg")}
|
|
2545
|
+
{colorRow("Heading", "heading")}
|
|
2546
|
+
{colorRow("Sub text", "subText")}
|
|
2547
|
+
{colorRow("Number tile background", "tileBg")}
|
|
2548
|
+
{colorRow("Numbers", "digit")}
|
|
2549
|
+
{colorRow("Unit labels", "unit")}
|
|
2550
|
+
{colorRow("Tile border", "border")}
|
|
2551
|
+
<button
|
|
2552
|
+
type="button"
|
|
2553
|
+
onClick={() => update({ ...p, colors: {} })}
|
|
2554
|
+
className="text-xs uppercase tracking-[0.18em] text-ink/60 underline hover:text-ink"
|
|
2555
|
+
>
|
|
2556
|
+
Reset colours to theme defaults
|
|
2557
|
+
</button>
|
|
2558
|
+
</InspectorSection>
|
|
2559
|
+
|
|
2560
|
+
<InspectorSection title="Layout">
|
|
2561
|
+
{select(
|
|
2562
|
+
"Corner rounding",
|
|
2563
|
+
p.rounding,
|
|
2564
|
+
"soft",
|
|
2565
|
+
[
|
|
2566
|
+
{ value: "square", label: "Square" },
|
|
2567
|
+
{ value: "soft", label: "Soft" },
|
|
2568
|
+
{ value: "rounded", label: "Rounded" },
|
|
2569
|
+
{ value: "pill", label: "Pill" },
|
|
2570
|
+
{ value: "custom", label: "Custom value" },
|
|
2571
|
+
],
|
|
2572
|
+
(v) => update({ ...p, rounding: v as CountdownProps["rounding"] }),
|
|
2573
|
+
)}
|
|
2574
|
+
{p.rounding === "custom" ? (
|
|
2575
|
+
<TextField
|
|
2576
|
+
label="Custom radius (e.g. 12px or 1.5rem)"
|
|
2577
|
+
value={p.radius ?? ""}
|
|
2578
|
+
onChange={(v) => update({ ...p, radius: v })}
|
|
2579
|
+
/>
|
|
2580
|
+
) : null}
|
|
2581
|
+
{select(
|
|
2582
|
+
"Tile size",
|
|
2583
|
+
p.tileSize,
|
|
2584
|
+
"md",
|
|
2585
|
+
[
|
|
2586
|
+
{ value: "sm", label: "Small" },
|
|
2587
|
+
{ value: "md", label: "Medium" },
|
|
2588
|
+
{ value: "lg", label: "Large" },
|
|
2589
|
+
],
|
|
2590
|
+
(v) => update({ ...p, tileSize: v as CountdownProps["tileSize"] }),
|
|
2591
|
+
)}
|
|
2592
|
+
{select(
|
|
2593
|
+
"Space between tiles",
|
|
2594
|
+
p.gap,
|
|
2595
|
+
"md",
|
|
2596
|
+
[
|
|
2597
|
+
{ value: "sm", label: "Tight" },
|
|
2598
|
+
{ value: "md", label: "Normal" },
|
|
2599
|
+
{ value: "lg", label: "Wide" },
|
|
2600
|
+
],
|
|
2601
|
+
(v) => update({ ...p, gap: v as CountdownProps["gap"] }),
|
|
2602
|
+
)}
|
|
2603
|
+
{select(
|
|
2604
|
+
"Section spacing",
|
|
2605
|
+
p.padding,
|
|
2606
|
+
"normal",
|
|
2607
|
+
[
|
|
2608
|
+
{ value: "compact", label: "Compact" },
|
|
2609
|
+
{ value: "normal", label: "Normal" },
|
|
2610
|
+
{ value: "roomy", label: "Roomy" },
|
|
2611
|
+
],
|
|
2612
|
+
(v) => update({ ...p, padding: v as CountdownProps["padding"] }),
|
|
2613
|
+
)}
|
|
2614
|
+
{select(
|
|
2615
|
+
"Alignment",
|
|
2616
|
+
p.align,
|
|
2617
|
+
"center",
|
|
2618
|
+
[
|
|
2619
|
+
{ value: "left", label: "Left" },
|
|
2620
|
+
{ value: "center", label: "Centered" },
|
|
2621
|
+
{ value: "right", label: "Right" },
|
|
2622
|
+
],
|
|
2623
|
+
(v) => update({ ...p, align: v as CountdownProps["align"] }),
|
|
2624
|
+
)}
|
|
2625
|
+
{select(
|
|
2626
|
+
"Separator between tiles",
|
|
2627
|
+
p.separator,
|
|
2628
|
+
"none",
|
|
2629
|
+
[
|
|
2630
|
+
{ value: "none", label: "None" },
|
|
2631
|
+
{ value: "colon", label: "Colon" },
|
|
2632
|
+
],
|
|
2633
|
+
(v) => update({ ...p, separator: v as CountdownProps["separator"] }),
|
|
2634
|
+
)}
|
|
2635
|
+
{toggle("Border around tiles", Boolean(p.showBorder), (v) => update({ ...p, showBorder: v }))}
|
|
2636
|
+
{toggle("Shadow under tiles", Boolean(p.showShadow), (v) => update({ ...p, showShadow: v }))}
|
|
2637
|
+
<TextField label="Anchor id" value={p.anchor ?? ""} onChange={(v) => update({ ...p, anchor: v })} />
|
|
2638
|
+
</InspectorSection>
|
|
2639
|
+
|
|
2640
|
+
<InspectorSection title="When the timer reaches zero">
|
|
2641
|
+
{select(
|
|
2642
|
+
"Then",
|
|
2643
|
+
p.onExpire,
|
|
2644
|
+
"hide",
|
|
2645
|
+
[
|
|
2646
|
+
{ value: "hide", label: "Stop showing this block" },
|
|
2647
|
+
{ value: "replace", label: "Show another (global) block instead" },
|
|
2648
|
+
{ value: "revealPage", label: "Publish a hidden page at the deadline" },
|
|
2649
|
+
],
|
|
2650
|
+
(v) => update({ ...p, onExpire: v as CountdownProps["onExpire"] }),
|
|
2651
|
+
)}
|
|
2652
|
+
{p.onExpire === "replace" ? (
|
|
2653
|
+
<TextField
|
|
2654
|
+
label="Global block key to show instead"
|
|
2655
|
+
value={p.expiredSectionKey ?? ""}
|
|
2656
|
+
onChange={(v) => update({ ...p, expiredSectionKey: v })}
|
|
2657
|
+
/>
|
|
2658
|
+
) : null}
|
|
2659
|
+
{p.onExpire === "revealPage" ? (
|
|
2660
|
+
<>
|
|
2661
|
+
<TextField
|
|
2662
|
+
label="Page key to publish at the deadline"
|
|
2663
|
+
value={p.revealPageKey ?? ""}
|
|
2664
|
+
onChange={(v) => update({ ...p, revealPageKey: v })}
|
|
2665
|
+
/>
|
|
2666
|
+
<p className="text-xs leading-relaxed text-ink/60">
|
|
2667
|
+
The page is scheduled to go live at the deadline the next time you publish this page.
|
|
2668
|
+
Nothing is deleted or unpublished.
|
|
2669
|
+
</p>
|
|
2670
|
+
</>
|
|
2671
|
+
) : null}
|
|
2672
|
+
{p.onExpire !== "hide" ? (
|
|
2673
|
+
<>
|
|
2674
|
+
<LocalizedField
|
|
2675
|
+
label="Message heading after the deadline"
|
|
2676
|
+
value={p.expiredTitle}
|
|
2677
|
+
onChange={(v) => update({ ...p, expiredTitle: v })}
|
|
2678
|
+
/>
|
|
2679
|
+
<LocalizedField
|
|
2680
|
+
label="Message text after the deadline"
|
|
2681
|
+
multiline
|
|
2682
|
+
value={p.expiredBody}
|
|
2683
|
+
onChange={(v) => update({ ...p, expiredBody: v })}
|
|
2684
|
+
/>
|
|
2685
|
+
<LocalizedField
|
|
2686
|
+
label="Button label"
|
|
2687
|
+
value={p.expiredCtaLabel}
|
|
2688
|
+
onChange={(v) => update({ ...p, expiredCtaLabel: v })}
|
|
2689
|
+
/>
|
|
2690
|
+
<TextField
|
|
2691
|
+
label="Button link"
|
|
2692
|
+
value={p.expiredCtaHref ?? ""}
|
|
2693
|
+
onChange={(v) => update({ ...p, expiredCtaHref: v })}
|
|
2694
|
+
/>
|
|
2695
|
+
</>
|
|
2696
|
+
) : null}
|
|
2697
|
+
</InspectorSection>
|
|
2698
|
+
</>
|
|
2699
|
+
);
|
|
2700
|
+
}
|
|
@@ -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,188 @@
|
|
|
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 { useCountdownReplacement } from "../countdown-replacement";
|
|
9
|
+
import type { CountdownProps } from "../types";
|
|
10
|
+
|
|
11
|
+
const TILE_SIZE = {
|
|
12
|
+
sm: { box: "min-w-14 px-3 py-2.5", digit: "text-2xl md:text-3xl", unit: "text-[0.55rem]" },
|
|
13
|
+
md: { box: "min-w-20 px-4 py-3.5", digit: "text-3xl md:text-5xl", unit: "text-[0.6rem]" },
|
|
14
|
+
lg: { box: "min-w-28 px-6 py-5", digit: "text-4xl md:text-6xl", unit: "text-[0.68rem]" },
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
const GAP = { sm: "gap-2", md: "gap-3 md:gap-4", lg: "gap-5 md:gap-7" } as const;
|
|
18
|
+
const PADDING = {
|
|
19
|
+
compact: "py-8 md:py-10",
|
|
20
|
+
normal: "py-14 md:py-20",
|
|
21
|
+
roomy: "py-20 md:py-32",
|
|
22
|
+
} as const;
|
|
23
|
+
const ALIGN = {
|
|
24
|
+
left: { wrap: "items-start text-left", row: "justify-start" },
|
|
25
|
+
center: { wrap: "items-center text-center", row: "justify-center" },
|
|
26
|
+
right: { wrap: "items-end text-right", row: "justify-end" },
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Localized, timezone-aware countdown with fully configurable colours, corner
|
|
33
|
+
* rounding and expiry behaviour.
|
|
34
|
+
*/
|
|
35
|
+
export function CountdownBlock({ props: p }: { props: CountdownProps }) {
|
|
36
|
+
const { t } = useT();
|
|
37
|
+
const replacement = useCountdownReplacement();
|
|
38
|
+
|
|
39
|
+
const target = useMemo(
|
|
40
|
+
() => resolveCountdownTarget(p.targetLocal, p.timezone),
|
|
41
|
+
[p.targetLocal, p.timezone],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const [now, setNow] = useState(() => Date.now());
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (target == null) return;
|
|
47
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
48
|
+
const tick = () => setNow(Date.now());
|
|
49
|
+
const start = () => {
|
|
50
|
+
stop();
|
|
51
|
+
tick();
|
|
52
|
+
timer = setInterval(tick, 1000);
|
|
53
|
+
};
|
|
54
|
+
const stop = () => {
|
|
55
|
+
if (timer) clearInterval(timer);
|
|
56
|
+
timer = undefined;
|
|
57
|
+
};
|
|
58
|
+
const onVisibility = () => (document.hidden ? stop() : start());
|
|
59
|
+
start();
|
|
60
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
61
|
+
return () => {
|
|
62
|
+
stop();
|
|
63
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
64
|
+
};
|
|
65
|
+
}, [target]);
|
|
66
|
+
|
|
67
|
+
const parts = countdownParts(target, now);
|
|
68
|
+
const expired = parts.expired;
|
|
69
|
+
const onExpire = p.onExpire ?? "hide";
|
|
70
|
+
|
|
71
|
+
const size = TILE_SIZE[p.tileSize ?? "md"];
|
|
72
|
+
const align = ALIGN[p.align ?? "center"];
|
|
73
|
+
const radius = countdownRadius(p.rounding, p.radius);
|
|
74
|
+
const style = {
|
|
75
|
+
...countdownColorStyle(p.colors),
|
|
76
|
+
["--cd-radius" as string]: radius,
|
|
77
|
+
} as CSSProperties;
|
|
78
|
+
|
|
79
|
+
const section = (children: ReactNode) => (
|
|
80
|
+
<section
|
|
81
|
+
id={p.anchor || undefined}
|
|
82
|
+
style={style}
|
|
83
|
+
className={`bg-[var(--cd-section-bg)] ${PADDING[p.padding ?? "normal"]}`}
|
|
84
|
+
>
|
|
85
|
+
<div className="container-editorial">
|
|
86
|
+
<Reveal>
|
|
87
|
+
<div className={`flex flex-col ${align.wrap}`}>{children}</div>
|
|
88
|
+
</Reveal>
|
|
89
|
+
</div>
|
|
90
|
+
</section>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const heading = (title?: Partial<LocalizedString>, body?: Partial<LocalizedString>) => (
|
|
94
|
+
<>
|
|
95
|
+
{t(title) ? (
|
|
96
|
+
<h2 className="font-serif text-[clamp(1.6rem,3.6vw,2.9rem)] leading-[1.1] tracking-[-0.02em] whitespace-pre-line text-[var(--cd-heading)]">
|
|
97
|
+
{t(title)}
|
|
98
|
+
</h2>
|
|
99
|
+
) : null}
|
|
100
|
+
{t(body) ? (
|
|
101
|
+
<p className="mt-4 max-w-2xl text-base md:text-lg leading-relaxed whitespace-pre-line text-[var(--cd-sub)]">
|
|
102
|
+
{t(body)}
|
|
103
|
+
</p>
|
|
104
|
+
) : null}
|
|
105
|
+
</>
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
/* ---------- Expired ---------- */
|
|
109
|
+
if (expired) {
|
|
110
|
+
if (onExpire === "hide") return null;
|
|
111
|
+
if (onExpire === "replace" && replacement) return <>{replacement}</>;
|
|
112
|
+
// "replace" without a global block, and "revealPage", both show the
|
|
113
|
+
// configured expired message (plus an optional link to the revealed page).
|
|
114
|
+
const href = p.expiredCtaHref?.trim() || (p.revealPageKey?.trim() ? `/${p.revealPageKey.trim()}` : "");
|
|
115
|
+
const label = t(p.expiredCtaLabel);
|
|
116
|
+
return section(
|
|
117
|
+
<>
|
|
118
|
+
{heading(p.expiredTitle, p.expiredBody)}
|
|
119
|
+
{href && label ? (
|
|
120
|
+
<a
|
|
121
|
+
href={href}
|
|
122
|
+
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"
|
|
123
|
+
>
|
|
124
|
+
{label}
|
|
125
|
+
</a>
|
|
126
|
+
) : null}
|
|
127
|
+
</>,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* ---------- Running ---------- */
|
|
132
|
+
const showDays = (p.showDays ?? true) && !(p.hideDaysWhenZero !== false && parts.days === 0);
|
|
133
|
+
const units: { value: string; label?: Partial<LocalizedString> }[] = [
|
|
134
|
+
...(showDays ? [{ value: pad(parts.days), label: p.labelDays }] : []),
|
|
135
|
+
{ value: pad(parts.hours), label: p.labelHours },
|
|
136
|
+
{ value: pad(parts.minutes), label: p.labelMinutes },
|
|
137
|
+
...((p.showSeconds ?? true) ? [{ value: pad(parts.seconds), label: p.labelSeconds }] : []),
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
const border = p.showBorder ? "border border-[var(--cd-border)]" : "";
|
|
141
|
+
const shadow = p.showShadow ? "shadow-lg shadow-black/10" : "";
|
|
142
|
+
|
|
143
|
+
return section(
|
|
144
|
+
<>
|
|
145
|
+
{heading(p.title, p.body)}
|
|
146
|
+
<div
|
|
147
|
+
className={`mt-8 flex flex-wrap items-stretch ${GAP[p.gap ?? "md"]} ${align.row}`}
|
|
148
|
+
aria-live="off"
|
|
149
|
+
>
|
|
150
|
+
{units.map((u, i) => (
|
|
151
|
+
<div key={i} className="flex items-stretch">
|
|
152
|
+
<div
|
|
153
|
+
className={`flex flex-col items-center justify-center bg-[var(--cd-tile-bg)] ${size.box} ${border} ${shadow}`}
|
|
154
|
+
style={{ borderRadius: "var(--cd-radius)" }}
|
|
155
|
+
>
|
|
156
|
+
<span
|
|
157
|
+
suppressHydrationWarning
|
|
158
|
+
className={`font-semibold tabular-nums leading-none text-[var(--cd-digit)] ${size.digit}`}
|
|
159
|
+
>
|
|
160
|
+
{u.value}
|
|
161
|
+
</span>
|
|
162
|
+
{t(u.label) ? (
|
|
163
|
+
<span
|
|
164
|
+
className={`mt-1.5 uppercase tracking-[0.18em] text-[var(--cd-unit)] ${size.unit}`}
|
|
165
|
+
>
|
|
166
|
+
{t(u.label)}
|
|
167
|
+
</span>
|
|
168
|
+
) : null}
|
|
169
|
+
</div>
|
|
170
|
+
{p.separator === "colon" && i < units.length - 1 ? (
|
|
171
|
+
<span
|
|
172
|
+
className={`self-center px-1 font-semibold text-[var(--cd-heading)] ${size.digit}`}
|
|
173
|
+
aria-hidden="true"
|
|
174
|
+
>
|
|
175
|
+
:
|
|
176
|
+
</span>
|
|
177
|
+
) : null}
|
|
178
|
+
</div>
|
|
179
|
+
))}
|
|
180
|
+
</div>
|
|
181
|
+
{t(p.note) ? (
|
|
182
|
+
<p className="mt-5 text-xs uppercase tracking-[0.18em] text-[var(--cd-sub)]">
|
|
183
|
+
{t(p.note)}
|
|
184
|
+
</p>
|
|
185
|
+
) : null}
|
|
186
|
+
</>,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -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
|
+
}
|
package/src/blocks/index.ts
CHANGED
|
@@ -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,24 @@ 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";
|
package/src/blocks/registry.tsx
CHANGED
|
@@ -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",
|
package/src/blocks/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ButtonColors } from "./button-colors";
|
|
2
|
+
import type { CountdownColors, CountdownRounding } from "./countdown-colors";
|
|
2
3
|
import type { LocalizedString } from "../i18n";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -448,3 +449,61 @@ export type FormProps = {
|
|
|
448
449
|
tone?: "white" | "paper" | "ink";
|
|
449
450
|
anchor?: string;
|
|
450
451
|
};
|
|
452
|
+
|
|
453
|
+
/** ---- Countdown (timezone-aware timer with configurable expiry) ---- */
|
|
454
|
+
|
|
455
|
+
/** What happens once the countdown reaches zero. */
|
|
456
|
+
export type CountdownExpiryAction = "hide" | "replace" | "revealPage";
|
|
457
|
+
|
|
458
|
+
export type CountdownProps = {
|
|
459
|
+
/** Heading above the timer. */
|
|
460
|
+
title?: Partial<LocalizedString>;
|
|
461
|
+
/** Sub text under the heading. */
|
|
462
|
+
body?: Partial<LocalizedString>;
|
|
463
|
+
/** Fine print under the timer. */
|
|
464
|
+
note?: Partial<LocalizedString>;
|
|
465
|
+
|
|
466
|
+
/** Deadline as wall-clock time, `YYYY-MM-DDTHH:mm`. */
|
|
467
|
+
targetLocal?: string;
|
|
468
|
+
/** IANA timezone the deadline is expressed in, e.g. `Europe/Stockholm`. */
|
|
469
|
+
timezone?: string;
|
|
470
|
+
|
|
471
|
+
/** Unit labels, translated per language. */
|
|
472
|
+
labelDays?: Partial<LocalizedString>;
|
|
473
|
+
labelHours?: Partial<LocalizedString>;
|
|
474
|
+
labelMinutes?: Partial<LocalizedString>;
|
|
475
|
+
labelSeconds?: Partial<LocalizedString>;
|
|
476
|
+
showDays?: boolean;
|
|
477
|
+
/** Drop the days tile once fewer than 24 hours remain (default true). */
|
|
478
|
+
hideDaysWhenZero?: boolean;
|
|
479
|
+
showSeconds?: boolean;
|
|
480
|
+
|
|
481
|
+
/** Colours; empty values fall back to the site theme. */
|
|
482
|
+
colors?: CountdownColors;
|
|
483
|
+
|
|
484
|
+
/** Layout. */
|
|
485
|
+
rounding?: CountdownRounding;
|
|
486
|
+
/** Custom corner radius, used when `rounding` is `custom`. */
|
|
487
|
+
radius?: string;
|
|
488
|
+
tileSize?: "sm" | "md" | "lg";
|
|
489
|
+
gap?: "sm" | "md" | "lg";
|
|
490
|
+
padding?: "compact" | "normal" | "roomy";
|
|
491
|
+
align?: "left" | "center" | "right";
|
|
492
|
+
separator?: "none" | "colon";
|
|
493
|
+
showBorder?: boolean;
|
|
494
|
+
showShadow?: boolean;
|
|
495
|
+
|
|
496
|
+
/** Expiry behaviour. */
|
|
497
|
+
onExpire?: CountdownExpiryAction;
|
|
498
|
+
/** Global block rendered instead of the countdown when it expires. */
|
|
499
|
+
expiredSectionKey?: string;
|
|
500
|
+
/** Fallback message when no global block is chosen. */
|
|
501
|
+
expiredTitle?: Partial<LocalizedString>;
|
|
502
|
+
expiredBody?: Partial<LocalizedString>;
|
|
503
|
+
expiredCtaLabel?: Partial<LocalizedString>;
|
|
504
|
+
expiredCtaHref?: string;
|
|
505
|
+
/** Page key scheduled to go live at the deadline (`revealPage`). */
|
|
506
|
+
revealPageKey?: string;
|
|
507
|
+
|
|
508
|
+
anchor?: string;
|
|
509
|
+
};
|
package/src/server/pages-core.ts
CHANGED
|
@@ -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
|
-
.
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|