@orion-studios/cms 0.5.6 → 0.5.8

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/README.md CHANGED
@@ -41,7 +41,14 @@ Requires Next.js (App Router), React 19, Zod 3. Supabase only in supabase mode.
41
41
  import { createCmsRoutes } from '@orion-studios/cms/server'
42
42
  import { registry } from '@/cms/registry'
43
43
  export const { GET, POST, PATCH, DELETE } = createCmsRoutes({
44
- registry, syncToken: process.env.CMS_SYNC_TOKEN,
44
+ registry,
45
+ syncToken: process.env.CMS_SYNC_TOKEN,
46
+ cronToken: process.env.CRON_SECRET,
47
+ previewSecret: process.env.CMS_PREVIEW_SECRET,
48
+ previewKeyId: process.env.CMS_PREVIEW_KEY_ID,
49
+ analyticsSecret: process.env.CMS_ANALYTICS_HASH_SECRET,
50
+ autoReplyHashSecret: process.env.CMS_AUTO_REPLY_HASH_SECRET,
51
+ projectRef: process.env.CMS_EXPECTED_SUPABASE_PROJECT_REF,
45
52
  })
46
53
 
47
54
  // src/app/studio/page.tsx — the admin
@@ -75,10 +82,20 @@ own role, never delete yourself.
75
82
  | Variable | Where | Purpose |
76
83
  | --- | --- | --- |
77
84
  | `NEXT_PUBLIC_SUPABASE_URL` | local + Vercel | project URL (also selects supabase mode) |
78
- | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | local + Vercel | public reads (RLS: published content only) |
79
- | `SUPABASE_SERVICE_ROLE_KEY` | local + Vercel | server-only; powers the API layer |
80
- | `DATABASE_URL` | local + Vercel | Postgres for bootstrap (use the transaction pooler on Vercel) |
85
+ | `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` | local + Vercel | public reads (RLS: published content only) |
86
+ | `SUPABASE_SECRET_KEY` | local + Vercel | server-only API access; the legacy service-role alias remains supported |
87
+ | `CMS_EXPECTED_SUPABASE_PROJECT_REF` | local + Vercel | binds the project URL, server credential, previews, and operator database target |
88
+ | `CMS_DATABASE_URL` | local operator only | direct or port 5432 session connection for bootstrap and migrations |
89
+ | `CMS_EXPECTED_DATABASE_ROLE` | local operator only | dedicated CMS migration role with no `app` schema access |
90
+ | `CMS_EXPECTED_DATABASE_NAME` | local operator only | exact database name required by the target guard |
91
+ | `CMS_DATABASE_CONFIRMATION` | local operator only | exact redacted target-receipt hash required before database writes |
92
+ | `CMS_PREVIEW_SECRET` | local + Vercel | signs short-lived, revocable preview grants |
93
+ | `CMS_PREVIEW_KEY_ID` | local + Vercel | non-secret identifier bound into preview grants |
94
+ | `CMS_PREVIEW_PREVIOUS_SECRET`, `CMS_PREVIEW_PREVIOUS_KEY_ID`, `CMS_PREVIEW_PREVIOUS_VALID_UNTIL` | local + Vercel | optional bounded preview-key rotation overlap |
95
+ | `CMS_ANALYTICS_HASH_SECRET` | local + Vercel | hashes analytics session and visitor identifiers |
96
+ | `CMS_AUTO_REPLY_HASH_SECRET` | local + Vercel | hashes form auto-reply rate-limit keys |
81
97
  | `CMS_SYNC_TOKEN` | local + Vercel | authorizes `POST /api/cms/sync` |
98
+ | `CRON_SECRET` | local + Vercel | authorizes scheduled publication only |
82
99
  | `CMS_STATIC=true` | optional | static mode (no CMS) |
83
100
  | `CMS_MEMORY=true` | optional | force memory mode |
84
101
 
@@ -1,3 +1,5 @@
1
+ declare const ANALYTICS_READY_EVENT = "orion-analytics-ready";
2
+
1
3
  /**
2
4
  * The site-side analytics tracker: small, first-party, and zero dependency.
3
5
  * Mount <Analytics /> once in the site layout. It captures:
@@ -50,4 +52,4 @@ declare function AnalyticsNotFound(): null;
50
52
  /** Programmatic tracking for custom site components. No-op when analytics is off. */
51
53
  declare function trackEvent(name: string, meta?: Record<string, unknown>): void;
52
54
 
53
- export { ANALYTICS_CONSENT_COOKIE, ANALYTICS_CONSENT_EVENT, ANALYTICS_VISITOR_COOKIE, Analytics, type AnalyticsConsent, AnalyticsNotFound, type AnalyticsProps, EXCLUDE_FLAG, getAnalyticsConsent, setAnalyticsConsent, trackEvent };
55
+ export { ANALYTICS_CONSENT_COOKIE, ANALYTICS_CONSENT_EVENT, ANALYTICS_READY_EVENT, ANALYTICS_VISITOR_COOKIE, Analytics, type AnalyticsConsent, AnalyticsNotFound, type AnalyticsProps, EXCLUDE_FLAG, getAnalyticsConsent, setAnalyticsConsent, trackEvent };
@@ -1,5 +1,8 @@
1
1
  'use client';
2
2
  "use client";
3
+ import {
4
+ ANALYTICS_READY_EVENT
5
+ } from "../chunk-DPKXKG2S.js";
3
6
 
4
7
  // src/analytics/react.tsx
5
8
  import { useEffect, useRef, useState } from "react";
@@ -105,6 +108,7 @@ function Analytics({
105
108
  track({ type, name, path: window.location.pathname, meta });
106
109
  if (type === "form" || type === "not_found") flush();
107
110
  };
111
+ window.dispatchEvent(new Event(ANALYTICS_READY_EVENT));
108
112
  const onClick = (event) => {
109
113
  const target = event.target;
110
114
  const tagged = target?.closest("[data-analytics]");
@@ -185,6 +189,7 @@ function trackEvent(name, meta) {
185
189
  export {
186
190
  ANALYTICS_CONSENT_COOKIE,
187
191
  ANALYTICS_CONSENT_EVENT,
192
+ ANALYTICS_READY_EVENT,
188
193
  ANALYTICS_VISITOR_COOKIE,
189
194
  Analytics,
190
195
  AnalyticsNotFound,
@@ -0,0 +1,8 @@
1
+ 'use client';
2
+
3
+ // src/analytics/constants.ts
4
+ var ANALYTICS_READY_EVENT = "orion-analytics-ready";
5
+
6
+ export {
7
+ ANALYTICS_READY_EVENT
8
+ };
@@ -1,5 +1,21 @@
1
1
  // src/content/index.ts
2
2
  import { createClient } from "@supabase/supabase-js";
3
+
4
+ // src/supabase-keys.ts
5
+ var isServerSupabaseKey = (key) => {
6
+ if (key.startsWith("sb_secret_")) return true;
7
+ const payload = key.split(".")[1];
8
+ if (!payload) return false;
9
+ try {
10
+ const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
11
+ const decoded = JSON.parse(atob(normalized));
12
+ return decoded.role === "service_role";
13
+ } catch {
14
+ return false;
15
+ }
16
+ };
17
+
18
+ // src/content/index.ts
3
19
  var CONTENT_CACHE_TAG = "orion-content";
4
20
  var toPublicPage = (row) => ({
5
21
  id: String(row.id),
@@ -11,12 +27,15 @@ var toPublicPage = (row) => ({
11
27
  });
12
28
  function createContentClient(options = {}) {
13
29
  const supabaseUrl = options.supabaseUrl || process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL || "";
14
- const anonKey = options.anonKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.SUPABASE_PUBLISHABLE_KEY || "";
30
+ const anonKey = options.anonKey || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_PUBLISHABLE_KEY || process.env.SUPABASE_ANON_KEY || "";
15
31
  if (!supabaseUrl || !anonKey) {
16
32
  throw new Error(
17
33
  "Orion CMS: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY (or NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY) must be set."
18
34
  );
19
35
  }
36
+ if (isServerSupabaseKey(anonKey)) {
37
+ throw new Error("Orion CMS: a Supabase server key cannot be used for public content reads.");
38
+ }
20
39
  const client = createClient(supabaseUrl, anonKey, {
21
40
  auth: { persistSession: false, autoRefreshToken: false }
22
41
  });
@@ -1,7 +1,16 @@
1
1
  'use client';
2
+ import {
3
+ ANALYTICS_READY_EVENT
4
+ } from "./chunk-DPKXKG2S.js";
2
5
 
3
6
  // src/forms/react.tsx
4
- import { useEffect, useMemo, useRef, useState } from "react";
7
+ import {
8
+ useCallback,
9
+ useEffect,
10
+ useMemo,
11
+ useRef,
12
+ useState
13
+ } from "react";
5
14
 
6
15
  // src/forms/validation.ts
7
16
  var MAJOR_EMAIL_PROVIDERS = [
@@ -248,8 +257,135 @@ function getAutoReplyEmailFields(config) {
248
257
  return [...fieldTypes.entries()].filter(([, types]) => types.size === 1 && types.has("email")).map(([name]) => name);
249
258
  }
250
259
 
260
+ // src/forms/funnel.ts
261
+ var FORM_VIEW_MIN_VISIBLE_RATIO = 0.75;
262
+ var FORM_VIEW_DWELL_MS = 2e3;
263
+ function createFormFunnelSession(trackStage) {
264
+ const trackedStages = /* @__PURE__ */ new Set();
265
+ const trackOnce = (stage) => {
266
+ if (trackedStages.has(stage) || !trackStage(stage)) return false;
267
+ trackedStages.add(stage);
268
+ return true;
269
+ };
270
+ return {
271
+ hasViewed: () => trackedStages.has("viewed"),
272
+ trackStarted: () => trackOnce("start"),
273
+ trackViewed: () => trackOnce("viewed")
274
+ };
275
+ }
276
+ function isQualifiedFormVisibility(intersectionRatio, isIntersecting, visibilityState) {
277
+ return isIntersecting && intersectionRatio >= FORM_VIEW_MIN_VISIBLE_RATIO && visibilityState === "visible";
278
+ }
279
+
251
280
  // src/forms/react.tsx
252
281
  import { jsx, jsxs } from "react/jsx-runtime";
282
+ var analyticsTrack = () => window.__orionTrack;
283
+ var isActionableFieldTarget = (target) => {
284
+ if (!(target instanceof Element)) return false;
285
+ const directField = target.closest("input, select, textarea");
286
+ const labelledField = target.closest("label")?.control;
287
+ const field = directField || labelledField;
288
+ if (!(field instanceof HTMLInputElement || field instanceof HTMLSelectElement || field instanceof HTMLTextAreaElement) || field.name === HONEYPOT_FIELD_NAME || field.disabled || "readOnly" in field && field.readOnly) {
289
+ return false;
290
+ }
291
+ return true;
292
+ };
293
+ function useFormFunnel(slug, options = {}) {
294
+ const { preview = false } = options;
295
+ const [attentionTarget, setAttentionTarget] = useState(null);
296
+ const visibleRef = useRef(false);
297
+ const timerRef = useRef(null);
298
+ const clearViewTimer = useCallback(() => {
299
+ if (timerRef.current === null) return;
300
+ window.clearTimeout(timerRef.current);
301
+ timerRef.current = null;
302
+ }, []);
303
+ const trackStage = useCallback(
304
+ (stage) => {
305
+ if (preview || typeof window === "undefined") return false;
306
+ const track = analyticsTrack();
307
+ if (!track) return false;
308
+ track("form", `${slug}:${stage}`);
309
+ return true;
310
+ },
311
+ [preview, slug]
312
+ );
313
+ const session = useMemo(() => createFormFunnelSession(trackStage), [trackStage]);
314
+ const trackViewed = useCallback(() => {
315
+ if (session.trackViewed()) clearViewTimer();
316
+ }, [clearViewTimer, session]);
317
+ const startViewTimer = useCallback(() => {
318
+ if (session.hasViewed() || timerRef.current !== null || !visibleRef.current || document.visibilityState !== "visible" || !analyticsTrack()) {
319
+ return;
320
+ }
321
+ timerRef.current = window.setTimeout(() => {
322
+ timerRef.current = null;
323
+ if (visibleRef.current && document.visibilityState === "visible") trackViewed();
324
+ }, FORM_VIEW_DWELL_MS);
325
+ }, [session, trackViewed]);
326
+ useEffect(() => {
327
+ visibleRef.current = false;
328
+ clearViewTimer();
329
+ if (preview || !attentionTarget || typeof IntersectionObserver === "undefined") return;
330
+ const observer = new IntersectionObserver(
331
+ ([entry]) => {
332
+ visibleRef.current = Boolean(
333
+ entry && entry.isIntersecting && entry.intersectionRatio >= FORM_VIEW_MIN_VISIBLE_RATIO
334
+ );
335
+ if (entry && isQualifiedFormVisibility(
336
+ entry.intersectionRatio,
337
+ entry.isIntersecting,
338
+ document.visibilityState
339
+ )) {
340
+ startViewTimer();
341
+ } else clearViewTimer();
342
+ },
343
+ { threshold: FORM_VIEW_MIN_VISIBLE_RATIO }
344
+ );
345
+ const onVisibilityChange = () => {
346
+ if (document.visibilityState !== "visible") {
347
+ clearViewTimer();
348
+ return;
349
+ }
350
+ if (visibleRef.current) startViewTimer();
351
+ };
352
+ const onAnalyticsReady = () => {
353
+ if (visibleRef.current) startViewTimer();
354
+ };
355
+ observer.observe(attentionTarget);
356
+ document.addEventListener("visibilitychange", onVisibilityChange);
357
+ window.addEventListener(ANALYTICS_READY_EVENT, onAnalyticsReady);
358
+ return () => {
359
+ observer.disconnect();
360
+ document.removeEventListener("visibilitychange", onVisibilityChange);
361
+ window.removeEventListener(ANALYTICS_READY_EVENT, onAnalyticsReady);
362
+ clearViewTimer();
363
+ };
364
+ }, [attentionTarget, clearViewTimer, preview, slug, startViewTimer]);
365
+ const onFieldPointerDownCapture = useCallback(
366
+ (event) => {
367
+ if (!event.nativeEvent.isTrusted || !isActionableFieldTarget(event.target)) return;
368
+ trackViewed();
369
+ },
370
+ [trackViewed]
371
+ );
372
+ const onFieldKeyDownCapture = useCallback(
373
+ (event) => {
374
+ if (!event.nativeEvent.isTrusted || !isActionableFieldTarget(event.target)) return;
375
+ trackViewed();
376
+ },
377
+ [trackViewed]
378
+ );
379
+ const trackStarted = useCallback(() => {
380
+ session.trackStarted();
381
+ }, [session]);
382
+ return {
383
+ attentionRef: setAttentionTarget,
384
+ onFieldKeyDownCapture,
385
+ onFieldPointerDownCapture,
386
+ trackStarted
387
+ };
388
+ }
253
389
  var DEFAULT_CLASSES = {
254
390
  form: "ocf-form",
255
391
  field: "ocf-field",
@@ -291,26 +427,17 @@ function FormRenderer({
291
427
  const [state, setState] = useState("idle");
292
428
  const [honeypot, setHoneypot] = useState("");
293
429
  const renderedAt = useRef(Date.now());
294
- const startedRef = useRef(false);
295
- const funnel = (stage) => {
296
- if (preview || typeof window === "undefined") return;
297
- window.__orionTrack?.(
298
- "form",
299
- `${slug}:${stage}`
300
- );
301
- };
302
- useEffect(() => {
303
- const timer = setTimeout(() => funnel("view"), 100);
304
- return () => clearTimeout(timer);
305
- }, [slug]);
430
+ const {
431
+ attentionRef,
432
+ onFieldKeyDownCapture,
433
+ onFieldPointerDownCapture,
434
+ trackStarted
435
+ } = useFormFunnel(slug, { preview });
306
436
  useEffect(() => {
307
437
  setStepIndex((current) => Math.min(current, Math.max(steps.length - 1, 0)));
308
438
  }, [steps.length]);
309
439
  const setValue = (name, next, type) => {
310
- if (!startedRef.current) {
311
- startedRef.current = true;
312
- funnel("start");
313
- }
440
+ trackStarted();
314
441
  setValues((current) => ({
315
442
  ...current,
316
443
  [name]: type === "phone" && typeof next === "string" ? formatPhoneUS(next) : next
@@ -393,6 +520,7 @@ function FormRenderer({
393
520
  }
394
521
  const isLastStep = stepIndex >= steps.length - 1;
395
522
  const visibleSteps = steps.length > 0 ? [steps[stepIndex]] : [];
523
+ const firstActionableFieldKey = visibleSteps.flatMap((step) => step.fields || []).map((field, index) => ({ field, key: fieldKey(field, index) })).find(({ field }) => inferFieldType(field) !== "hidden")?.key;
396
524
  const renderField = (field, index) => {
397
525
  const name = fieldKey(field, index);
398
526
  const type = inferFieldType(field);
@@ -402,15 +530,23 @@ function FormRenderer({
402
530
  const id = `ocf-${slug}-${name}`;
403
531
  const value = values[name];
404
532
  if (type === "hidden") return null;
405
- const wrap = (control) => /* @__PURE__ */ jsxs("div", { className: `${cls.field}${error ? ` ${cls.fieldError}` : ""}`, children: [
406
- type !== "checkbox" || options.length > 0 ? /* @__PURE__ */ jsxs("label", { className: cls.label, htmlFor: id, children: [
407
- label,
408
- field.required ? " *" : ""
409
- ] }) : null,
410
- control,
411
- field.help ? /* @__PURE__ */ jsx("p", { className: "ocf-help", children: field.help }) : null,
412
- error ? /* @__PURE__ */ jsx("p", { className: cls.errorText, role: "alert", children: error }) : null
413
- ] }, name);
533
+ const wrap = (control) => /* @__PURE__ */ jsxs(
534
+ "div",
535
+ {
536
+ className: `${cls.field}${error ? ` ${cls.fieldError}` : ""}`,
537
+ ref: name === firstActionableFieldKey ? attentionRef : void 0,
538
+ children: [
539
+ type !== "checkbox" || options.length > 0 ? /* @__PURE__ */ jsxs("label", { className: cls.label, htmlFor: id, children: [
540
+ label,
541
+ field.required ? " *" : ""
542
+ ] }) : null,
543
+ control,
544
+ field.help ? /* @__PURE__ */ jsx("p", { className: "ocf-help", children: field.help }) : null,
545
+ error ? /* @__PURE__ */ jsx("p", { className: cls.errorText, role: "alert", children: error }) : null
546
+ ]
547
+ },
548
+ name
549
+ );
414
550
  if (type === "textarea") {
415
551
  return wrap(
416
552
  /* @__PURE__ */ jsx(
@@ -480,22 +616,30 @@ function FormRenderer({
480
616
  );
481
617
  }
482
618
  if (type === "checkbox") {
483
- return /* @__PURE__ */ jsxs("div", { className: `${cls.field}${error ? ` ${cls.fieldError}` : ""}`, children: [
484
- /* @__PURE__ */ jsxs("label", { className: cls.checkbox, htmlFor: id, children: [
485
- /* @__PURE__ */ jsx(
486
- "input",
487
- {
488
- checked: value === true,
489
- id,
490
- onChange: (event) => setValue(name, event.target.checked, type),
491
- type: "checkbox"
492
- }
493
- ),
494
- label,
495
- field.required ? " *" : ""
496
- ] }),
497
- error ? /* @__PURE__ */ jsx("p", { className: cls.errorText, role: "alert", children: error }) : null
498
- ] }, name);
619
+ return /* @__PURE__ */ jsxs(
620
+ "div",
621
+ {
622
+ className: `${cls.field}${error ? ` ${cls.fieldError}` : ""}`,
623
+ ref: name === firstActionableFieldKey ? attentionRef : void 0,
624
+ children: [
625
+ /* @__PURE__ */ jsxs("label", { className: cls.checkbox, htmlFor: id, children: [
626
+ /* @__PURE__ */ jsx(
627
+ "input",
628
+ {
629
+ checked: value === true,
630
+ id,
631
+ onChange: (event) => setValue(name, event.target.checked, type),
632
+ type: "checkbox"
633
+ }
634
+ ),
635
+ label,
636
+ field.required ? " *" : ""
637
+ ] }),
638
+ error ? /* @__PURE__ */ jsx("p", { className: cls.errorText, role: "alert", children: error }) : null
639
+ ]
640
+ },
641
+ name
642
+ );
499
643
  }
500
644
  const inputType = type === "email" ? "email" : type === "date" ? "date" : type === "number" ? "text" : "text";
501
645
  const inputMode = type === "phone" ? "tel" : type === "email" ? "email" : type === "number" ? "decimal" : void 0;
@@ -514,40 +658,51 @@ function FormRenderer({
514
658
  )
515
659
  );
516
660
  };
517
- return /* @__PURE__ */ jsxs("form", { className: cls.form, noValidate: true, onSubmit: submit, children: [
518
- title,
519
- intro,
520
- /* @__PURE__ */ jsx(
521
- "input",
522
- {
523
- "aria-hidden": "true",
524
- autoComplete: "off",
525
- className: "ocf-honeypot",
526
- name: HONEYPOT_FIELD_NAME,
527
- onChange: (event) => setHoneypot(event.target.value),
528
- style: { position: "absolute", left: "-9999px", height: 0, width: 0, opacity: 0 },
529
- tabIndex: -1,
530
- value: honeypot
531
- }
532
- ),
533
- steps.length > 1 ? /* @__PURE__ */ jsxs("p", { className: cls.stepTitle, children: [
534
- "Step ",
535
- stepIndex + 1,
536
- " of ",
537
- steps.length,
538
- steps[stepIndex]?.title ? ` \u2014 ${steps[stepIndex].title}` : ""
539
- ] }) : null,
540
- visibleSteps.map((step) => (step.fields || []).map(renderField)),
541
- state === "error" ? /* @__PURE__ */ jsx("p", { className: cls.formError, role: "alert", children: "The request could not be sent. Please try again." }) : null,
542
- /* @__PURE__ */ jsxs("div", { className: "ocf-actions", children: [
543
- steps.length > 1 && stepIndex > 0 ? /* @__PURE__ */ jsx("button", { className: cls.button, onClick: () => setStepIndex(stepIndex - 1), type: "button", children: "Back" }) : null,
544
- !isLastStep ? /* @__PURE__ */ jsx("button", { className: cls.button, onClick: nextStep, type: "button", children: "Next" }) : /* @__PURE__ */ jsx("button", { className: cls.button, disabled: state === "submitting" || preview, type: "submit", children: state === "submitting" ? "Sending\u2026" : submitLabel })
545
- ] })
546
- ] });
661
+ return /* @__PURE__ */ jsxs(
662
+ "form",
663
+ {
664
+ className: cls.form,
665
+ noValidate: true,
666
+ onKeyDownCapture: onFieldKeyDownCapture,
667
+ onPointerDownCapture: onFieldPointerDownCapture,
668
+ onSubmit: submit,
669
+ children: [
670
+ title,
671
+ intro,
672
+ /* @__PURE__ */ jsx(
673
+ "input",
674
+ {
675
+ "aria-hidden": "true",
676
+ autoComplete: "off",
677
+ className: "ocf-honeypot",
678
+ name: HONEYPOT_FIELD_NAME,
679
+ onChange: (event) => setHoneypot(event.target.value),
680
+ style: { position: "absolute", left: "-9999px", height: 0, width: 0, opacity: 0 },
681
+ tabIndex: -1,
682
+ value: honeypot
683
+ }
684
+ ),
685
+ steps.length > 1 ? /* @__PURE__ */ jsxs("p", { className: cls.stepTitle, children: [
686
+ "Step ",
687
+ stepIndex + 1,
688
+ " of ",
689
+ steps.length,
690
+ steps[stepIndex]?.title ? ` \u2014 ${steps[stepIndex].title}` : ""
691
+ ] }) : null,
692
+ visibleSteps.map((step) => (step.fields || []).map(renderField)),
693
+ state === "error" ? /* @__PURE__ */ jsx("p", { className: cls.formError, role: "alert", children: "The request could not be sent. Please try again." }) : null,
694
+ /* @__PURE__ */ jsxs("div", { className: "ocf-actions", children: [
695
+ steps.length > 1 && stepIndex > 0 ? /* @__PURE__ */ jsx("button", { className: cls.button, onClick: () => setStepIndex(stepIndex - 1), type: "button", children: "Back" }) : null,
696
+ !isLastStep ? /* @__PURE__ */ jsx("button", { className: cls.button, onClick: nextStep, type: "button", children: "Next" }) : /* @__PURE__ */ jsx("button", { className: cls.button, disabled: state === "submitting" || preview, type: "submit", children: state === "submitting" ? "Sending\u2026" : submitLabel })
697
+ ] })
698
+ ]
699
+ }
700
+ );
547
701
  }
548
702
 
549
703
  export {
550
704
  FORM_FIELD_TYPES,
551
705
  getAutoReplyEmailFields,
706
+ useFormFunnel,
552
707
  FormRenderer
553
708
  };
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  CONTENT_CACHE_TAG,
3
3
  createContentClient
4
- } from "../chunk-AYV6KYDP.js";
4
+ } from "../chunk-NSAZCP4I.js";
5
5
  export {
6
6
  CONTENT_CACHE_TAG,
7
7
  createContentClient
@@ -1,7 +1,17 @@
1
1
  import * as react from 'react';
2
- import { ReactNode } from 'react';
2
+ import { KeyboardEvent, PointerEvent, ReactNode } from 'react';
3
3
  import { F as FormConfig } from '../submission-CKZgx1h7.js';
4
4
 
5
+ type FormFunnelOptions = {
6
+ preview?: boolean;
7
+ };
8
+ type FormFunnelTracking = {
9
+ attentionRef: (node: HTMLElement | null) => void;
10
+ onFieldKeyDownCapture: (event: KeyboardEvent<HTMLElement>) => void;
11
+ onFieldPointerDownCapture: (event: PointerEvent<HTMLElement>) => void;
12
+ trackStarted: () => void;
13
+ };
14
+ declare function useFormFunnel(slug: string, options?: FormFunnelOptions): FormFunnelTracking;
5
15
  /**
6
16
  * The shared, config-driven form renderer: the same component renders a form
7
17
  * in the Studio's form editor preview and on the public site, from the same
@@ -42,4 +52,4 @@ type FormRendererProps = {
42
52
  };
43
53
  declare function FormRenderer({ slug, config, successMessage, basePath, classNames, title, intro, submitLabel, preview, onSuccess, }: FormRendererProps): react.JSX.Element;
44
54
 
45
- export { FormRenderer, type FormRendererClassNames, type FormRendererProps };
55
+ export { type FormFunnelOptions, type FormFunnelTracking, FormRenderer, type FormRendererClassNames, type FormRendererProps, useFormFunnel };
@@ -1,8 +1,11 @@
1
1
  'use client';
2
2
  "use client";
3
3
  import {
4
- FormRenderer
5
- } from "../chunk-ULE565KD.js";
4
+ FormRenderer,
5
+ useFormFunnel
6
+ } from "../chunk-Z52R6XR7.js";
7
+ import "../chunk-DPKXKG2S.js";
6
8
  export {
7
- FormRenderer
9
+ FormRenderer,
10
+ useFormFunnel
8
11
  };