@bettercms-ai/next 0.21.0 → 0.22.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/dist/blocks.d.ts CHANGED
@@ -29,8 +29,19 @@ interface BcmsBlocksProps {
29
29
  apiBase?: string;
30
30
  /** Class for the wrapping <div>. */
31
31
  className?: string;
32
+ /**
33
+ * The instant block windows (`block.window`) are read against. Defaults to the render time: the
34
+ * build for a static page, the request for a server-rendered one.
35
+ */
36
+ now?: Date;
37
+ /**
38
+ * Draw the blocks outside their window too, marked `data-bcms-window="pending|expired"`.
39
+ * For a draft preview, where an author must be able to find what they scheduled. Off, a block
40
+ * outside its window is not rendered, at any depth.
41
+ */
42
+ showScheduled?: boolean;
32
43
  }
33
44
  /** Render a page's blocks. Unknown/empty blocks render nothing (fail-soft). */
34
- declare function BcmsBlocks({ blocks, forms, components, turnstileSiteKey, apiBase, className, }: BcmsBlocksProps): ReactElement;
45
+ declare function BcmsBlocks({ blocks: all, forms, components, turnstileSiteKey, apiBase, className, now, showScheduled, }: BcmsBlocksProps): ReactElement;
35
46
 
36
47
  export { BcmsBlocks, type BcmsBlocksProps };
package/dist/blocks.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  isValidElement,
8
8
  useState as useState2
9
9
  } from "react";
10
- import { blockStyleToCss, blockStyleToInnerCss, splitButtonStyle, buttonIconOf, buttonIconSvg, buttonVariantOf, headingPaintCss, withButtonLookProps } from "@bettercms-ai/types";
10
+ import { RESPONSIVE_CSS, blockStyleToCss, blockStyleToInnerCss, hideOnAttr, usesHideOn, visibleBlocks, windowState, splitButtonStyle, buttonIconOf, buttonIconSvg, buttonVariantOf, headingPaintCss, withButtonLookProps } from "@bettercms-ai/types";
11
11
  import { componentInstanceAddresses } from "@bettercms-ai/types";
12
12
  import { applyOverrides } from "@bettercms-ai/ui/resolve";
13
13
  import { PortableText } from "@bettercms-ai/ui/blocks";
@@ -15,7 +15,7 @@ import { embeddedComponentBlock, readStructuredContent } from "@bettercms-ai/ric
15
15
  import { headingParts, richtextHeadingLevel } from "@bettercms-ai/richtext/heading";
16
16
 
17
17
  // src/form.tsx
18
- import { lazy, Suspense, useEffect, useMemo, useState } from "react";
18
+ import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
19
19
  import {
20
20
  submitForm,
21
21
  shouldShowField,
@@ -23,7 +23,7 @@ import {
23
23
  asStringArray,
24
24
  toggleOption
25
25
  } from "@bettercms-ai/sdk";
26
- import { jsx, jsxs } from "react/jsx-runtime";
26
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
27
27
  var TURNSTILE_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js";
28
28
  var PhoneField = lazy(() => import("./phone-field.js"));
29
29
  var INPUT_TYPES = {
@@ -42,6 +42,21 @@ function readPrefill() {
42
42
  });
43
43
  return out;
44
44
  }
45
+ function splitSteps(fields) {
46
+ if (!fields.some((f) => f.type === "step")) return null;
47
+ const pages = [];
48
+ let cur = { label: null, key: null, fields: [] };
49
+ for (const f of fields) {
50
+ if (f.type !== "step") {
51
+ cur.fields.push(f);
52
+ continue;
53
+ }
54
+ if (cur.fields.length) pages.push(cur);
55
+ cur = { label: f.label, key: f.key, fields: [] };
56
+ }
57
+ if (cur.fields.length) pages.push(cur);
58
+ return pages;
59
+ }
45
60
  function resetTurnstile() {
46
61
  const t = globalThis.turnstile;
47
62
  if (t) {
@@ -66,6 +81,37 @@ function BcmsForm({
66
81
  });
67
82
  const [status, setStatus] = useState({ state: "idle" });
68
83
  const [fieldErrors, setFieldErrors] = useState({});
84
+ const pages = useMemo(() => splitSteps(form.fields), [form.fields]);
85
+ const [enhanced, setEnhanced] = useState(false);
86
+ const [step, setStep] = useState(0);
87
+ const pageRefs = useRef([]);
88
+ const headingRefs = useRef([]);
89
+ const moveFocus = useRef(false);
90
+ const last = pages ? pages.length - 1 : 0;
91
+ const paged = Boolean(pages && enhanced);
92
+ useEffect(() => setEnhanced(true), []);
93
+ useEffect(() => {
94
+ if (!moveFocus.current) return;
95
+ moveFocus.current = false;
96
+ headingRefs.current[step]?.focus();
97
+ }, [step]);
98
+ function pageInvalid(i) {
99
+ const controls = pageRefs.current[i]?.querySelectorAll("input,select,textarea") ?? [];
100
+ for (const c of controls) {
101
+ if (!c.checkValidity()) {
102
+ c.reportValidity();
103
+ return true;
104
+ }
105
+ }
106
+ return false;
107
+ }
108
+ function goTo(i) {
109
+ moveFocus.current = true;
110
+ setStep(i);
111
+ }
112
+ function next() {
113
+ if (!pageInvalid(step)) goTo(step + 1);
114
+ }
69
115
  useEffect(() => {
70
116
  if (!showTurnstile || document.querySelector(`script[src="${TURNSTILE_SRC}"]`)) return;
71
117
  const script = document.createElement("script");
@@ -75,12 +121,13 @@ function BcmsForm({
75
121
  document.head.appendChild(script);
76
122
  }, [showTurnstile]);
77
123
  const visibleFields = useMemo(
78
- () => form.fields.filter((f) => f.type !== "hidden" && shouldShowField(f, values)),
124
+ () => form.fields.filter((f) => f.type !== "hidden" && f.type !== "step" && shouldShowField(f, values)),
79
125
  [form.fields, values]
80
126
  );
81
127
  const setValue = (key, value) => setValues((prev) => ({ ...prev, [key]: value }));
82
128
  async function handleSubmit(e) {
83
129
  e.preventDefault();
130
+ if (paged && step < last) return next();
84
131
  setStatus({ state: "submitting" });
85
132
  setFieldErrors({});
86
133
  const tokenEl = e.currentTarget.querySelector(
@@ -89,6 +136,7 @@ function BcmsForm({
89
136
  const turnstileToken = tokenEl?.value || void 0;
90
137
  const data = {};
91
138
  for (const field of form.fields) {
139
+ if (field.type === "step") continue;
92
140
  if (field.type === "hidden" || shouldShowField(field, values)) {
93
141
  data[field.key] = values[field.key];
94
142
  }
@@ -104,6 +152,7 @@ function BcmsForm({
104
152
  const reset = formInitialValues(form);
105
153
  if (form.honeypotField) reset[form.honeypotField] = "";
106
154
  setValues(reset);
155
+ setStep(0);
107
156
  setStatus({
108
157
  state: "success",
109
158
  message: form.successMessage ?? "Thanks! Your submission was received."
@@ -111,7 +160,11 @@ function BcmsForm({
111
160
  resetTurnstile();
112
161
  } catch (err) {
113
162
  const e2 = err;
114
- if (e2.fieldErrors) setFieldErrors(e2.fieldErrors);
163
+ if (e2.fieldErrors) {
164
+ setFieldErrors(e2.fieldErrors);
165
+ const bad = pages?.findIndex((p) => p.fields.some((f) => e2.fieldErrors?.[f.key]));
166
+ if (bad !== void 0 && bad >= 0) goTo(bad);
167
+ }
115
168
  setStatus({ state: "error", message: e2.message || "Something went wrong. Please try again." });
116
169
  resetTurnstile();
117
170
  }
@@ -121,7 +174,59 @@ function BcmsForm({
121
174
  // preview frame and patch its copy live. Valued rather than bare: the runtime
122
175
  // selectors are `form[data-bcms-form]`, which a valued attribute still matches.
123
176
  /* @__PURE__ */ jsxs("form", { className, onSubmit: handleSubmit, noValidate: true, "data-bcms-form": form.id, children: [
124
- visibleFields.map((field) => /* @__PURE__ */ jsx(
177
+ pages ? pages.map((page, i) => /* @__PURE__ */ jsxs(
178
+ "fieldset",
179
+ {
180
+ ref: (el) => {
181
+ pageRefs.current[i] = el;
182
+ },
183
+ className: "bcms-step",
184
+ "data-bcms-step": i + 1,
185
+ hidden: paged && i !== step,
186
+ "aria-labelledby": `bcms-step-${form.id}-${i + 1}`,
187
+ children: [
188
+ /* @__PURE__ */ jsxs(
189
+ "h3",
190
+ {
191
+ id: `bcms-step-${form.id}-${i + 1}`,
192
+ className: "bcms-step-title",
193
+ tabIndex: -1,
194
+ ref: (el) => {
195
+ headingRefs.current[i] = el;
196
+ },
197
+ "data-bcms-form-field": page.key ?? void 0,
198
+ children: [
199
+ /* @__PURE__ */ jsxs("span", { className: "bcms-step-count", children: [
200
+ "Step ",
201
+ i + 1,
202
+ " of ",
203
+ pages.length
204
+ ] }),
205
+ page.label ? /* @__PURE__ */ jsxs(Fragment, { children: [
206
+ " ",
207
+ /* @__PURE__ */ jsx("span", { "data-bcms-form-part": "label", children: page.label })
208
+ ] }) : null
209
+ ]
210
+ }
211
+ ),
212
+ visibleFields.filter((f) => page.fields.includes(f)).map((field) => /* @__PURE__ */ jsx(
213
+ Field,
214
+ {
215
+ field,
216
+ value: values[field.key],
217
+ error: fieldErrors[field.key],
218
+ onChange: (v) => setValue(field.key, v)
219
+ },
220
+ field.key
221
+ )),
222
+ paged ? /* @__PURE__ */ jsxs("div", { className: "bcms-step-nav", children: [
223
+ i > 0 ? /* @__PURE__ */ jsx("button", { type: "button", "data-bcms-step-back": true, onClick: () => goTo(i - 1), children: "Back" }) : null,
224
+ i < last ? /* @__PURE__ */ jsx("button", { type: "button", "data-bcms-step-next": true, onClick: next, children: "Next" }) : null
225
+ ] }) : null
226
+ ]
227
+ },
228
+ page.key ?? "first"
229
+ )) : visibleFields.map((field) => /* @__PURE__ */ jsx(
125
230
  Field,
126
231
  {
127
232
  field,
@@ -143,7 +248,16 @@ function BcmsForm({
143
248
  }
144
249
  ) }) : null,
145
250
  showTurnstile ? /* @__PURE__ */ jsx("div", { className: "cf-turnstile", "data-sitekey": turnstileSiteKey }) : null,
146
- /* @__PURE__ */ jsx("button", { type: "submit", disabled: status.state === "submitting", "data-bcms-form-part": "submit", children: status.state === "submitting" ? "Submitting\u2026" : form.submitLabel ?? "Submit" }),
251
+ /* @__PURE__ */ jsx(
252
+ "button",
253
+ {
254
+ type: "submit",
255
+ hidden: paged && step < last,
256
+ disabled: status.state === "submitting",
257
+ "data-bcms-form-part": "submit",
258
+ children: status.state === "submitting" ? "Submitting\u2026" : form.submitLabel ?? "Submit"
259
+ }
260
+ ),
147
261
  status.message ? /* @__PURE__ */ jsx(
148
262
  "p",
149
263
  {
@@ -310,16 +424,20 @@ function bindProps(id, entries, ctx) {
310
424
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
311
425
  var MAX_COMPONENT_DEPTH = 10;
312
426
  function BcmsBlocks({
313
- blocks,
427
+ blocks: all,
314
428
  forms,
315
429
  components,
316
430
  turnstileSiteKey,
317
431
  apiBase,
318
- className
432
+ className,
433
+ now,
434
+ showScheduled
319
435
  }) {
320
436
  const formsById = new Map((forms ?? []).map((f) => [f.id, f]));
321
437
  const componentsById = new Map((components ?? []).map((cmp) => [cmp.id, cmp]));
322
- const ctx = { formsById, componentsById, turnstileSiteKey, apiBase, depth: 0, seen: /* @__PURE__ */ new Set() };
438
+ const at = now ?? /* @__PURE__ */ new Date();
439
+ const blocks = showScheduled ? all : visibleBlocks(all, at);
440
+ const ctx = { formsById, componentsById, turnstileSiteKey, apiBase, depth: 0, seen: /* @__PURE__ */ new Set(), now: at, showScheduled };
323
441
  return (
324
442
  /**
325
443
  * 🔴 `data-bcms-page` marks THIS SUBTREE as ours to edit — the SDK's equivalent of the hosted
@@ -335,7 +453,10 @@ function BcmsBlocks({
335
453
  * NO blocks yet is still recognised — otherwise there is no way to add the first section,
336
454
  * which is exactly the state a new page starts in.
337
455
  */
338
- /* @__PURE__ */ jsx2("div", { className, "data-bcms-page": "", children: blocks.map((block) => tagTopLevel(block, renderBlock(block, ctx))) })
456
+ /* @__PURE__ */ jsxs2("div", { className, "data-bcms-page": "", children: [
457
+ usesHideOn([blocks, components]) && /* @__PURE__ */ jsx2("style", { dangerouslySetInnerHTML: { __html: RESPONSIVE_CSS } }),
458
+ blocks.map((block) => tagTopLevel(block, renderBlock(block, ctx)))
459
+ ] })
339
460
  );
340
461
  }
341
462
  function tagTopLevel(block, node) {
@@ -345,14 +466,21 @@ function tagTopLevel(block, node) {
345
466
  });
346
467
  }
347
468
  function renderBlock(block, ctx) {
469
+ const node = renderStyledBlock(block, ctx);
470
+ const state = ctx.showScheduled && ctx.now ? windowState(block, ctx.now) : "live";
471
+ if (state === "live" || !isValidElement(node)) return node;
472
+ return cloneElement(node, { "data-bcms-window": state });
473
+ }
474
+ function renderStyledBlock(block, ctx) {
348
475
  const node = renderBlockInner(block, ctx);
349
476
  const own = block.type === "button" ? splitButtonStyle(block.style).placement : block.style;
350
477
  const style = blockStyleToCss(own);
351
478
  const inner = blockStyleToInnerCss(own);
352
- if (!style && !inner || node == null) return node;
479
+ const hide = hideOnAttr(own);
480
+ if (!style && !inner && !hide || node == null) return node;
353
481
  const column = inner ? /* @__PURE__ */ jsx2("div", { className: "bcms-block-inner", style: inner, children: node }, block.id) : node;
354
- if (!style) return column;
355
- return /* @__PURE__ */ jsx2("div", { style, children: column }, block.id);
482
+ if (!style && !hide) return column;
483
+ return /* @__PURE__ */ jsx2("div", { style, ...hide ? { "data-bcms-hide": hide } : {}, children: column }, block.id);
356
484
  }
357
485
  function renderBlockInner(block, ctx) {
358
486
  switch (block.type) {
@@ -626,6 +754,7 @@ function renderComponent({ id, props }, ctx) {
626
754
  if (!def) return renderMissingComponent(props.componentId, ctx);
627
755
  if (ctx.depth >= MAX_COMPONENT_DEPTH || ctx.seen.has(def.id)) return null;
628
756
  const resolved = applyOverrides(def.blockJson, withButtonLookProps(def), props.overrides ?? {});
757
+ const shown = ctx.showScheduled || !ctx.now ? resolved : visibleBlocks(resolved, ctx.now);
629
758
  const childCtx = {
630
759
  ...ctx,
631
760
  depth: ctx.depth + 1,
@@ -640,7 +769,7 @@ function renderComponent({ id, props }, ctx) {
640
769
  "data-bcms-component": def.id,
641
770
  "data-bcms-variant": "base",
642
771
  "data-bcms-instance": id,
643
- children: resolved.map((child) => renderBlock(child, childCtx))
772
+ children: shown.map((child) => renderBlock(child, childCtx))
644
773
  },
645
774
  id
646
775
  );