@10x-media/form-builder 0.1.0-beta.3 → 0.1.0-beta.4

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.
Files changed (166) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +2 -2
  3. package/dist/actions/body/serializeBody.d.ts +19 -4
  4. package/dist/actions/body/serializeBody.js +3 -1
  5. package/dist/actions/body/serializeBody.js.map +1 -1
  6. package/dist/actions/builtin/confirmation.js +13 -4
  7. package/dist/actions/builtin/confirmation.js.map +1 -1
  8. package/dist/actions/builtin/emailTeam.js +13 -4
  9. package/dist/actions/builtin/emailTeam.js.map +1 -1
  10. package/dist/actions/builtin/index.d.ts +13 -1
  11. package/dist/actions/builtin/index.js +18 -7
  12. package/dist/actions/builtin/index.js.map +1 -1
  13. package/dist/actions/builtin/signedWebhook.js +2 -16
  14. package/dist/actions/builtin/signedWebhook.js.map +1 -1
  15. package/dist/actions/runActions.js +2 -0
  16. package/dist/actions/runActions.js.map +1 -1
  17. package/dist/aggregation/resolveResultsRequest.d.ts +22 -6
  18. package/dist/aggregation/resolveResultsRequest.js +18 -7
  19. package/dist/aggregation/resolveResultsRequest.js.map +1 -1
  20. package/dist/client/FlowBuilder.js +98 -53
  21. package/dist/client/FlowBuilder.js.map +1 -1
  22. package/dist/client/admin.css +47 -5
  23. package/dist/client/flowAuthoring.js +35 -0
  24. package/dist/client/flowAuthoring.js.map +1 -0
  25. package/dist/client/synthesizeClientField.js +6 -2
  26. package/dist/client/synthesizeClientField.js.map +1 -1
  27. package/dist/collections/formSubmissions.js +47 -9
  28. package/dist/collections/formSubmissions.js.map +1 -1
  29. package/dist/collections/forms.js +180 -34
  30. package/dist/collections/forms.js.map +1 -1
  31. package/dist/conditions/conditionType.js +9 -2
  32. package/dist/conditions/conditionType.js.map +1 -1
  33. package/dist/conditions/normalizeConditions.js +2 -1
  34. package/dist/conditions/normalizeConditions.js.map +1 -1
  35. package/dist/consent/builtin/index.d.ts +11 -1
  36. package/dist/consent/builtin/index.js +15 -5
  37. package/dist/consent/builtin/index.js.map +1 -1
  38. package/dist/consent/builtin/static.js +7 -3
  39. package/dist/consent/builtin/static.js.map +1 -1
  40. package/dist/exports/react.d.ts +10 -6
  41. package/dist/exports/react.js +6 -2
  42. package/dist/exports/rsc.d.ts +6 -1
  43. package/dist/exports/rsc.js +6 -1
  44. package/dist/exports/types.d.ts +4 -4
  45. package/dist/fields/buildFieldBlocks.js +17 -8
  46. package/dist/fields/buildFieldBlocks.js.map +1 -1
  47. package/dist/fields/builtin/consent.js +8 -4
  48. package/dist/fields/builtin/consent.js.map +1 -1
  49. package/dist/fields/builtin/file.js +8 -4
  50. package/dist/fields/builtin/file.js.map +1 -1
  51. package/dist/fields/builtin/index.d.ts +11 -1
  52. package/dist/fields/builtin/index.js +21 -9
  53. package/dist/fields/builtin/index.js.map +1 -1
  54. package/dist/fields/builtin/message.js +26 -0
  55. package/dist/fields/builtin/message.js.map +1 -0
  56. package/dist/fields/builtin/repeater.js +7 -3
  57. package/dist/fields/builtin/repeater.js.map +1 -1
  58. package/dist/fields/builtin/select.js +7 -3
  59. package/dist/fields/builtin/select.js.map +1 -1
  60. package/dist/fields/localizedIf.d.ts +13 -0
  61. package/dist/fields/localizedIf.js +12 -0
  62. package/dist/fields/localizedIf.js.map +1 -0
  63. package/dist/fields/sharedConfig.js +64 -51
  64. package/dist/fields/sharedConfig.js.map +1 -1
  65. package/dist/fields/types.d.ts +7 -2
  66. package/dist/flow/normalizeFlow.js +7 -1
  67. package/dist/flow/normalizeFlow.js.map +1 -1
  68. package/dist/flow/stepLabel.js +15 -0
  69. package/dist/flow/stepLabel.js.map +1 -0
  70. package/dist/form/pollState.d.ts +22 -0
  71. package/dist/form/pollState.js +17 -0
  72. package/dist/form/pollState.js.map +1 -0
  73. package/dist/form/toFormDocument.d.ts +60 -0
  74. package/dist/form/toFormDocument.js +39 -0
  75. package/dist/form/toFormDocument.js.map +1 -0
  76. package/dist/form/types.d.ts +48 -0
  77. package/dist/index.d.ts +69 -18
  78. package/dist/index.js +24 -13
  79. package/dist/index.js.map +1 -1
  80. package/dist/plugin/registerCollections.js +15 -36
  81. package/dist/plugin/registerCollections.js.map +1 -1
  82. package/dist/plugin/uploadsCollection.d.ts +11 -0
  83. package/dist/plugin/uploadsCollection.js +44 -0
  84. package/dist/plugin/uploadsCollection.js.map +1 -0
  85. package/dist/poll/applyPollOptions.js +17 -0
  86. package/dist/poll/applyPollOptions.js.map +1 -0
  87. package/dist/poll/buildPollOptionSourceFields.js +53 -0
  88. package/dist/poll/buildPollOptionSourceFields.js.map +1 -0
  89. package/dist/poll/definePollOptionSource.d.ts +35 -0
  90. package/dist/poll/definePollOptionSource.js +6 -0
  91. package/dist/poll/definePollOptionSource.js.map +1 -0
  92. package/dist/poll/registry.d.ts +16 -0
  93. package/dist/poll/registry.js +16 -0
  94. package/dist/poll/registry.js.map +1 -0
  95. package/dist/poll/resolvePollOptions.d.ts +28 -0
  96. package/dist/poll/resolvePollOptions.js +64 -0
  97. package/dist/poll/resolvePollOptions.js.map +1 -0
  98. package/dist/poll/resolvePollOutcome.d.ts +21 -0
  99. package/dist/poll/resolvePollOutcome.js +36 -0
  100. package/dist/poll/resolvePollOutcome.js.map +1 -0
  101. package/dist/prefill/valuesFromSearchParams.js +1 -1
  102. package/dist/prefill/valuesFromSearchParams.js.map +1 -1
  103. package/dist/presentations/types.d.ts +3 -2
  104. package/dist/react/Form.d.ts +13 -31
  105. package/dist/react/Form.js +96 -83
  106. package/dist/react/Form.js.map +1 -1
  107. package/dist/react/FormContext.d.ts +35 -1
  108. package/dist/react/FormContext.js.map +1 -1
  109. package/dist/react/FormControls.d.ts +55 -0
  110. package/dist/react/FormControls.js +56 -0
  111. package/dist/react/FormControls.js.map +1 -0
  112. package/dist/react/FormResults.d.ts +6 -3
  113. package/dist/react/FormResults.js +37 -29
  114. package/dist/react/FormResults.js.map +1 -1
  115. package/dist/react/FormSteps.d.ts +17 -0
  116. package/dist/react/FormSteps.js +35 -0
  117. package/dist/react/FormSteps.js.map +1 -0
  118. package/dist/react/Poll.d.ts +17 -6
  119. package/dist/react/Poll.js +66 -18
  120. package/dist/react/Poll.js.map +1 -1
  121. package/dist/react/fetchResults.d.ts +1 -1
  122. package/dist/react/fetchResults.js +1 -1
  123. package/dist/react/fetchResults.js.map +1 -1
  124. package/dist/react/renderers/file.js +5 -1
  125. package/dist/react/renderers/file.js.map +1 -1
  126. package/dist/react/renderers/index.js +2 -0
  127. package/dist/react/renderers/index.js.map +1 -1
  128. package/dist/react/renderers/message.js +35 -0
  129. package/dist/react/renderers/message.js.map +1 -0
  130. package/dist/react/state.d.ts +25 -1
  131. package/dist/react/state.js +14 -1
  132. package/dist/react/state.js.map +1 -1
  133. package/dist/react/uploadFile.d.ts +2 -2
  134. package/dist/react/uploadFile.js +1 -1
  135. package/dist/react/uploadFile.js.map +1 -1
  136. package/dist/submissions/runSubmission.js +70 -33
  137. package/dist/submissions/runSubmission.js.map +1 -1
  138. package/dist/submissions/validateSubmission.js +41 -7
  139. package/dist/submissions/validateSubmission.js.map +1 -1
  140. package/dist/submissions/votedCookie.d.ts +13 -0
  141. package/dist/submissions/votedCookie.js +27 -0
  142. package/dist/submissions/votedCookie.js.map +1 -0
  143. package/dist/translations/de.js +219 -0
  144. package/dist/translations/de.js.map +1 -0
  145. package/dist/translations/en.js +45 -6
  146. package/dist/translations/en.js.map +1 -1
  147. package/dist/translations/index.d.ts +2 -1
  148. package/dist/translations/index.js +6 -2
  149. package/dist/translations/index.js.map +1 -1
  150. package/dist/translations/keys.d.ts +43 -4
  151. package/dist/translations/keys.js +44 -5
  152. package/dist/translations/keys.js.map +1 -1
  153. package/dist/uploads/types.d.ts +1 -2
  154. package/dist/validation/validateUrl.js +22 -0
  155. package/dist/validation/validateUrl.js.map +1 -0
  156. package/package.json +2 -1
  157. package/styles/form-builder.css +10 -0
  158. package/dist/collections/uploads.d.ts +0 -30
  159. package/dist/collections/uploads.js +0 -55
  160. package/dist/collections/uploads.js.map +0 -1
  161. package/dist/presentations/registry.d.ts +0 -15
  162. package/dist/presentations/registry.js +0 -15
  163. package/dist/presentations/registry.js.map +0 -1
  164. package/dist/react/toFormDocument.d.ts +0 -24
  165. package/dist/react/toFormDocument.js +0 -22
  166. package/dist/react/toFormDocument.js.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FormControls.js","names":[],"sources":["../../src/react/FormControls.tsx"],"sourcesContent":["'use client'\n\nimport type { ReactNode } from 'react'\nimport { cn } from './cn'\nimport { useFormContext } from './FormContext'\n\n/** Props passed to `renderSubmit`. */\nexport type SubmitButtonRenderProps = {\n\tlabel: string\n\tsubmitting: boolean\n}\n\n/** Props passed to `renderNext`. */\nexport type NextButtonRenderProps = {\n\tlabel: string\n\tsubmitting: boolean\n\tonClick: () => void\n}\n\n/** Props passed to `renderBack`. */\nexport type BackButtonRenderProps = {\n\tlabel: string\n\tsubmitting: boolean\n\tonClick: () => void\n}\n\nexport type FormControlsProps = {\n\t/** Override the context-resolved back label for this instance. */\n\tbackLabel?: string\n\t/** Override the context-resolved next label for this instance. */\n\tnextLabel?: string\n\t/** Override the context-resolved submit label for this instance. */\n\tsubmitLabel?: string\n\t/** Additional CSS class on the wrapping `.fb-form__controls` element. */\n\tclassName?: string\n\t/** CSS class forwarded to the default \"Back\" `<button>`. Ignored when `renderBack` is provided. */\n\tbackButtonClassName?: string\n\t/** CSS class forwarded to the default \"Next\" `<button>`. Ignored when `renderNext` is provided. */\n\tnextButtonClassName?: string\n\t/** CSS class forwarded to the default submit `<button>`. Ignored when `renderSubmit` is provided. */\n\tsubmitButtonClassName?: string\n\t/** Replace the default \"Back\" button entirely. */\n\trenderBack?: (props: BackButtonRenderProps) => ReactNode\n\t/** Replace the default \"Next\" button entirely. */\n\trenderNext?: (props: NextButtonRenderProps) => ReactNode\n\t/** Replace the default submit button entirely. Receives the resolved label and submitting state. */\n\trenderSubmit?: (props: SubmitButtonRenderProps) => ReactNode\n}\n\n/**\n * The form's navigation chrome: back on non-first steps, next on non-terminal steps, submit on\n * the terminal step (a single-step form is first and terminal, so it gets only submit). Driven\n * entirely by the form context, so it composes into custom `children` layouts exactly like the\n * default field loop's chrome. Labels resolve from context (`<Form>` props, the form's\n * `response.submitLabel`, translated defaults) unless overridden per button here. Must render\n * inside `<Form>`, within its `<form>` element so the submit button submits.\n */\nexport const FormControls = ({\n\tbackLabel,\n\tnextLabel,\n\tsubmitLabel,\n\tclassName,\n\tbackButtonClassName,\n\tnextButtonClassName,\n\tsubmitButtonClassName,\n\trenderBack,\n\trenderNext,\n\trenderSubmit,\n}: FormControlsProps) => {\n\tconst { state, step, labels } = useFormContext()\n\tconst submitting = state.submitting\n\tconst back = backLabel ?? labels.back\n\tconst next = nextLabel ?? labels.next\n\tconst submit = submitLabel ?? labels.submit\n\treturn (\n\t\t<div className={cn('fb-form__controls', className)}>\n\t\t\t{!step.isFirst ? (\n\t\t\t\trenderBack ? (\n\t\t\t\t\trenderBack({ label: back, submitting, onClick: step.goBack })\n\t\t\t\t) : (\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tclassName={cn('fb-form__back', backButtonClassName)}\n\t\t\t\t\t\tonClick={step.goBack}\n\t\t\t\t\t\tdisabled={submitting}\n\t\t\t\t\t>\n\t\t\t\t\t\t{back}\n\t\t\t\t\t</button>\n\t\t\t\t)\n\t\t\t) : null}\n\t\t\t{step.isTerminal ? (\n\t\t\t\trenderSubmit ? (\n\t\t\t\t\trenderSubmit({ label: submit, submitting })\n\t\t\t\t) : (\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\tclassName={cn('fb-form__submit', submitButtonClassName)}\n\t\t\t\t\t\tdisabled={submitting}\n\t\t\t\t\t>\n\t\t\t\t\t\t{submit}\n\t\t\t\t\t</button>\n\t\t\t\t)\n\t\t\t) : renderNext ? (\n\t\t\t\trenderNext({ label: next, submitting, onClick: step.goNext })\n\t\t\t) : (\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tclassName={cn('fb-form__next', nextButtonClassName)}\n\t\t\t\t\tonClick={step.goNext}\n\t\t\t\t\tdisabled={submitting}\n\t\t\t\t>\n\t\t\t\t\t{next}\n\t\t\t\t</button>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"],"mappings":";;;;;;;;;;;;;AAyDA,MAAa,gBAAgB,EAC5B,WACA,WACA,aACA,WACA,qBACA,qBACA,uBACA,YACA,YACA,mBACwB;CACxB,MAAM,EAAE,OAAO,MAAM,WAAW,eAAe;CAC/C,MAAM,aAAa,MAAM;CACzB,MAAM,OAAO,aAAa,OAAO;CACjC,MAAM,OAAO,aAAa,OAAO;CACjC,MAAM,SAAS,eAAe,OAAO;CACrC,OACC,qBAAC,OAAD;EAAK,WAAW,GAAG,qBAAqB,SAAS;YAAjD,CACE,CAAC,KAAK,UACN,aACC,WAAW;GAAE,OAAO;GAAM;GAAY,SAAS,KAAK;EAAO,CAAC,IAE5D,oBAAC,UAAD;GACC,MAAK;GACL,WAAW,GAAG,iBAAiB,mBAAmB;GAClD,SAAS,KAAK;GACd,UAAU;aAET;EACM,CAAA,IAEN,MACH,KAAK,aACL,eACC,aAAa;GAAE,OAAO;GAAQ;EAAW,CAAC,IAE1C,oBAAC,UAAD;GACC,MAAK;GACL,WAAW,GAAG,mBAAmB,qBAAqB;GACtD,UAAU;aAET;EACM,CAAA,IAEN,aACH,WAAW;GAAE,OAAO;GAAM;GAAY,SAAS,KAAK;EAAO,CAAC,IAE5D,oBAAC,UAAD;GACC,MAAK;GACL,WAAW,GAAG,iBAAiB,mBAAmB;GAClD,SAAS,KAAK;GACd,UAAU;aAET;EACM,CAAA,CAEL;;AAEP"}
@@ -7,18 +7,21 @@ type FormResultsProps = {
7
7
  /** One field aggregation or several (a survey summary), resolved server-side and passed in. */results: FieldAggregation | FieldAggregation[]; /** Localized translator for built-in copy. Defaults to identity (returns the key). */
8
8
  t?: RendererTranslate;
9
9
  locale?: string; /** Show the raw count beside each option. Default true. */
10
- showCounts?: boolean;
10
+ showCounts?: boolean; /** The recorded outcome's stable option value; the matching bucket is highlighted as the winner. */
11
+ winningValue?: string;
11
12
  };
12
13
  /**
13
14
  * Presentational results view for polls and survey summaries: per-option bars with percentages. Headless,
14
15
  * data resolved server-side via `aggregateFieldResponses`/`aggregateFormResponses` and passed in (it never
15
16
  * fetches). The option label, count, and percentage are real text (the accessible content); the bar fill
16
- * is `aria-hidden` visual sugar sized by inline width.
17
+ * is `aria-hidden` visual sugar sized by inline width. With a `winningValue`, the matching bucket gets
18
+ * the winner class and a translated badge (real text, so the outcome is accessible).
17
19
  */
18
20
  declare const FormResults: ({
19
21
  results,
20
22
  t,
21
- showCounts
23
+ showCounts,
24
+ winningValue
22
25
  }: FormResultsProps) => ReactNode;
23
26
  //#endregion
24
27
  export { FormResults, FormResultsProps };
@@ -4,7 +4,7 @@ import { en } from "../translations/en.js";
4
4
  import { makeTranslate } from "../translations/makeTranslate.js";
5
5
  import { jsx, jsxs } from "react/jsx-runtime";
6
6
  //#region src/react/FormResults.tsx
7
- const OneResult = ({ result, t, showCounts }) => {
7
+ const OneResult = ({ result, t, showCounts, winningValue }) => {
8
8
  if (result.total === 0) return /* @__PURE__ */ jsxs("section", {
9
9
  className: "fb-results__field",
10
10
  children: [result.label ? /* @__PURE__ */ jsx("h3", {
@@ -24,31 +24,37 @@ const OneResult = ({ result, t, showCounts }) => {
24
24
  }) : null,
25
25
  /* @__PURE__ */ jsx("ul", {
26
26
  className: "fb-results__list",
27
- children: result.buckets.map((bucket) => /* @__PURE__ */ jsxs("li", {
28
- className: "fb-results__row",
29
- children: [
30
- /* @__PURE__ */ jsx("span", {
31
- className: "fb-results__option",
32
- children: bucket.label
33
- }),
34
- /* @__PURE__ */ jsx("span", {
35
- className: "fb-results__bar",
36
- children: /* @__PURE__ */ jsx("span", {
37
- className: "fb-results__bar-fill",
38
- style: { width: `${bucket.percentage}%` },
39
- "aria-hidden": "true"
40
- })
41
- }),
42
- /* @__PURE__ */ jsxs("span", {
43
- className: "fb-results__pct",
44
- children: [bucket.percentage, "%"]
45
- }),
46
- showCounts ? /* @__PURE__ */ jsx("span", {
47
- className: "fb-results__count",
48
- children: bucket.count
49
- }) : null
50
- ]
51
- }, bucket.value))
27
+ children: result.buckets.map((bucket) => {
28
+ const isWinner = winningValue != null && bucket.value === winningValue;
29
+ return /* @__PURE__ */ jsxs("li", {
30
+ className: isWinner ? "fb-results__row fb-results__bucket--winner" : "fb-results__row",
31
+ children: [
32
+ /* @__PURE__ */ jsxs("span", {
33
+ className: "fb-results__option",
34
+ children: [bucket.label, isWinner ? /* @__PURE__ */ jsx("span", {
35
+ className: "fb-results__winner-badge",
36
+ children: t(keys.resultsWinner)
37
+ }) : null]
38
+ }),
39
+ /* @__PURE__ */ jsx("span", {
40
+ className: "fb-results__bar",
41
+ children: /* @__PURE__ */ jsx("span", {
42
+ className: "fb-results__bar-fill",
43
+ style: { width: `${bucket.percentage}%` },
44
+ "aria-hidden": "true"
45
+ })
46
+ }),
47
+ /* @__PURE__ */ jsxs("span", {
48
+ className: "fb-results__pct",
49
+ children: [bucket.percentage, "%"]
50
+ }),
51
+ showCounts ? /* @__PURE__ */ jsx("span", {
52
+ className: "fb-results__count",
53
+ children: bucket.count
54
+ }) : null
55
+ ]
56
+ }, bucket.value);
57
+ })
52
58
  }),
53
59
  /* @__PURE__ */ jsxs("p", {
54
60
  className: "fb-results__total",
@@ -66,16 +72,18 @@ const OneResult = ({ result, t, showCounts }) => {
66
72
  * Presentational results view for polls and survey summaries: per-option bars with percentages. Headless,
67
73
  * data resolved server-side via `aggregateFieldResponses`/`aggregateFormResponses` and passed in (it never
68
74
  * fetches). The option label, count, and percentage are real text (the accessible content); the bar fill
69
- * is `aria-hidden` visual sugar sized by inline width.
75
+ * is `aria-hidden` visual sugar sized by inline width. With a `winningValue`, the matching bucket gets
76
+ * the winner class and a translated badge (real text, so the outcome is accessible).
70
77
  */
71
- const FormResults = ({ results, t, showCounts = true }) => {
78
+ const FormResults = ({ results, t, showCounts = true, winningValue }) => {
72
79
  const translate = t ?? makeTranslate(en);
73
80
  return /* @__PURE__ */ jsx("div", {
74
81
  className: "fb-results",
75
82
  children: (Array.isArray(results) ? results : [results]).map((result) => /* @__PURE__ */ jsx(OneResult, {
76
83
  result,
77
84
  t: translate,
78
- showCounts
85
+ showCounts,
86
+ winningValue
79
87
  }, result.field))
80
88
  });
81
89
  };
@@ -1 +1 @@
1
- {"version":3,"file":"FormResults.js","names":[],"sources":["../../src/react/FormResults.tsx"],"sourcesContent":["'use client'\n\nimport type { ReactNode } from 'react'\nimport type { FieldAggregation } from '../aggregation/types'\nimport { en } from '../translations/en'\nimport { keys } from '../translations/keys'\nimport { makeTranslate } from '../translations/makeTranslate'\nimport type { RendererTranslate } from './contract'\n\nexport type FormResultsProps = {\n\t/** One field aggregation or several (a survey summary), resolved server-side and passed in. */\n\tresults: FieldAggregation | FieldAggregation[]\n\t/** Localized translator for built-in copy. Defaults to identity (returns the key). */\n\tt?: RendererTranslate\n\tlocale?: string\n\t/** Show the raw count beside each option. Default true. */\n\tshowCounts?: boolean\n}\n\nconst OneResult = ({\n\tresult,\n\tt,\n\tshowCounts,\n}: {\n\tresult: FieldAggregation\n\tt: RendererTranslate\n\tshowCounts: boolean\n}): ReactNode => {\n\tif (result.total === 0) {\n\t\treturn (\n\t\t\t<section className=\"fb-results__field\">\n\t\t\t\t{result.label ? <h3 className=\"fb-results__label\">{result.label}</h3> : null}\n\t\t\t\t<p className=\"fb-results__empty\">{t(keys.resultsNoResponses)}</p>\n\t\t\t</section>\n\t\t)\n\t}\n\treturn (\n\t\t<section className=\"fb-results__field\">\n\t\t\t{result.label ? <h3 className=\"fb-results__label\">{result.label}</h3> : null}\n\t\t\t<ul className=\"fb-results__list\">\n\t\t\t\t{result.buckets.map((bucket) => (\n\t\t\t\t\t<li key={bucket.value} className=\"fb-results__row\">\n\t\t\t\t\t\t<span className=\"fb-results__option\">{bucket.label}</span>\n\t\t\t\t\t\t<span className=\"fb-results__bar\">\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName=\"fb-results__bar-fill\"\n\t\t\t\t\t\t\t\tstyle={{ width: `${bucket.percentage}%` }}\n\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span className=\"fb-results__pct\">{bucket.percentage}%</span>\n\t\t\t\t\t\t{showCounts ? <span className=\"fb-results__count\">{bucket.count}</span> : null}\n\t\t\t\t\t</li>\n\t\t\t\t))}\n\t\t\t</ul>\n\t\t\t<p className=\"fb-results__total\">\n\t\t\t\t{result.total} {t(keys.resultsResponses)}\n\t\t\t\t{result.truncated ? ` (${t(keys.resultsTruncated)})` : ''}\n\t\t\t</p>\n\t\t</section>\n\t)\n}\n\n/**\n * Presentational results view for polls and survey summaries: per-option bars with percentages. Headless,\n * data resolved server-side via `aggregateFieldResponses`/`aggregateFormResponses` and passed in (it never\n * fetches). The option label, count, and percentage are real text (the accessible content); the bar fill\n * is `aria-hidden` visual sugar sized by inline width.\n */\nexport const FormResults = ({ results, t, showCounts = true }: FormResultsProps): ReactNode => {\n\tconst translate: RendererTranslate = t ?? makeTranslate(en)\n\tconst list = Array.isArray(results) ? results : [results]\n\treturn (\n\t\t<div className=\"fb-results\">\n\t\t\t{list.map((result) => (\n\t\t\t\t<OneResult key={result.field} result={result} t={translate} showCounts={showCounts} />\n\t\t\t))}\n\t\t</div>\n\t)\n}\n"],"mappings":";;;;;;AAmBA,MAAM,aAAa,EAClB,QACA,GACA,iBAKgB;CAChB,IAAI,OAAO,UAAU,GACpB,OACC,qBAAC,WAAD;EAAS,WAAU;YAAnB,CACE,OAAO,QAAQ,oBAAC,MAAD;GAAI,WAAU;aAAqB,OAAO;EAAU,CAAA,IAAI,MACxE,oBAAC,KAAD;GAAG,WAAU;aAAqB,EAAE,KAAK,kBAAkB;EAAK,CAAA,CACxD;;CAGX,OACC,qBAAC,WAAD;EAAS,WAAU;YAAnB;GACE,OAAO,QAAQ,oBAAC,MAAD;IAAI,WAAU;cAAqB,OAAO;GAAU,CAAA,IAAI;GACxE,oBAAC,MAAD;IAAI,WAAU;cACZ,OAAO,QAAQ,KAAK,WACpB,qBAAC,MAAD;KAAuB,WAAU;eAAjC;MACC,oBAAC,QAAD;OAAM,WAAU;iBAAsB,OAAO;MAAY,CAAA;MACzD,oBAAC,QAAD;OAAM,WAAU;iBACf,oBAAC,QAAD;QACC,WAAU;QACV,OAAO,EAAE,OAAO,GAAG,OAAO,WAAW,GAAG;QACxC,eAAY;OACZ,CAAA;MACI,CAAA;MACN,qBAAC,QAAD;OAAM,WAAU;iBAAhB,CAAmC,OAAO,YAAW,GAAO;;MAC3D,aAAa,oBAAC,QAAD;OAAM,WAAU;iBAAqB,OAAO;MAAY,CAAA,IAAI;KACvE;OAXK,OAAO,KAWZ,CACJ;GACE,CAAA;GACJ,qBAAC,KAAD;IAAG,WAAU;cAAb;KACE,OAAO;KAAM;KAAE,EAAE,KAAK,gBAAgB;KACtC,OAAO,YAAY,KAAK,EAAE,KAAK,gBAAgB,EAAE,KAAK;IACrD;;EACK;;AAEX;;;;;;;AAQA,MAAa,eAAe,EAAE,SAAS,GAAG,aAAa,WAAwC;CAC9F,MAAM,YAA+B,KAAK,cAAc,EAAE;CAE1D,OACC,oBAAC,OAAD;EAAK,WAAU;aAFH,MAAM,QAAQ,OAAO,IAAI,UAAU,CAAC,OAAO,GAGhD,KAAK,WACV,oBAAC,WAAD;GAAsC;GAAQ,GAAG;GAAuB;EAAa,GAArE,OAAO,KAA8D,CACrF;CACG,CAAA;AAEP"}
1
+ {"version":3,"file":"FormResults.js","names":[],"sources":["../../src/react/FormResults.tsx"],"sourcesContent":["'use client'\n\nimport type { ReactNode } from 'react'\nimport type { FieldAggregation } from '../aggregation/types'\nimport { en } from '../translations/en'\nimport { keys } from '../translations/keys'\nimport { makeTranslate } from '../translations/makeTranslate'\nimport type { RendererTranslate } from './contract'\n\nexport type FormResultsProps = {\n\t/** One field aggregation or several (a survey summary), resolved server-side and passed in. */\n\tresults: FieldAggregation | FieldAggregation[]\n\t/** Localized translator for built-in copy. Defaults to identity (returns the key). */\n\tt?: RendererTranslate\n\tlocale?: string\n\t/** Show the raw count beside each option. Default true. */\n\tshowCounts?: boolean\n\t/** The recorded outcome's stable option value; the matching bucket is highlighted as the winner. */\n\twinningValue?: string\n}\n\nconst OneResult = ({\n\tresult,\n\tt,\n\tshowCounts,\n\twinningValue,\n}: {\n\tresult: FieldAggregation\n\tt: RendererTranslate\n\tshowCounts: boolean\n\twinningValue?: string\n}): ReactNode => {\n\tif (result.total === 0) {\n\t\treturn (\n\t\t\t<section className=\"fb-results__field\">\n\t\t\t\t{result.label ? <h3 className=\"fb-results__label\">{result.label}</h3> : null}\n\t\t\t\t<p className=\"fb-results__empty\">{t(keys.resultsNoResponses)}</p>\n\t\t\t</section>\n\t\t)\n\t}\n\treturn (\n\t\t<section className=\"fb-results__field\">\n\t\t\t{result.label ? <h3 className=\"fb-results__label\">{result.label}</h3> : null}\n\t\t\t<ul className=\"fb-results__list\">\n\t\t\t\t{result.buckets.map((bucket) => {\n\t\t\t\t\tconst isWinner = winningValue != null && bucket.value === winningValue\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<li\n\t\t\t\t\t\t\tkey={bucket.value}\n\t\t\t\t\t\t\tclassName={\n\t\t\t\t\t\t\t\tisWinner ? 'fb-results__row fb-results__bucket--winner' : 'fb-results__row'\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span className=\"fb-results__option\">\n\t\t\t\t\t\t\t\t{bucket.label}\n\t\t\t\t\t\t\t\t{isWinner ? (\n\t\t\t\t\t\t\t\t\t<span className=\"fb-results__winner-badge\">{t(keys.resultsWinner)}</span>\n\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<span className=\"fb-results__bar\">\n\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\tclassName=\"fb-results__bar-fill\"\n\t\t\t\t\t\t\t\t\tstyle={{ width: `${bucket.percentage}%` }}\n\t\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<span className=\"fb-results__pct\">{bucket.percentage}%</span>\n\t\t\t\t\t\t\t{showCounts ? <span className=\"fb-results__count\">{bucket.count}</span> : null}\n\t\t\t\t\t\t</li>\n\t\t\t\t\t)\n\t\t\t\t})}\n\t\t\t</ul>\n\t\t\t<p className=\"fb-results__total\">\n\t\t\t\t{result.total} {t(keys.resultsResponses)}\n\t\t\t\t{result.truncated ? ` (${t(keys.resultsTruncated)})` : ''}\n\t\t\t</p>\n\t\t</section>\n\t)\n}\n\n/**\n * Presentational results view for polls and survey summaries: per-option bars with percentages. Headless,\n * data resolved server-side via `aggregateFieldResponses`/`aggregateFormResponses` and passed in (it never\n * fetches). The option label, count, and percentage are real text (the accessible content); the bar fill\n * is `aria-hidden` visual sugar sized by inline width. With a `winningValue`, the matching bucket gets\n * the winner class and a translated badge (real text, so the outcome is accessible).\n */\nexport const FormResults = ({\n\tresults,\n\tt,\n\tshowCounts = true,\n\twinningValue,\n}: FormResultsProps): ReactNode => {\n\tconst translate: RendererTranslate = t ?? makeTranslate(en)\n\tconst list = Array.isArray(results) ? results : [results]\n\treturn (\n\t\t<div className=\"fb-results\">\n\t\t\t{list.map((result) => (\n\t\t\t\t<OneResult\n\t\t\t\t\tkey={result.field}\n\t\t\t\t\tresult={result}\n\t\t\t\t\tt={translate}\n\t\t\t\t\tshowCounts={showCounts}\n\t\t\t\t\twinningValue={winningValue}\n\t\t\t\t/>\n\t\t\t))}\n\t\t</div>\n\t)\n}\n"],"mappings":";;;;;;AAqBA,MAAM,aAAa,EAClB,QACA,GACA,YACA,mBAMgB;CAChB,IAAI,OAAO,UAAU,GACpB,OACC,qBAAC,WAAD;EAAS,WAAU;YAAnB,CACE,OAAO,QAAQ,oBAAC,MAAD;GAAI,WAAU;aAAqB,OAAO;EAAU,CAAA,IAAI,MACxE,oBAAC,KAAD;GAAG,WAAU;aAAqB,EAAE,KAAK,kBAAkB;EAAK,CAAA,CACxD;;CAGX,OACC,qBAAC,WAAD;EAAS,WAAU;YAAnB;GACE,OAAO,QAAQ,oBAAC,MAAD;IAAI,WAAU;cAAqB,OAAO;GAAU,CAAA,IAAI;GACxE,oBAAC,MAAD;IAAI,WAAU;cACZ,OAAO,QAAQ,KAAK,WAAW;KAC/B,MAAM,WAAW,gBAAgB,QAAQ,OAAO,UAAU;KAC1D,OACC,qBAAC,MAAD;MAEC,WACC,WAAW,+CAA+C;gBAH5D;OAMC,qBAAC,QAAD;QAAM,WAAU;kBAAhB,CACE,OAAO,OACP,WACA,oBAAC,QAAD;SAAM,WAAU;mBAA4B,EAAE,KAAK,aAAa;QAAQ,CAAA,IACrE,IACC;;OACN,oBAAC,QAAD;QAAM,WAAU;kBACf,oBAAC,QAAD;SACC,WAAU;SACV,OAAO,EAAE,OAAO,GAAG,OAAO,WAAW,GAAG;SACxC,eAAY;QACZ,CAAA;OACI,CAAA;OACN,qBAAC,QAAD;QAAM,WAAU;kBAAhB,CAAmC,OAAO,YAAW,GAAO;;OAC3D,aAAa,oBAAC,QAAD;QAAM,WAAU;kBAAqB,OAAO;OAAY,CAAA,IAAI;MACvE;QApBE,OAAO,KAoBT;IAEN,CAAC;GACE,CAAA;GACJ,qBAAC,KAAD;IAAG,WAAU;cAAb;KACE,OAAO;KAAM;KAAE,EAAE,KAAK,gBAAgB;KACtC,OAAO,YAAY,KAAK,EAAE,KAAK,gBAAgB,EAAE,KAAK;IACrD;;EACK;;AAEX;;;;;;;;AASA,MAAa,eAAe,EAC3B,SACA,GACA,aAAa,MACb,mBACkC;CAClC,MAAM,YAA+B,KAAK,cAAc,EAAE;CAE1D,OACC,oBAAC,OAAD;EAAK,WAAU;aAFH,MAAM,QAAQ,OAAO,IAAI,UAAU,CAAC,OAAO,GAGhD,KAAK,WACV,oBAAC,WAAD;GAES;GACR,GAAG;GACS;GACE;EACd,GALK,OAAO,KAKZ,CACD;CACG,CAAA;AAEP"}
@@ -0,0 +1,17 @@
1
+ //#region src/react/FormSteps.d.ts
2
+ type FormStepsProps = {
3
+ /** Additional CSS class on the wrapping `.fb-form-steps` list. */className?: string;
4
+ };
5
+ /**
6
+ * Step-progress indicator for multi-step forms: an ordered list of the flow's steps with the
7
+ * current one marked via `aria-current="step"` and a `--current` class. Step labels use the
8
+ * step's title, falling back to the translated "Step {n}" (same semantics as the admin flow
9
+ * builder). Carries no styling opinions beyond `fb-form-steps` classes. Renders nothing when
10
+ * the form has fewer than two steps. Must render inside `<Form>`.
11
+ */
12
+ declare const FormSteps: ({
13
+ className
14
+ }: FormStepsProps) => import("react/jsx-runtime").JSX.Element | null;
15
+ //#endregion
16
+ export { FormSteps, FormStepsProps };
17
+ //# sourceMappingURL=FormSteps.d.ts.map
@@ -0,0 +1,35 @@
1
+ "use client";
2
+ import { keys } from "../translations/keys.js";
3
+ import { stepLabel } from "../flow/stepLabel.js";
4
+ import { cn } from "./cn.js";
5
+ import { useFormContext } from "./FormContext.js";
6
+ import { jsx } from "react/jsx-runtime";
7
+ //#region src/react/FormSteps.tsx
8
+ /**
9
+ * Step-progress indicator for multi-step forms: an ordered list of the flow's steps with the
10
+ * current one marked via `aria-current="step"` and a `--current` class. Step labels use the
11
+ * step's title, falling back to the translated "Step {n}" (same semantics as the admin flow
12
+ * builder). Carries no styling opinions beyond `fb-form-steps` classes. Renders nothing when
13
+ * the form has fewer than two steps. Must render inside `<Form>`.
14
+ */
15
+ const FormSteps = ({ className }) => {
16
+ const { step, t } = useFormContext();
17
+ const steps = step.flow?.steps;
18
+ if (!steps || steps.length < 2) return null;
19
+ const fallbackTitle = t(keys.flowStepFallbackTitle);
20
+ return /* @__PURE__ */ jsx("ol", {
21
+ className: cn("fb-form-steps", className),
22
+ children: steps.map((flowStep, index) => {
23
+ const current = index === step.stepIndex;
24
+ return /* @__PURE__ */ jsx("li", {
25
+ className: cn("fb-form-steps__step", current && "fb-form-steps__step--current"),
26
+ "aria-current": current ? "step" : void 0,
27
+ children: stepLabel(flowStep, index, fallbackTitle)
28
+ }, flowStep.id);
29
+ })
30
+ });
31
+ };
32
+ //#endregion
33
+ export { FormSteps };
34
+
35
+ //# sourceMappingURL=FormSteps.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FormSteps.js","names":[],"sources":["../../src/react/FormSteps.tsx"],"sourcesContent":["'use client'\n\nimport { stepLabel } from '../flow/stepLabel'\nimport { keys } from '../translations/keys'\nimport { cn } from './cn'\nimport { useFormContext } from './FormContext'\n\nexport type FormStepsProps = {\n\t/** Additional CSS class on the wrapping `.fb-form-steps` list. */\n\tclassName?: string\n}\n\n/**\n * Step-progress indicator for multi-step forms: an ordered list of the flow's steps with the\n * current one marked via `aria-current=\"step\"` and a `--current` class. Step labels use the\n * step's title, falling back to the translated \"Step {n}\" (same semantics as the admin flow\n * builder). Carries no styling opinions beyond `fb-form-steps` classes. Renders nothing when\n * the form has fewer than two steps. Must render inside `<Form>`.\n */\nexport const FormSteps = ({ className }: FormStepsProps) => {\n\tconst { step, t } = useFormContext()\n\tconst steps = step.flow?.steps\n\tif (!steps || steps.length < 2) {\n\t\treturn null\n\t}\n\tconst fallbackTitle = t(keys.flowStepFallbackTitle)\n\treturn (\n\t\t<ol className={cn('fb-form-steps', className)}>\n\t\t\t{steps.map((flowStep, index) => {\n\t\t\t\tconst current = index === step.stepIndex\n\t\t\t\treturn (\n\t\t\t\t\t<li\n\t\t\t\t\t\tkey={flowStep.id}\n\t\t\t\t\t\tclassName={cn('fb-form-steps__step', current && 'fb-form-steps__step--current')}\n\t\t\t\t\t\taria-current={current ? 'step' : undefined}\n\t\t\t\t\t>\n\t\t\t\t\t\t{stepLabel(flowStep, index, fallbackTitle)}\n\t\t\t\t\t</li>\n\t\t\t\t)\n\t\t\t})}\n\t\t</ol>\n\t)\n}\n"],"mappings":";;;;;;;;;;;;;;AAmBA,MAAa,aAAa,EAAE,gBAAgC;CAC3D,MAAM,EAAE,MAAM,MAAM,eAAe;CACnC,MAAM,QAAQ,KAAK,MAAM;CACzB,IAAI,CAAC,SAAS,MAAM,SAAS,GAC5B,OAAO;CAER,MAAM,gBAAgB,EAAE,KAAK,qBAAqB;CAClD,OACC,oBAAC,MAAD;EAAI,WAAW,GAAG,iBAAiB,SAAS;YAC1C,MAAM,KAAK,UAAU,UAAU;GAC/B,MAAM,UAAU,UAAU,KAAK;GAC/B,OACC,oBAAC,MAAD;IAEC,WAAW,GAAG,uBAAuB,WAAW,8BAA8B;IAC9E,gBAAc,UAAU,SAAS,KAAA;cAEhC,UAAU,UAAU,OAAO,aAAa;GACtC,GALE,SAAS,EAKX;EAEN,CAAC;CACE,CAAA;AAEN"}
@@ -3,8 +3,13 @@ import { fetchFormResults } from "./fetchResults.js";
3
3
 
4
4
  //#region src/react/Poll.d.ts
5
5
  type PollProps = FormProps & {
6
- /** The choice field whose results are shown after voting (should match the form's public `resultsField`). */resultsField: string; /** localStorage key for the per-browser voted guard. Default `fb-poll-{form.id}`. */
7
- storageKey?: string; /** Force the voted state (controlled). Omit to use the localStorage guard. */
6
+ /** The choice field whose results are shown after voting (should match the form's public `poll.resultsField`). */resultsField: string; /** localStorage key for the per-browser voted guard. Default `fb-poll-{form.id}`. */
7
+ storageKey?: string;
8
+ /**
9
+ * Server-known voted state, ORed with the localStorage guard: `true` marks the visitor as voted;
10
+ * `false`/omitted falls back to localStorage. SSR hosts using the plugin's `poll.votedCookie`
11
+ * option pass `hasVotedCookie(cookieHeader, form.id)` here.
12
+ */
8
13
  hasVoted?: boolean;
9
14
  /**
10
15
  * Injectable results fetch (testing); defaults to `fetchFormResults`. Pass a stable reference (module
@@ -13,10 +18,16 @@ type PollProps = FormProps & {
13
18
  fetchResultsImpl?: typeof fetchFormResults;
14
19
  };
15
20
  /**
16
- * A poll: renders `<Form>` until the visitor votes, then fetches the aggregate results and shows
17
- * `<FormResults>`. A per-browser localStorage flag (`storageKey`) skips straight to results on revisit. The
18
- * guard is UX, not integrity (bypassable): server-enforced one-per-identity dedup composes via `req.user`
19
- * (authed forms) or a `notAlreadySubmitted` rule, with cookie/IP identity deferred to the spam phase.
21
+ * A poll: renders `<Form>` while open and not yet voted, then fetches the aggregate results and shows
22
+ * `<FormResults>`. Lifecycle comes from `form.poll`: past `closesAt` the poll is closed (a translated
23
+ * notice plus results, which the endpoint serves for any visibility once closed); a voted-but-open
24
+ * `afterClose` poll shows a translated wait notice instead of fetching (the endpoint would refuse). A
25
+ * recorded `outcome.winningValue` (via `resolvePollOutcome`) supersedes everything: a translated final
26
+ * notice plus results with the winning bucket highlighted. A
27
+ * per-browser localStorage flag (`storageKey`) skips straight to results on revisit; `hasVoted: true`
28
+ * (e.g. from the server-set voted cookie) marks voted regardless of localStorage. The guard is UX, not
29
+ * integrity (bypassable): server-enforced
30
+ * one-per-identity dedup composes via `req.user` (authed forms) or a `notAlreadySubmitted` rule.
20
31
  */
21
32
  declare const Poll: ({
22
33
  resultsField,
@@ -1,9 +1,13 @@
1
1
  "use client";
2
+ import { keys } from "../translations/keys.js";
3
+ import { isPollClosed } from "../form/pollState.js";
4
+ import { en } from "../translations/en.js";
5
+ import { makeTranslate } from "../translations/makeTranslate.js";
2
6
  import { Form } from "./Form.js";
3
7
  import { FormResults } from "./FormResults.js";
4
8
  import { fetchFormResults } from "./fetchResults.js";
5
- import { jsx } from "react/jsx-runtime";
6
- import { useCallback, useEffect, useState } from "react";
9
+ import { jsx, jsxs } from "react/jsx-runtime";
10
+ import { useCallback, useEffect, useMemo, useState } from "react";
7
11
  //#region src/react/Poll.tsx
8
12
  const readVoted = (key) => {
9
13
  try {
@@ -18,15 +22,27 @@ const writeVoted = (key) => {
18
22
  } catch {}
19
23
  };
20
24
  /**
21
- * A poll: renders `<Form>` until the visitor votes, then fetches the aggregate results and shows
22
- * `<FormResults>`. A per-browser localStorage flag (`storageKey`) skips straight to results on revisit. The
23
- * guard is UX, not integrity (bypassable): server-enforced one-per-identity dedup composes via `req.user`
24
- * (authed forms) or a `notAlreadySubmitted` rule, with cookie/IP identity deferred to the spam phase.
25
+ * A poll: renders `<Form>` while open and not yet voted, then fetches the aggregate results and shows
26
+ * `<FormResults>`. Lifecycle comes from `form.poll`: past `closesAt` the poll is closed (a translated
27
+ * notice plus results, which the endpoint serves for any visibility once closed); a voted-but-open
28
+ * `afterClose` poll shows a translated wait notice instead of fetching (the endpoint would refuse). A
29
+ * recorded `outcome.winningValue` (via `resolvePollOutcome`) supersedes everything: a translated final
30
+ * notice plus results with the winning bucket highlighted. A
31
+ * per-browser localStorage flag (`storageKey`) skips straight to results on revisit; `hasVoted: true`
32
+ * (e.g. from the server-set voted cookie) marks voted regardless of localStorage. The guard is UX, not
33
+ * integrity (bypassable): server-enforced
34
+ * one-per-identity dedup composes via `req.user` (authed forms) or a `notAlreadySubmitted` rule.
25
35
  */
26
36
  const Poll = ({ resultsField, storageKey, hasVoted, fetchResultsImpl = fetchFormResults, apiRoute, onSuccess, ...formProps }) => {
27
37
  const key = storageKey ?? `fb-poll-${formProps.form.id}`;
38
+ const poll = formProps.form.poll;
39
+ const closed = isPollClosed(poll);
40
+ const winningValue = poll?.outcome?.winningValue;
41
+ const finalized = typeof winningValue === "string" && winningValue.length > 0;
42
+ const resultsAwaitClose = !closed && !finalized && poll?.resultsVisibility === "afterClose";
28
43
  const [voted, setVoted] = useState(false);
29
44
  const [results, setResults] = useState(null);
45
+ const translate = useMemo(() => formProps.t ?? makeTranslate(en), [formProps.t]);
30
46
  const loadResults = useCallback(async () => {
31
47
  const result = await fetchResultsImpl({
32
48
  formId: formProps.form.id,
@@ -41,30 +57,62 @@ const Poll = ({ resultsField, storageKey, hasVoted, fetchResultsImpl = fetchForm
41
57
  apiRoute
42
58
  ]);
43
59
  useEffect(() => {
44
- if (hasVoted ?? readVoted(key)) {
45
- setVoted(true);
46
- loadResults();
47
- }
60
+ const already = hasVoted === true || readVoted(key);
61
+ if (already) setVoted(true);
62
+ if (already && !resultsAwaitClose || closed || finalized) loadResults();
48
63
  }, [
49
64
  hasVoted,
50
65
  key,
51
- loadResults
66
+ loadResults,
67
+ closed,
68
+ resultsAwaitClose,
69
+ finalized
52
70
  ]);
53
71
  const handleSuccess = useCallback((submissionId) => {
54
72
  writeVoted(key);
55
73
  setVoted(true);
56
- loadResults();
74
+ if (!resultsAwaitClose) loadResults();
57
75
  onSuccess?.(submissionId);
58
76
  }, [
59
77
  key,
60
78
  loadResults,
61
- onSuccess
79
+ onSuccess,
80
+ resultsAwaitClose
62
81
  ]);
63
- if (voted) return results ? /* @__PURE__ */ jsx(FormResults, {
64
- results,
65
- t: formProps.t,
66
- locale: formProps.locale
67
- }) : null;
82
+ if (finalized) return /* @__PURE__ */ jsxs("div", {
83
+ className: "fb-poll fb-poll--final",
84
+ children: [/* @__PURE__ */ jsx("p", {
85
+ className: "fb-poll__final",
86
+ children: translate(keys.pollFinalResult)
87
+ }), results ? /* @__PURE__ */ jsx(FormResults, {
88
+ results,
89
+ winningValue,
90
+ t: formProps.t,
91
+ locale: formProps.locale
92
+ }) : null]
93
+ });
94
+ if (closed) return /* @__PURE__ */ jsxs("div", {
95
+ className: "fb-poll fb-poll--closed",
96
+ children: [/* @__PURE__ */ jsx("p", {
97
+ className: "fb-poll__closed",
98
+ children: translate(keys.pollClosed)
99
+ }), results ? /* @__PURE__ */ jsx(FormResults, {
100
+ results,
101
+ t: formProps.t,
102
+ locale: formProps.locale
103
+ }) : null]
104
+ });
105
+ if (voted) {
106
+ if (resultsAwaitClose) return /* @__PURE__ */ jsx("p", {
107
+ className: "fb-poll__await-close",
108
+ children: translate(keys.pollResultsAfterClose)
109
+ });
110
+ return results ? /* @__PURE__ */ jsx(FormResults, {
111
+ results,
112
+ t: formProps.t,
113
+ locale: formProps.locale
114
+ }) : null;
115
+ }
68
116
  return /* @__PURE__ */ jsx(Form, {
69
117
  ...formProps,
70
118
  apiRoute,
@@ -1 +1 @@
1
- {"version":3,"file":"Poll.js","names":[],"sources":["../../src/react/Poll.tsx"],"sourcesContent":["'use client'\n\nimport { useCallback, useEffect, useState } from 'react'\nimport type { FieldAggregation } from '../aggregation/types'\nimport { Form, type FormProps } from './Form'\nimport { FormResults } from './FormResults'\nimport { type FetchResultsResult, fetchFormResults } from './fetchResults'\n\nexport type PollProps = FormProps & {\n\t/** The choice field whose results are shown after voting (should match the form's public `resultsField`). */\n\tresultsField: string\n\t/** localStorage key for the per-browser voted guard. Default `fb-poll-{form.id}`. */\n\tstorageKey?: string\n\t/** Force the voted state (controlled). Omit to use the localStorage guard. */\n\thasVoted?: boolean\n\t/**\n\t * Injectable results fetch (testing); defaults to `fetchFormResults`. Pass a stable reference (module\n\t * scope, `useCallback`, or `useMemo`): an inline function re-runs the load effect and double-fetches.\n\t */\n\tfetchResultsImpl?: typeof fetchFormResults\n}\n\nconst readVoted = (key: string): boolean => {\n\ttry {\n\t\treturn window.localStorage.getItem(key) != null\n\t} catch {\n\t\treturn false\n\t}\n}\n\nconst writeVoted = (key: string): void => {\n\ttry {\n\t\twindow.localStorage.setItem(key, '1')\n\t} catch {\n\t\t// Private mode / storage disabled: the guard is best-effort UX, never integrity.\n\t}\n}\n\n/**\n * A poll: renders `<Form>` until the visitor votes, then fetches the aggregate results and shows\n * `<FormResults>`. A per-browser localStorage flag (`storageKey`) skips straight to results on revisit. The\n * guard is UX, not integrity (bypassable): server-enforced one-per-identity dedup composes via `req.user`\n * (authed forms) or a `notAlreadySubmitted` rule, with cookie/IP identity deferred to the spam phase.\n */\nexport const Poll = ({\n\tresultsField,\n\tstorageKey,\n\thasVoted,\n\tfetchResultsImpl = fetchFormResults,\n\tapiRoute,\n\tonSuccess,\n\t...formProps\n}: PollProps) => {\n\tconst key = storageKey ?? `fb-poll-${formProps.form.id}`\n\tconst [voted, setVoted] = useState(false)\n\tconst [results, setResults] = useState<FieldAggregation[] | null>(null)\n\n\tconst loadResults = useCallback(async () => {\n\t\tconst result: FetchResultsResult = await fetchResultsImpl({\n\t\t\tformId: formProps.form.id,\n\t\t\tfield: resultsField,\n\t\t\tapiRoute,\n\t\t})\n\t\tsetResults(result.ok ? result.results : [])\n\t}, [fetchResultsImpl, formProps.form.id, resultsField, apiRoute])\n\n\tuseEffect(() => {\n\t\tconst already = hasVoted ?? readVoted(key)\n\t\tif (already) {\n\t\t\tsetVoted(true)\n\t\t\tvoid loadResults()\n\t\t}\n\t}, [hasVoted, key, loadResults])\n\n\tconst handleSuccess = useCallback(\n\t\t(submissionId?: string) => {\n\t\t\twriteVoted(key)\n\t\t\tsetVoted(true)\n\t\t\tvoid loadResults()\n\t\t\tonSuccess?.(submissionId)\n\t\t},\n\t\t[key, loadResults, onSuccess]\n\t)\n\n\tif (voted) {\n\t\treturn results ? (\n\t\t\t<FormResults results={results} t={formProps.t} locale={formProps.locale} />\n\t\t) : null\n\t}\n\n\treturn <Form {...formProps} apiRoute={apiRoute} onSuccess={handleSuccess} />\n}\n"],"mappings":";;;;;;;AAsBA,MAAM,aAAa,QAAyB;CAC3C,IAAI;EACH,OAAO,OAAO,aAAa,QAAQ,GAAG,KAAK;CAC5C,QAAQ;EACP,OAAO;CACR;AACD;AAEA,MAAM,cAAc,QAAsB;CACzC,IAAI;EACH,OAAO,aAAa,QAAQ,KAAK,GAAG;CACrC,QAAQ,CAER;AACD;;;;;;;AAQA,MAAa,QAAQ,EACpB,cACA,YACA,UACA,mBAAmB,kBACnB,UACA,WACA,GAAG,gBACa;CAChB,MAAM,MAAM,cAAc,WAAW,UAAU,KAAK;CACpD,MAAM,CAAC,OAAO,YAAY,SAAS,KAAK;CACxC,MAAM,CAAC,SAAS,cAAc,SAAoC,IAAI;CAEtE,MAAM,cAAc,YAAY,YAAY;EAC3C,MAAM,SAA6B,MAAM,iBAAiB;GACzD,QAAQ,UAAU,KAAK;GACvB,OAAO;GACP;EACD,CAAC;EACD,WAAW,OAAO,KAAK,OAAO,UAAU,CAAC,CAAC;CAC3C,GAAG;EAAC;EAAkB,UAAU,KAAK;EAAI;EAAc;CAAQ,CAAC;CAEhE,gBAAgB;EAEf,IADgB,YAAY,UAAU,GAAG,GAC5B;GACZ,SAAS,IAAI;GACb,YAAiB;EAClB;CACD,GAAG;EAAC;EAAU;EAAK;CAAW,CAAC;CAE/B,MAAM,gBAAgB,aACpB,iBAA0B;EAC1B,WAAW,GAAG;EACd,SAAS,IAAI;EACb,YAAiB;EACjB,YAAY,YAAY;CACzB,GACA;EAAC;EAAK;EAAa;CAAS,CAC7B;CAEA,IAAI,OACH,OAAO,UACN,oBAAC,aAAD;EAAsB;EAAS,GAAG,UAAU;EAAG,QAAQ,UAAU;CAAS,CAAA,IACvE;CAGL,OAAO,oBAAC,MAAD;EAAM,GAAI;EAAqB;EAAU,WAAW;CAAgB,CAAA;AAC5E"}
1
+ {"version":3,"file":"Poll.js","names":[],"sources":["../../src/react/Poll.tsx"],"sourcesContent":["'use client'\n\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport type { FieldAggregation } from '../aggregation/types'\nimport { isPollClosed } from '../form/pollState'\nimport { en } from '../translations/en'\nimport { keys } from '../translations/keys'\nimport { makeTranslate } from '../translations/makeTranslate'\nimport { Form, type FormProps } from './Form'\nimport { FormResults } from './FormResults'\nimport { type FetchResultsResult, fetchFormResults } from './fetchResults'\n\nexport type PollProps = FormProps & {\n\t/** The choice field whose results are shown after voting (should match the form's public `poll.resultsField`). */\n\tresultsField: string\n\t/** localStorage key for the per-browser voted guard. Default `fb-poll-{form.id}`. */\n\tstorageKey?: string\n\t/**\n\t * Server-known voted state, ORed with the localStorage guard: `true` marks the visitor as voted;\n\t * `false`/omitted falls back to localStorage. SSR hosts using the plugin's `poll.votedCookie`\n\t * option pass `hasVotedCookie(cookieHeader, form.id)` here.\n\t */\n\thasVoted?: boolean\n\t/**\n\t * Injectable results fetch (testing); defaults to `fetchFormResults`. Pass a stable reference (module\n\t * scope, `useCallback`, or `useMemo`): an inline function re-runs the load effect and double-fetches.\n\t */\n\tfetchResultsImpl?: typeof fetchFormResults\n}\n\nconst readVoted = (key: string): boolean => {\n\ttry {\n\t\treturn window.localStorage.getItem(key) != null\n\t} catch {\n\t\treturn false\n\t}\n}\n\nconst writeVoted = (key: string): void => {\n\ttry {\n\t\twindow.localStorage.setItem(key, '1')\n\t} catch {\n\t\t// Private mode / storage disabled: the guard is best-effort UX, never integrity.\n\t}\n}\n\n/**\n * A poll: renders `<Form>` while open and not yet voted, then fetches the aggregate results and shows\n * `<FormResults>`. Lifecycle comes from `form.poll`: past `closesAt` the poll is closed (a translated\n * notice plus results, which the endpoint serves for any visibility once closed); a voted-but-open\n * `afterClose` poll shows a translated wait notice instead of fetching (the endpoint would refuse). A\n * recorded `outcome.winningValue` (via `resolvePollOutcome`) supersedes everything: a translated final\n * notice plus results with the winning bucket highlighted. A\n * per-browser localStorage flag (`storageKey`) skips straight to results on revisit; `hasVoted: true`\n * (e.g. from the server-set voted cookie) marks voted regardless of localStorage. The guard is UX, not\n * integrity (bypassable): server-enforced\n * one-per-identity dedup composes via `req.user` (authed forms) or a `notAlreadySubmitted` rule.\n */\nexport const Poll = ({\n\tresultsField,\n\tstorageKey,\n\thasVoted,\n\tfetchResultsImpl = fetchFormResults,\n\tapiRoute,\n\tonSuccess,\n\t...formProps\n}: PollProps) => {\n\tconst key = storageKey ?? `fb-poll-${formProps.form.id}`\n\tconst poll = formProps.form.poll\n\tconst closed = isPollClosed(poll)\n\tconst winningValue = poll?.outcome?.winningValue\n\tconst finalized = typeof winningValue === 'string' && winningValue.length > 0\n\tconst resultsAwaitClose = !closed && !finalized && poll?.resultsVisibility === 'afterClose'\n\tconst [voted, setVoted] = useState(false)\n\tconst [results, setResults] = useState<FieldAggregation[] | null>(null)\n\tconst translate = useMemo(() => formProps.t ?? makeTranslate(en), [formProps.t])\n\n\tconst loadResults = useCallback(async () => {\n\t\tconst result: FetchResultsResult = await fetchResultsImpl({\n\t\t\tformId: formProps.form.id,\n\t\t\tfield: resultsField,\n\t\t\tapiRoute,\n\t\t})\n\t\tsetResults(result.ok ? result.results : [])\n\t}, [fetchResultsImpl, formProps.form.id, resultsField, apiRoute])\n\n\tuseEffect(() => {\n\t\tconst already = hasVoted === true || readVoted(key)\n\t\tif (already) {\n\t\t\tsetVoted(true)\n\t\t}\n\t\tif ((already && !resultsAwaitClose) || closed || finalized) {\n\t\t\tvoid loadResults()\n\t\t}\n\t}, [hasVoted, key, loadResults, closed, resultsAwaitClose, finalized])\n\n\tconst handleSuccess = useCallback(\n\t\t(submissionId?: string) => {\n\t\t\twriteVoted(key)\n\t\t\tsetVoted(true)\n\t\t\tif (!resultsAwaitClose) {\n\t\t\t\tvoid loadResults()\n\t\t\t}\n\t\t\tonSuccess?.(submissionId)\n\t\t},\n\t\t[key, loadResults, onSuccess, resultsAwaitClose]\n\t)\n\n\tif (finalized) {\n\t\treturn (\n\t\t\t<div className=\"fb-poll fb-poll--final\">\n\t\t\t\t<p className=\"fb-poll__final\">{translate(keys.pollFinalResult)}</p>\n\t\t\t\t{results ? (\n\t\t\t\t\t<FormResults\n\t\t\t\t\t\tresults={results}\n\t\t\t\t\t\twinningValue={winningValue}\n\t\t\t\t\t\tt={formProps.t}\n\t\t\t\t\t\tlocale={formProps.locale}\n\t\t\t\t\t/>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t)\n\t}\n\n\tif (closed) {\n\t\treturn (\n\t\t\t<div className=\"fb-poll fb-poll--closed\">\n\t\t\t\t<p className=\"fb-poll__closed\">{translate(keys.pollClosed)}</p>\n\t\t\t\t{results ? (\n\t\t\t\t\t<FormResults results={results} t={formProps.t} locale={formProps.locale} />\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t)\n\t}\n\n\tif (voted) {\n\t\tif (resultsAwaitClose) {\n\t\t\treturn <p className=\"fb-poll__await-close\">{translate(keys.pollResultsAfterClose)}</p>\n\t\t}\n\t\treturn results ? (\n\t\t\t<FormResults results={results} t={formProps.t} locale={formProps.locale} />\n\t\t) : null\n\t}\n\n\treturn <Form {...formProps} apiRoute={apiRoute} onSuccess={handleSuccess} />\n}\n"],"mappings":";;;;;;;;;;;AA8BA,MAAM,aAAa,QAAyB;CAC3C,IAAI;EACH,OAAO,OAAO,aAAa,QAAQ,GAAG,KAAK;CAC5C,QAAQ;EACP,OAAO;CACR;AACD;AAEA,MAAM,cAAc,QAAsB;CACzC,IAAI;EACH,OAAO,aAAa,QAAQ,KAAK,GAAG;CACrC,QAAQ,CAER;AACD;;;;;;;;;;;;;AAcA,MAAa,QAAQ,EACpB,cACA,YACA,UACA,mBAAmB,kBACnB,UACA,WACA,GAAG,gBACa;CAChB,MAAM,MAAM,cAAc,WAAW,UAAU,KAAK;CACpD,MAAM,OAAO,UAAU,KAAK;CAC5B,MAAM,SAAS,aAAa,IAAI;CAChC,MAAM,eAAe,MAAM,SAAS;CACpC,MAAM,YAAY,OAAO,iBAAiB,YAAY,aAAa,SAAS;CAC5E,MAAM,oBAAoB,CAAC,UAAU,CAAC,aAAa,MAAM,sBAAsB;CAC/E,MAAM,CAAC,OAAO,YAAY,SAAS,KAAK;CACxC,MAAM,CAAC,SAAS,cAAc,SAAoC,IAAI;CACtE,MAAM,YAAY,cAAc,UAAU,KAAK,cAAc,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;CAE/E,MAAM,cAAc,YAAY,YAAY;EAC3C,MAAM,SAA6B,MAAM,iBAAiB;GACzD,QAAQ,UAAU,KAAK;GACvB,OAAO;GACP;EACD,CAAC;EACD,WAAW,OAAO,KAAK,OAAO,UAAU,CAAC,CAAC;CAC3C,GAAG;EAAC;EAAkB,UAAU,KAAK;EAAI;EAAc;CAAQ,CAAC;CAEhE,gBAAgB;EACf,MAAM,UAAU,aAAa,QAAQ,UAAU,GAAG;EAClD,IAAI,SACH,SAAS,IAAI;EAEd,IAAK,WAAW,CAAC,qBAAsB,UAAU,WAChD,YAAiB;CAEnB,GAAG;EAAC;EAAU;EAAK;EAAa;EAAQ;EAAmB;CAAS,CAAC;CAErE,MAAM,gBAAgB,aACpB,iBAA0B;EAC1B,WAAW,GAAG;EACd,SAAS,IAAI;EACb,IAAI,CAAC,mBACJ,YAAiB;EAElB,YAAY,YAAY;CACzB,GACA;EAAC;EAAK;EAAa;EAAW;CAAiB,CAChD;CAEA,IAAI,WACH,OACC,qBAAC,OAAD;EAAK,WAAU;YAAf,CACC,oBAAC,KAAD;GAAG,WAAU;aAAkB,UAAU,KAAK,eAAe;EAAK,CAAA,GACjE,UACA,oBAAC,aAAD;GACU;GACK;GACd,GAAG,UAAU;GACb,QAAQ,UAAU;EAClB,CAAA,IACE,IACA;;CAIP,IAAI,QACH,OACC,qBAAC,OAAD;EAAK,WAAU;YAAf,CACC,oBAAC,KAAD;GAAG,WAAU;aAAmB,UAAU,KAAK,UAAU;EAAK,CAAA,GAC7D,UACA,oBAAC,aAAD;GAAsB;GAAS,GAAG,UAAU;GAAG,QAAQ,UAAU;EAAS,CAAA,IACvE,IACA;;CAIP,IAAI,OAAO;EACV,IAAI,mBACH,OAAO,oBAAC,KAAD;GAAG,WAAU;aAAwB,UAAU,KAAK,qBAAqB;EAAK,CAAA;EAEtF,OAAO,UACN,oBAAC,aAAD;GAAsB;GAAS,GAAG,UAAU;GAAG,QAAQ,UAAU;EAAS,CAAA,IACvE;CACL;CAEA,OAAO,oBAAC,MAAD;EAAM,GAAI;EAAqB;EAAU,WAAW;CAAgB,CAAA;AAC5E"}
@@ -17,7 +17,7 @@ type FetchResultsResult = {
17
17
  /**
18
18
  * Fetch aggregate poll/survey results from the form-builder results endpoint
19
19
  * (`GET {apiRoute}/forms/:id/results`). Returns the server-resolved aggregations; the endpoint gates public
20
- * access by the form's `showResults` opt-in. Pure: inject `fetchImpl` in tests.
20
+ * access by the form's poll opt-in and results visibility. Pure: inject `fetchImpl` in tests.
21
21
  */
22
22
  declare const fetchFormResults: (input: FetchResultsInput) => Promise<FetchResultsResult>;
23
23
  //#endregion
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Fetch aggregate poll/survey results from the form-builder results endpoint
4
4
  * (`GET {apiRoute}/forms/:id/results`). Returns the server-resolved aggregations; the endpoint gates public
5
- * access by the form's `showResults` opt-in. Pure: inject `fetchImpl` in tests.
5
+ * access by the form's poll opt-in and results visibility. Pure: inject `fetchImpl` in tests.
6
6
  */
7
7
  const fetchFormResults = async (input) => {
8
8
  const { formId, field, apiRoute = "/api", fetchImpl = fetch } = input;
@@ -1 +1 @@
1
- {"version":3,"file":"fetchResults.js","names":[],"sources":["../../src/react/fetchResults.ts"],"sourcesContent":["import type { FieldAggregation } from '../aggregation/types'\n\nexport type FetchResultsInput = {\n\tformId: number | string\n\t/** Restrict to one field. Omit to get every public/enumerable field. */\n\tfield?: string\n\t/** Payload API route prefix; defaults to `/api`. */\n\tapiRoute?: string\n\t/** Injectable for testing; defaults to global `fetch`. */\n\tfetchImpl?: typeof fetch\n}\n\nexport type FetchResultsResult =\n\t| { ok: true; results: FieldAggregation[] }\n\t| { ok: false; message?: string }\n\n/**\n * Fetch aggregate poll/survey results from the form-builder results endpoint\n * (`GET {apiRoute}/forms/:id/results`). Returns the server-resolved aggregations; the endpoint gates public\n * access by the form's `showResults` opt-in. Pure: inject `fetchImpl` in tests.\n */\nexport const fetchFormResults = async (input: FetchResultsInput): Promise<FetchResultsResult> => {\n\tconst { formId, field, apiRoute = '/api', fetchImpl = fetch } = input\n\tconst query = field ? `?field=${encodeURIComponent(field)}` : ''\n\tlet response: Response\n\ttry {\n\t\tresponse = await fetchImpl(`${apiRoute}/forms/${formId}/results${query}`, { method: 'GET' })\n\t} catch (error) {\n\t\treturn { ok: false, message: error instanceof Error ? error.message : 'Network error' }\n\t}\n\tif (!response.ok) {\n\t\treturn { ok: false, message: `Request failed (${response.status})` }\n\t}\n\tconst body = (await response.json().catch(() => ({}))) as { results?: FieldAggregation[] }\n\treturn { ok: true, results: body.results ?? [] }\n}\n"],"mappings":";;;;;;AAqBA,MAAa,mBAAmB,OAAO,UAA0D;CAChG,MAAM,EAAE,QAAQ,OAAO,WAAW,QAAQ,YAAY,UAAU;CAChE,MAAM,QAAQ,QAAQ,UAAU,mBAAmB,KAAK,MAAM;CAC9D,IAAI;CACJ,IAAI;EACH,WAAW,MAAM,UAAU,GAAG,SAAS,SAAS,OAAO,UAAU,SAAS,EAAE,QAAQ,MAAM,CAAC;CAC5F,SAAS,OAAO;EACf,OAAO;GAAE,IAAI;GAAO,SAAS,iBAAiB,QAAQ,MAAM,UAAU;EAAgB;CACvF;CACA,IAAI,CAAC,SAAS,IACb,OAAO;EAAE,IAAI;EAAO,SAAS,mBAAmB,SAAS,OAAO;CAAG;CAGpE,OAAO;EAAE,IAAI;EAAM,UAAS,MADR,SAAS,KAAK,EAAE,aAAa,CAAC,EAAE,GACnB,WAAW,CAAC;CAAE;AAChD"}
1
+ {"version":3,"file":"fetchResults.js","names":[],"sources":["../../src/react/fetchResults.ts"],"sourcesContent":["import type { FieldAggregation } from '../aggregation/types'\n\nexport type FetchResultsInput = {\n\tformId: number | string\n\t/** Restrict to one field. Omit to get every public/enumerable field. */\n\tfield?: string\n\t/** Payload API route prefix; defaults to `/api`. */\n\tapiRoute?: string\n\t/** Injectable for testing; defaults to global `fetch`. */\n\tfetchImpl?: typeof fetch\n}\n\nexport type FetchResultsResult =\n\t| { ok: true; results: FieldAggregation[] }\n\t| { ok: false; message?: string }\n\n/**\n * Fetch aggregate poll/survey results from the form-builder results endpoint\n * (`GET {apiRoute}/forms/:id/results`). Returns the server-resolved aggregations; the endpoint gates public\n * access by the form's poll opt-in and results visibility. Pure: inject `fetchImpl` in tests.\n */\nexport const fetchFormResults = async (input: FetchResultsInput): Promise<FetchResultsResult> => {\n\tconst { formId, field, apiRoute = '/api', fetchImpl = fetch } = input\n\tconst query = field ? `?field=${encodeURIComponent(field)}` : ''\n\tlet response: Response\n\ttry {\n\t\tresponse = await fetchImpl(`${apiRoute}/forms/${formId}/results${query}`, { method: 'GET' })\n\t} catch (error) {\n\t\treturn { ok: false, message: error instanceof Error ? error.message : 'Network error' }\n\t}\n\tif (!response.ok) {\n\t\treturn { ok: false, message: `Request failed (${response.status})` }\n\t}\n\tconst body = (await response.json().catch(() => ({}))) as { results?: FieldAggregation[] }\n\treturn { ok: true, results: body.results ?? [] }\n}\n"],"mappings":";;;;;;AAqBA,MAAa,mBAAmB,OAAO,UAA0D;CAChG,MAAM,EAAE,QAAQ,OAAO,WAAW,QAAQ,YAAY,UAAU;CAChE,MAAM,QAAQ,QAAQ,UAAU,mBAAmB,KAAK,MAAM;CAC9D,IAAI;CACJ,IAAI;EACH,WAAW,MAAM,UAAU,GAAG,SAAS,SAAS,OAAO,UAAU,SAAS,EAAE,QAAQ,MAAM,CAAC;CAC5F,SAAS,OAAO;EACf,OAAO;GAAE,IAAI;GAAO,SAAS,iBAAiB,QAAQ,MAAM,UAAU;EAAgB;CACvF;CACA,IAAI,CAAC,SAAS,IACb,OAAO;EAAE,IAAI;EAAO,SAAS,mBAAmB,SAAS,OAAO;CAAG;CAGpE,OAAO;EAAE,IAAI;EAAM,UAAS,MADR,SAAS,KAAK,EAAE,aAAa,CAAC,EAAE,GACnB,WAAW,CAAC;CAAE;AAChD"}
@@ -24,7 +24,7 @@ const fileRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur
24
24
  const [uploading, setUploading] = useState(false);
25
25
  const [filename, setFilename] = useState(void 0);
26
26
  const [localError, setLocalError] = useState(void 0);
27
- const collection = typeof field.relationTo === "string" ? field.relationTo : "form-uploads";
27
+ const collection = typeof field.uploadsCollection === "string" ? field.uploadsCollection : void 0;
28
28
  const types = typesOf(field.mimeTypes);
29
29
  const accept = types.length > 0 ? types.join(",") : void 0;
30
30
  const maxSize = typeof field.maxSize === "number" && field.maxSize >= 0 ? field.maxSize : void 0;
@@ -38,6 +38,10 @@ const fileRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur
38
38
  setLocalError(resolveMessage(t(keys.fileTooLarge), { max: formatBytes(maxSize) }));
39
39
  return;
40
40
  }
41
+ if (!collection) {
42
+ setLocalError(t(keys.fileUploadMisconfigured));
43
+ return;
44
+ }
41
45
  setUploading(true);
42
46
  setLocalError(void 0);
43
47
  const result = await uploadFile({
@@ -1 +1 @@
1
- {"version":3,"file":"file.js","names":[],"sources":["../../../src/react/renderers/file.tsx"],"sourcesContent":["'use client'\n\nimport { type ChangeEvent, useId, useState } from 'react'\nimport { keys } from '../../translations/keys'\nimport { formatBytes } from '../../uploads/formatBytes'\nimport { resolveMessage } from '../../validation/message'\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { uploadFile } from '../uploadFile'\n\nconst typesOf = (mimeTypes: unknown): string[] => {\n\tif (!Array.isArray(mimeTypes)) {\n\t\treturn []\n\t}\n\treturn mimeTypes.filter((entry): entry is string => typeof entry === 'string')\n}\n\n/**\n * Headless file renderer: a native file input that uploads the chosen file to the upload collection and\n * stores the returned id as the field value. The server re-derives filename/mimeType/filesize from the\n * stored doc at submit, so the client value is only the id. Shows the uploaded filename with a clear\n * control and surfaces upload errors. Single file (v1); the upload posts to the default `/api` route.\n */\nexport const fileRenderer = defineFieldRenderer<string | number>(\n\t({ field, name, value, onChange, onBlur, errors, warnings, required, disabled, t }) => {\n\t\tconst id = useId()\n\t\tconst describedById = `${id}-desc`\n\t\tconst [uploading, setUploading] = useState(false)\n\t\tconst [filename, setFilename] = useState<string | undefined>(undefined)\n\t\tconst [localError, setLocalError] = useState<string | undefined>(undefined)\n\n\t\tconst collection = typeof field.relationTo === 'string' ? field.relationTo : 'form-uploads'\n\t\tconst types = typesOf(field.mimeTypes)\n\t\tconst accept = types.length > 0 ? types.join(',') : undefined\n\t\t// Zero is a real limit, matching resolveFileRef server-side (filesize > maxSize rejects any non-empty file).\n\t\tconst maxSize =\n\t\t\ttypeof field.maxSize === 'number' && field.maxSize >= 0 ? field.maxSize : undefined\n\t\tconst hints = [\n\t\t\ttypes.length > 0\n\t\t\t\t? resolveMessage(t(keys.fileHintAccepted), { types: types.join(', ') })\n\t\t\t\t: undefined,\n\t\t\tmaxSize !== undefined\n\t\t\t\t? resolveMessage(t(keys.fileHintMaxSize), { max: formatBytes(maxSize) })\n\t\t\t\t: undefined,\n\t\t].filter((hint): hint is string => typeof hint === 'string')\n\t\tconst allErrors = localError ? [...errors, localError] : errors\n\t\t// Show the just-uploaded filename, or a generic indicator when the field already holds an id (recall/prefill): the filename is not part of the client value.\n\t\tconst hasValue = value !== undefined && value !== null && value !== ''\n\t\tconst displayName = filename ?? (hasValue ? 'Uploaded file' : undefined)\n\n\t\tconst handleChange = async (event: ChangeEvent<HTMLInputElement>) => {\n\t\t\tconst selected = event.target.files?.[0]\n\t\t\tif (!selected) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (maxSize !== undefined && selected.size > maxSize) {\n\t\t\t\tsetLocalError(resolveMessage(t(keys.fileTooLarge), { max: formatBytes(maxSize) }))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsetUploading(true)\n\t\t\tsetLocalError(undefined)\n\t\t\tconst result = await uploadFile({ file: selected, collection })\n\t\t\tsetUploading(false)\n\t\t\tif (result.ok) {\n\t\t\t\tsetFilename(selected.name)\n\t\t\t\tonChange(result.id)\n\t\t\t} else {\n\t\t\t\tsetLocalError(result.message ?? 'Upload failed')\n\t\t\t}\n\t\t}\n\n\t\tconst clear = () => {\n\t\t\tsetFilename(undefined)\n\t\t\tsetLocalError(undefined)\n\t\t\tonChange('')\n\t\t}\n\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tlabel={field.label}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={allErrors}\n\t\t\t\twarnings={warnings}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t<input\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\ttype=\"file\"\n\t\t\t\t\taccept={accept}\n\t\t\t\t\tdisabled={disabled || uploading}\n\t\t\t\t\tonChange={(event) => {\n\t\t\t\t\t\tvoid handleChange(event)\n\t\t\t\t\t}}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\taria-invalid={allErrors.length > 0}\n\t\t\t\t\taria-describedby={describedById}\n\t\t\t\t/>\n\t\t\t\t{hints.length > 0 ? <p className=\"fb-field__file-hint\">{hints.join(' · ')}</p> : null}\n\t\t\t\t{uploading ? (\n\t\t\t\t\t<p className=\"fb-field__file-status\" aria-live=\"polite\">\n\t\t\t\t\t\tUploading\n\t\t\t\t\t</p>\n\t\t\t\t) : null}\n\t\t\t\t{displayName ? (\n\t\t\t\t\t<p className=\"fb-field__file-name\">\n\t\t\t\t\t\t<span>{displayName}</span>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tclassName=\"fb-field__file-clear\"\n\t\t\t\t\t\t\tonClick={clear}\n\t\t\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tRemove\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</p>\n\t\t\t\t) : null}\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":";;;;;;;;;;AAUA,MAAM,WAAW,cAAiC;CACjD,IAAI,CAAC,MAAM,QAAQ,SAAS,GAC3B,OAAO,CAAC;CAET,OAAO,UAAU,QAAQ,UAA2B,OAAO,UAAU,QAAQ;AAC9E;;;;;;;AAQA,MAAa,eAAe,qBAC1B,EAAE,OAAO,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,UAAU,UAAU,QAAQ;CACtF,MAAM,KAAK,MAAM;CACjB,MAAM,gBAAgB,GAAG,GAAG;CAC5B,MAAM,CAAC,WAAW,gBAAgB,SAAS,KAAK;CAChD,MAAM,CAAC,UAAU,eAAe,SAA6B,KAAA,CAAS;CACtE,MAAM,CAAC,YAAY,iBAAiB,SAA6B,KAAA,CAAS;CAE1E,MAAM,aAAa,OAAO,MAAM,eAAe,WAAW,MAAM,aAAa;CAC7E,MAAM,QAAQ,QAAQ,MAAM,SAAS;CACrC,MAAM,SAAS,MAAM,SAAS,IAAI,MAAM,KAAK,GAAG,IAAI,KAAA;CAEpD,MAAM,UACL,OAAO,MAAM,YAAY,YAAY,MAAM,WAAW,IAAI,MAAM,UAAU,KAAA;CAC3E,MAAM,QAAQ,CACb,MAAM,SAAS,IACZ,eAAe,EAAE,KAAK,gBAAgB,GAAG,EAAE,OAAO,MAAM,KAAK,IAAI,EAAE,CAAC,IACpE,KAAA,GACH,YAAY,KAAA,IACT,eAAe,EAAE,KAAK,eAAe,GAAG,EAAE,KAAK,YAAY,OAAO,EAAE,CAAC,IACrE,KAAA,CACJ,EAAE,QAAQ,SAAyB,OAAO,SAAS,QAAQ;CAC3D,MAAM,YAAY,aAAa,CAAC,GAAG,QAAQ,UAAU,IAAI;CAGzD,MAAM,cAAc,aADH,UAAU,KAAA,KAAa,UAAU,QAAQ,UAAU,KACxB,kBAAkB,KAAA;CAE9D,MAAM,eAAe,OAAO,UAAyC;EACpE,MAAM,WAAW,MAAM,OAAO,QAAQ;EACtC,IAAI,CAAC,UACJ;EAED,IAAI,YAAY,KAAA,KAAa,SAAS,OAAO,SAAS;GACrD,cAAc,eAAe,EAAE,KAAK,YAAY,GAAG,EAAE,KAAK,YAAY,OAAO,EAAE,CAAC,CAAC;GACjF;EACD;EACA,aAAa,IAAI;EACjB,cAAc,KAAA,CAAS;EACvB,MAAM,SAAS,MAAM,WAAW;GAAE,MAAM;GAAU;EAAW,CAAC;EAC9D,aAAa,KAAK;EAClB,IAAI,OAAO,IAAI;GACd,YAAY,SAAS,IAAI;GACzB,SAAS,OAAO,EAAE;EACnB,OACC,cAAc,OAAO,WAAW,eAAe;CAEjD;CAEA,MAAM,cAAc;EACnB,YAAY,KAAA,CAAS;EACrB,cAAc,KAAA,CAAS;EACvB,SAAS,EAAE;CACZ;CAEA,OACC,qBAAC,YAAD;EACK;EACJ,OAAO,MAAM;EACb,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACV,QAAQ;EACE;EACK;YAPhB;GASC,oBAAC,SAAD;IACK;IACE;IACN,MAAK;IACG;IACR,UAAU,YAAY;IACtB,WAAW,UAAU;KACpB,aAAkB,KAAK;IACxB;IACQ;IACR,gBAAc,UAAU,SAAS;IACjC,oBAAkB;GAClB,CAAA;GACA,MAAM,SAAS,IAAI,oBAAC,KAAD;IAAG,WAAU;cAAuB,MAAM,KAAK,KAAK;GAAK,CAAA,IAAI;GAChF,YACA,oBAAC,KAAD;IAAG,WAAU;IAAwB,aAAU;cAAS;GAErD,CAAA,IACA;GACH,cACA,qBAAC,KAAD;IAAG,WAAU;cAAb,CACC,oBAAC,QAAD,EAAA,UAAO,YAAkB,CAAA,GACzB,oBAAC,UAAD;KACC,MAAK;KACL,WAAU;KACV,SAAS;KACC;eACV;IAEO,CAAA,CACN;QACA;EACO;;AAEd,CACD"}
1
+ {"version":3,"file":"file.js","names":[],"sources":["../../../src/react/renderers/file.tsx"],"sourcesContent":["'use client'\n\nimport { type ChangeEvent, useId, useState } from 'react'\nimport { keys } from '../../translations/keys'\nimport { formatBytes } from '../../uploads/formatBytes'\nimport { resolveMessage } from '../../validation/message'\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { uploadFile } from '../uploadFile'\n\nconst typesOf = (mimeTypes: unknown): string[] => {\n\tif (!Array.isArray(mimeTypes)) {\n\t\treturn []\n\t}\n\treturn mimeTypes.filter((entry): entry is string => typeof entry === 'string')\n}\n\n/**\n * Headless file renderer: a native file input that uploads the chosen file to the upload collection and\n * stores the returned id as the field value. The server re-derives filename/mimeType/filesize from the\n * stored doc at submit, so the client value is only the id. Shows the uploaded filename with a clear\n * control and surfaces upload errors. Single file (v1); the upload posts to the default `/api` route.\n */\nexport const fileRenderer = defineFieldRenderer<string | number>(\n\t({ field, name, value, onChange, onBlur, errors, warnings, required, disabled, t }) => {\n\t\tconst id = useId()\n\t\tconst describedById = `${id}-desc`\n\t\tconst [uploading, setUploading] = useState(false)\n\t\tconst [filename, setFilename] = useState<string | undefined>(undefined)\n\t\tconst [localError, setLocalError] = useState<string | undefined>(undefined)\n\n\t\t// Stamped server-side onto the block from plugin config; absent only for stale form docs saved\n\t\t// before uploads were configured (uploading then fails with a visible error, never a guess).\n\t\tconst collection =\n\t\t\ttypeof field.uploadsCollection === 'string' ? field.uploadsCollection : undefined\n\t\tconst types = typesOf(field.mimeTypes)\n\t\tconst accept = types.length > 0 ? types.join(',') : undefined\n\t\t// Zero is a real limit, matching resolveFileRef server-side (filesize > maxSize rejects any non-empty file).\n\t\tconst maxSize =\n\t\t\ttypeof field.maxSize === 'number' && field.maxSize >= 0 ? field.maxSize : undefined\n\t\tconst hints = [\n\t\t\ttypes.length > 0\n\t\t\t\t? resolveMessage(t(keys.fileHintAccepted), { types: types.join(', ') })\n\t\t\t\t: undefined,\n\t\t\tmaxSize !== undefined\n\t\t\t\t? resolveMessage(t(keys.fileHintMaxSize), { max: formatBytes(maxSize) })\n\t\t\t\t: undefined,\n\t\t].filter((hint): hint is string => typeof hint === 'string')\n\t\tconst allErrors = localError ? [...errors, localError] : errors\n\t\t// Show the just-uploaded filename, or a generic indicator when the field already holds an id (recall/prefill): the filename is not part of the client value.\n\t\tconst hasValue = value !== undefined && value !== null && value !== ''\n\t\tconst displayName = filename ?? (hasValue ? 'Uploaded file' : undefined)\n\n\t\tconst handleChange = async (event: ChangeEvent<HTMLInputElement>) => {\n\t\t\tconst selected = event.target.files?.[0]\n\t\t\tif (!selected) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (maxSize !== undefined && selected.size > maxSize) {\n\t\t\t\tsetLocalError(resolveMessage(t(keys.fileTooLarge), { max: formatBytes(maxSize) }))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (!collection) {\n\t\t\t\tsetLocalError(t(keys.fileUploadMisconfigured))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsetUploading(true)\n\t\t\tsetLocalError(undefined)\n\t\t\tconst result = await uploadFile({ file: selected, collection })\n\t\t\tsetUploading(false)\n\t\t\tif (result.ok) {\n\t\t\t\tsetFilename(selected.name)\n\t\t\t\tonChange(result.id)\n\t\t\t} else {\n\t\t\t\tsetLocalError(result.message ?? 'Upload failed')\n\t\t\t}\n\t\t}\n\n\t\tconst clear = () => {\n\t\t\tsetFilename(undefined)\n\t\t\tsetLocalError(undefined)\n\t\t\tonChange('')\n\t\t}\n\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tlabel={field.label}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={allErrors}\n\t\t\t\twarnings={warnings}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t<input\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\ttype=\"file\"\n\t\t\t\t\taccept={accept}\n\t\t\t\t\tdisabled={disabled || uploading}\n\t\t\t\t\tonChange={(event) => {\n\t\t\t\t\t\tvoid handleChange(event)\n\t\t\t\t\t}}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\taria-invalid={allErrors.length > 0}\n\t\t\t\t\taria-describedby={describedById}\n\t\t\t\t/>\n\t\t\t\t{hints.length > 0 ? <p className=\"fb-field__file-hint\">{hints.join(' · ')}</p> : null}\n\t\t\t\t{uploading ? (\n\t\t\t\t\t<p className=\"fb-field__file-status\" aria-live=\"polite\">\n\t\t\t\t\t\tUploading\n\t\t\t\t\t</p>\n\t\t\t\t) : null}\n\t\t\t\t{displayName ? (\n\t\t\t\t\t<p className=\"fb-field__file-name\">\n\t\t\t\t\t\t<span>{displayName}</span>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tclassName=\"fb-field__file-clear\"\n\t\t\t\t\t\t\tonClick={clear}\n\t\t\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tRemove\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</p>\n\t\t\t\t) : null}\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":";;;;;;;;;;AAUA,MAAM,WAAW,cAAiC;CACjD,IAAI,CAAC,MAAM,QAAQ,SAAS,GAC3B,OAAO,CAAC;CAET,OAAO,UAAU,QAAQ,UAA2B,OAAO,UAAU,QAAQ;AAC9E;;;;;;;AAQA,MAAa,eAAe,qBAC1B,EAAE,OAAO,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,UAAU,UAAU,QAAQ;CACtF,MAAM,KAAK,MAAM;CACjB,MAAM,gBAAgB,GAAG,GAAG;CAC5B,MAAM,CAAC,WAAW,gBAAgB,SAAS,KAAK;CAChD,MAAM,CAAC,UAAU,eAAe,SAA6B,KAAA,CAAS;CACtE,MAAM,CAAC,YAAY,iBAAiB,SAA6B,KAAA,CAAS;CAI1E,MAAM,aACL,OAAO,MAAM,sBAAsB,WAAW,MAAM,oBAAoB,KAAA;CACzE,MAAM,QAAQ,QAAQ,MAAM,SAAS;CACrC,MAAM,SAAS,MAAM,SAAS,IAAI,MAAM,KAAK,GAAG,IAAI,KAAA;CAEpD,MAAM,UACL,OAAO,MAAM,YAAY,YAAY,MAAM,WAAW,IAAI,MAAM,UAAU,KAAA;CAC3E,MAAM,QAAQ,CACb,MAAM,SAAS,IACZ,eAAe,EAAE,KAAK,gBAAgB,GAAG,EAAE,OAAO,MAAM,KAAK,IAAI,EAAE,CAAC,IACpE,KAAA,GACH,YAAY,KAAA,IACT,eAAe,EAAE,KAAK,eAAe,GAAG,EAAE,KAAK,YAAY,OAAO,EAAE,CAAC,IACrE,KAAA,CACJ,EAAE,QAAQ,SAAyB,OAAO,SAAS,QAAQ;CAC3D,MAAM,YAAY,aAAa,CAAC,GAAG,QAAQ,UAAU,IAAI;CAGzD,MAAM,cAAc,aADH,UAAU,KAAA,KAAa,UAAU,QAAQ,UAAU,KACxB,kBAAkB,KAAA;CAE9D,MAAM,eAAe,OAAO,UAAyC;EACpE,MAAM,WAAW,MAAM,OAAO,QAAQ;EACtC,IAAI,CAAC,UACJ;EAED,IAAI,YAAY,KAAA,KAAa,SAAS,OAAO,SAAS;GACrD,cAAc,eAAe,EAAE,KAAK,YAAY,GAAG,EAAE,KAAK,YAAY,OAAO,EAAE,CAAC,CAAC;GACjF;EACD;EACA,IAAI,CAAC,YAAY;GAChB,cAAc,EAAE,KAAK,uBAAuB,CAAC;GAC7C;EACD;EACA,aAAa,IAAI;EACjB,cAAc,KAAA,CAAS;EACvB,MAAM,SAAS,MAAM,WAAW;GAAE,MAAM;GAAU;EAAW,CAAC;EAC9D,aAAa,KAAK;EAClB,IAAI,OAAO,IAAI;GACd,YAAY,SAAS,IAAI;GACzB,SAAS,OAAO,EAAE;EACnB,OACC,cAAc,OAAO,WAAW,eAAe;CAEjD;CAEA,MAAM,cAAc;EACnB,YAAY,KAAA,CAAS;EACrB,cAAc,KAAA,CAAS;EACvB,SAAS,EAAE;CACZ;CAEA,OACC,qBAAC,YAAD;EACK;EACJ,OAAO,MAAM;EACb,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACV,QAAQ;EACE;EACK;YAPhB;GASC,oBAAC,SAAD;IACK;IACE;IACN,MAAK;IACG;IACR,UAAU,YAAY;IACtB,WAAW,UAAU;KACpB,aAAkB,KAAK;IACxB;IACQ;IACR,gBAAc,UAAU,SAAS;IACjC,oBAAkB;GAClB,CAAA;GACA,MAAM,SAAS,IAAI,oBAAC,KAAD;IAAG,WAAU;cAAuB,MAAM,KAAK,KAAK;GAAK,CAAA,IAAI;GAChF,YACA,oBAAC,KAAD;IAAG,WAAU;IAAwB,aAAU;cAAS;GAErD,CAAA,IACA;GACH,cACA,qBAAC,KAAD;IAAG,WAAU;cAAb,CACC,oBAAC,QAAD,EAAA,UAAO,YAAkB,CAAA,GACzB,oBAAC,UAAD;KACC,MAAK;KACL,WAAU;KACV,SAAS;KACC;eACV;IAEO,CAAA,CACN;QACA;EACO;;AAEd,CACD"}
@@ -4,6 +4,7 @@ import { consentRenderer } from "./consent.js";
4
4
  import { dateRenderer } from "./date.js";
5
5
  import { emailRenderer } from "./email.js";
6
6
  import { fileRenderer } from "./file.js";
7
+ import { messageRenderer } from "./message.js";
7
8
  import { numberRenderer } from "./number.js";
8
9
  import { repeaterRenderer } from "./repeater.js";
9
10
  import { selectRenderer } from "./select.js";
@@ -18,6 +19,7 @@ const defaultRenderers = {
18
19
  date: dateRenderer,
19
20
  email: emailRenderer,
20
21
  file: fileRenderer,
22
+ message: messageRenderer,
21
23
  number: numberRenderer,
22
24
  repeater: repeaterRenderer,
23
25
  select: selectRenderer,
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../../src/react/renderers/index.ts"],"sourcesContent":["import type { FieldRenderer } from '../contract'\nimport { calculationRenderer } from './calculation'\nimport { checkboxRenderer } from './checkbox'\nimport { consentRenderer } from './consent'\nimport { dateRenderer } from './date'\nimport { emailRenderer } from './email'\nimport { fileRenderer } from './file'\nimport { numberRenderer } from './number'\nimport { repeaterRenderer } from './repeater'\nimport { selectRenderer } from './select'\nimport { textRenderer } from './text'\nimport { textareaRenderer } from './textarea'\n\n/** The built-in field renderers, keyed by field-type slug. Override via the renderer registry. */\nexport const defaultRenderers: Record<string, FieldRenderer> = {\n\tcalculation: calculationRenderer as FieldRenderer,\n\tcheckbox: checkboxRenderer as FieldRenderer,\n\tconsent: consentRenderer as FieldRenderer,\n\tdate: dateRenderer as FieldRenderer,\n\temail: emailRenderer as FieldRenderer,\n\tfile: fileRenderer as FieldRenderer,\n\tnumber: numberRenderer as FieldRenderer,\n\trepeater: repeaterRenderer as FieldRenderer,\n\tselect: selectRenderer as FieldRenderer,\n\ttext: textRenderer as FieldRenderer,\n\ttextarea: textareaRenderer as FieldRenderer,\n}\n"],"mappings":";;;;;;;;;;;;;AAcA,MAAa,mBAAkD;CAC9D,aAAa;CACb,UAAU;CACV,SAAS;CACT,MAAM;CACN,OAAO;CACP,MAAM;CACN,QAAQ;CACR,UAAU;CACV,QAAQ;CACR,MAAM;CACN,UAAU;AACX"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../src/react/renderers/index.ts"],"sourcesContent":["import type { FieldRenderer } from '../contract'\nimport { calculationRenderer } from './calculation'\nimport { checkboxRenderer } from './checkbox'\nimport { consentRenderer } from './consent'\nimport { dateRenderer } from './date'\nimport { emailRenderer } from './email'\nimport { fileRenderer } from './file'\nimport { messageRenderer } from './message'\nimport { numberRenderer } from './number'\nimport { repeaterRenderer } from './repeater'\nimport { selectRenderer } from './select'\nimport { textRenderer } from './text'\nimport { textareaRenderer } from './textarea'\n\n/** The built-in field renderers, keyed by field-type slug. Override via the renderer registry. */\nexport const defaultRenderers: Record<string, FieldRenderer> = {\n\tcalculation: calculationRenderer as FieldRenderer,\n\tcheckbox: checkboxRenderer as FieldRenderer,\n\tconsent: consentRenderer as FieldRenderer,\n\tdate: dateRenderer as FieldRenderer,\n\temail: emailRenderer as FieldRenderer,\n\tfile: fileRenderer as FieldRenderer,\n\tmessage: messageRenderer as FieldRenderer,\n\tnumber: numberRenderer as FieldRenderer,\n\trepeater: repeaterRenderer as FieldRenderer,\n\tselect: selectRenderer as FieldRenderer,\n\ttext: textRenderer as FieldRenderer,\n\ttextarea: textareaRenderer as FieldRenderer,\n}\n"],"mappings":";;;;;;;;;;;;;;AAeA,MAAa,mBAAkD;CAC9D,aAAa;CACb,UAAU;CACV,SAAS;CACT,MAAM;CACN,OAAO;CACP,MAAM;CACN,SAAS;CACT,QAAQ;CACR,UAAU;CACV,QAAQ;CACR,MAAM;CACN,UAAU;AACX"}
@@ -0,0 +1,35 @@
1
+ "use client";
2
+ import { serializeBody } from "../../actions/body/serializeBody.js";
3
+ import { defineFieldRenderer } from "../contract.js";
4
+ import { FormContext } from "../FormContext.js";
5
+ import { jsx } from "react/jsx-runtime";
6
+ import { useContext, useMemo } from "react";
7
+ //#region src/react/renderers/message.tsx
8
+ /**
9
+ * Display-only renderer for a `message` field: serializes the authored `content` rich text to HTML
10
+ * inline between fields. Recall tokens (`{{name}}`) resolve against the current answers (calc
11
+ * values included). Never binds form state; the field writes nothing to submissions.
12
+ */
13
+ const messageRenderer = defineFieldRenderer(({ field }) => {
14
+ const context = useContext(FormContext);
15
+ const values = context?.effectiveValues ?? context?.state.values;
16
+ const html = useMemo(() => {
17
+ const answered = Object.entries(values ?? {}).filter(([, value]) => value != null && value !== "").map(([name, value]) => ({
18
+ field: name,
19
+ value
20
+ }));
21
+ return serializeBody(field.content, {
22
+ values: answered,
23
+ descriptors: []
24
+ });
25
+ }, [field.content, values]);
26
+ if (html === "") return null;
27
+ return /* @__PURE__ */ jsx("div", {
28
+ className: "fb-field__message",
29
+ dangerouslySetInnerHTML: { __html: html }
30
+ });
31
+ });
32
+ //#endregion
33
+ export { messageRenderer };
34
+
35
+ //# sourceMappingURL=message.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"message.js","names":[],"sources":["../../../src/react/renderers/message.tsx"],"sourcesContent":["'use client'\n\nimport { useContext, useMemo } from 'react'\nimport { serializeBody } from '../../actions/body/serializeBody'\nimport type { SubmissionValue } from '../../submissions/types'\nimport { defineFieldRenderer } from '../contract'\nimport { FormContext } from '../FormContext'\n\n/**\n * Display-only renderer for a `message` field: serializes the authored `content` rich text to HTML\n * inline between fields. Recall tokens (`{{name}}`) resolve against the current answers (calc\n * values included). Never binds form state; the field writes nothing to submissions.\n */\nexport const messageRenderer = defineFieldRenderer(({ field }) => {\n\tconst context = useContext(FormContext)\n\tconst values = context?.effectiveValues ?? context?.state.values\n\tconst html = useMemo(() => {\n\t\tconst answered: SubmissionValue[] = Object.entries(values ?? {})\n\t\t\t.filter(([, value]) => value != null && value !== '')\n\t\t\t.map(([name, value]) => ({ field: name, value }))\n\t\treturn serializeBody(field.content, { values: answered, descriptors: [] })\n\t}, [field.content, values])\n\tif (html === '') {\n\t\treturn null\n\t}\n\t// Safe to inject: serializeBody HTML-escapes all text (recall values included) and sanitizes link URLs.\n\t// biome-ignore lint/security/noDangerouslySetInnerHtml: HTML is produced by our escaping serializer, never raw user input\n\treturn <div className=\"fb-field__message\" dangerouslySetInnerHTML={{ __html: html }} />\n})\n"],"mappings":";;;;;;;;;;;;AAaA,MAAa,kBAAkB,qBAAqB,EAAE,YAAY;CACjE,MAAM,UAAU,WAAW,WAAW;CACtC,MAAM,SAAS,SAAS,mBAAmB,SAAS,MAAM;CAC1D,MAAM,OAAO,cAAc;EAC1B,MAAM,WAA8B,OAAO,QAAQ,UAAU,CAAC,CAAC,EAC7D,QAAQ,GAAG,WAAW,SAAS,QAAQ,UAAU,EAAE,EACnD,KAAK,CAAC,MAAM,YAAY;GAAE,OAAO;GAAM;EAAM,EAAE;EACjD,OAAO,cAAc,MAAM,SAAS;GAAE,QAAQ;GAAU,aAAa,CAAC;EAAE,CAAC;CAC1E,GAAG,CAAC,MAAM,SAAS,MAAM,CAAC;CAC1B,IAAI,SAAS,IACZ,OAAO;CAIR,OAAO,oBAAC,OAAD;EAAK,WAAU;EAAoB,yBAAyB,EAAE,QAAQ,KAAK;CAAI,CAAA;AACvF,CAAC"}
@@ -10,6 +10,30 @@ type FormState = {
10
10
  submitAttempted: boolean;
11
11
  submitError?: string;
12
12
  };
13
+ type FormAction = {
14
+ type: 'SET_VALUE';
15
+ name: string;
16
+ value: unknown;
17
+ } | {
18
+ type: 'TOUCH';
19
+ name: string;
20
+ } | {
21
+ type: 'SET_FIELD_ISSUES';
22
+ name: string;
23
+ errors: string[];
24
+ warnings: string[];
25
+ } | {
26
+ type: 'SET_ALL_ISSUES';
27
+ errors: FieldErrors;
28
+ warnings: FieldErrors;
29
+ } | {
30
+ type: 'SUBMIT_START';
31
+ } | {
32
+ type: 'SUBMIT_SUCCESS';
33
+ } | {
34
+ type: 'SUBMIT_ERROR';
35
+ message: string;
36
+ };
13
37
  //#endregion
14
- export { FormState };
38
+ export { FieldErrors, FormAction, FormState };
15
39
  //# sourceMappingURL=state.d.ts.map