@wix/agent-skills 1.19.2 → 1.20.1

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 (215) hide show
  1. package/package.json +1 -1
  2. package/skills/wix-app/references/SERVICE_PLUGIN.md +14 -1
  3. package/skills/wix-app/references/editor-react-component/ANIMATED-COMPONENTS.md +7 -6
  4. package/skills/wix-app/references/service-plugin/ADDITIONAL-FEES.md +4 -0
  5. package/skills/wix-app/references/service-plugin/AVAILABILITY-TIME-SLOTS-CONFIGURATION.md +118 -0
  6. package/skills/wix-app/references/service-plugin/BOOKING-POLICY.md +65 -0
  7. package/skills/wix-app/references/service-plugin/BOOKINGS-PRICING.md +71 -0
  8. package/skills/wix-app/references/service-plugin/BOOKINGS-STAFF-SORTING.md +11 -3
  9. package/skills/wix-app/references/service-plugin/BOOKINGS-VALIDATION.md +137 -0
  10. package/skills/wix-app/references/service-plugin/DISCOUNT-TRIGGERS.md +23 -16
  11. package/skills/wix-app/references/service-plugin/GIFT-CARDS.md +4 -0
  12. package/skills/wix-app/references/service-plugin/PAYMENT-SETTINGS.md +4 -2
  13. package/skills/wix-app/references/service-plugin/REALTIME-PERMISSIONS.md +45 -0
  14. package/skills/wix-app/references/service-plugin/SHIPPING-RATES.md +15 -0
  15. package/skills/wix-app/references/service-plugin/TOOLS_PROVIDER.md +4 -0
  16. package/skills/wix-app/references/service-plugin/VALIDATIONS.md +4 -0
  17. package/skills/wix-headless/references/CAPABILITIES.md +2 -2
  18. package/skills/wix-headless/references/inline-recipes/experience-store.md +4 -3
  19. package/skills/wix-headless/references/inline-recipes/how-to-code-a-store.md +98 -18
  20. package/skills/wix-headless/references/managed/DEPLOYMENT.md +8 -8
  21. package/skills/wix-headless-fast/SKILL.md +200 -34
  22. package/skills/wix-headless-fast/entry/skill.md +10 -2
  23. package/skills/wix-headless-fast/install/attach.mjs +253 -0
  24. package/skills/wix-headless-fast/install/deploy.mjs +109 -9
  25. package/skills/wix-headless-fast/install/fast-path.mjs +28 -31
  26. package/skills/wix-headless-fast/references/blog/INSTRUCTIONS.md +224 -60
  27. package/skills/wix-headless-fast/references/blog/app/components/blog/BlogFeedView.tsx +2 -3
  28. package/skills/wix-headless-fast/references/blog/app/components/blog/PostView.tsx +2 -3
  29. package/skills/wix-headless-fast/references/blog/app/hooks/blog/useBlogFeed.ts +24 -116
  30. package/skills/wix-headless-fast/references/blog/app/hooks/blog/usePost.ts +16 -64
  31. package/skills/wix-headless-fast/references/blog/app/wix/blog/blog-feed-store.ts +137 -0
  32. package/skills/wix-headless-fast/references/blog/app/wix/blog/post-store.ts +78 -0
  33. package/skills/wix-headless-fast/references/blog/app/wix/blog/posts-core.ts +117 -0
  34. package/skills/wix-headless-fast/references/blog/app/wix/blog/posts.ts +13 -60
  35. package/skills/wix-headless-fast/references/blog/app/wix/blog/taxonomy-core.ts +47 -0
  36. package/skills/wix-headless-fast/references/blog/app/wix/blog/taxonomy.ts +10 -28
  37. package/skills/wix-headless-fast/references/blog/app-astro/pages/blog/[...slug].astro +2 -4
  38. package/skills/wix-headless-fast/references/blog/rest/posts.ts +42 -0
  39. package/skills/wix-headless-fast/references/blog/rest/taxonomy.ts +62 -0
  40. package/skills/wix-headless-fast/references/blog/seed/SEED.md +9 -0
  41. package/skills/wix-headless-fast/references/bookings/INSTRUCTIONS.md +179 -33
  42. package/skills/wix-headless-fast/references/bookings/app/components/bookings/ServiceBookingView.tsx +2 -1
  43. package/skills/wix-headless-fast/references/bookings/app/hooks/bookings/useBookingFlow.ts +26 -132
  44. package/skills/wix-headless-fast/references/bookings/app/hooks/bookings/useServices.ts +18 -49
  45. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/booking-core.ts +218 -0
  46. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/booking-flow-store.ts +169 -0
  47. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/booking.ts +57 -142
  48. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/services-core.ts +92 -0
  49. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/services-store.ts +90 -0
  50. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/services.ts +22 -61
  51. package/skills/wix-headless-fast/references/bookings/app/wix/bookings/types.ts +3 -1
  52. package/skills/wix-headless-fast/references/bookings/rest/booking.ts +113 -0
  53. package/skills/wix-headless-fast/references/bookings/rest/services.ts +55 -0
  54. package/skills/wix-headless-fast/references/bookings/seed/SEED.md +10 -0
  55. package/skills/wix-headless-fast/references/cms/INSTRUCTIONS.md +236 -62
  56. package/skills/wix-headless-fast/references/cms/app/hooks/cms/useCollection.ts +19 -52
  57. package/skills/wix-headless-fast/references/cms/app/hooks/cms/useItem.ts +16 -36
  58. package/skills/wix-headless-fast/references/cms/app/wix/cms/collection-store.ts +158 -0
  59. package/skills/wix-headless-fast/references/cms/app/wix/cms/item-store.ts +93 -0
  60. package/skills/wix-headless-fast/references/cms/app/wix/cms/items-core.ts +177 -0
  61. package/skills/wix-headless-fast/references/cms/app/wix/cms/items.ts +13 -41
  62. package/skills/wix-headless-fast/references/cms/rest/items.ts +125 -0
  63. package/skills/wix-headless-fast/references/cms/seed/SEED.md +9 -0
  64. package/skills/wix-headless-fast/references/events/INSTRUCTIONS.md +239 -83
  65. package/skills/wix-headless-fast/references/events/app/components/events/EventConfirmationView.tsx +1 -1
  66. package/skills/wix-headless-fast/references/events/app/components/events/EventRegistrationView.tsx +7 -5
  67. package/skills/wix-headless-fast/references/events/app/components/events/EventsView.tsx +2 -2
  68. package/skills/wix-headless-fast/references/events/app/hooks/events/useEventRegistration.ts +28 -110
  69. package/skills/wix-headless-fast/references/events/app/hooks/events/useEvents.ts +19 -54
  70. package/skills/wix-headless-fast/references/events/app/wix/events/events-core.ts +130 -0
  71. package/skills/wix-headless-fast/references/events/app/wix/events/events-store.ts +97 -0
  72. package/skills/wix-headless-fast/references/events/app/wix/events/events.ts +13 -94
  73. package/skills/wix-headless-fast/references/events/app/wix/events/registration-core.ts +113 -0
  74. package/skills/wix-headless-fast/references/events/app/wix/events/registration-store.ts +156 -0
  75. package/skills/wix-headless-fast/references/events/app/wix/events/registration.ts +38 -69
  76. package/skills/wix-headless-fast/references/events/rest/events.ts +57 -0
  77. package/skills/wix-headless-fast/references/events/rest/registration.ts +83 -0
  78. package/skills/wix-headless-fast/references/events/seed/SEED.md +9 -0
  79. package/skills/wix-headless-fast/references/forms/INSTRUCTIONS.md +214 -26
  80. package/skills/wix-headless-fast/references/forms/app/hooks/forms/useWixForm.ts +35 -267
  81. package/skills/wix-headless-fast/references/forms/app/wix/forms/form-store.ts +332 -0
  82. package/skills/wix-headless-fast/references/forms/app/wix/forms/forms-core.ts +238 -0
  83. package/skills/wix-headless-fast/references/forms/app/wix/forms/forms.ts +28 -243
  84. package/skills/wix-headless-fast/references/forms/app/wix/forms/submissions-core.ts +216 -0
  85. package/skills/wix-headless-fast/references/forms/app/wix/forms/submissions.ts +32 -204
  86. package/skills/wix-headless-fast/references/forms/rest/forms.ts +34 -0
  87. package/skills/wix-headless-fast/references/forms/rest/submissions.ts +56 -0
  88. package/skills/wix-headless-fast/references/forms/seed/SEED.md +8 -0
  89. package/skills/wix-headless-fast/references/forms/seed/seed-forms.mjs +15 -8
  90. package/skills/wix-headless-fast/references/members/INSTRUCTIONS.md +314 -63
  91. package/skills/wix-headless-fast/references/members/app/components/members/LoginForm.tsx +1 -1
  92. package/skills/wix-headless-fast/references/members/app/wix/members/auth-core.ts +105 -0
  93. package/skills/wix-headless-fast/references/members/app/wix/members/auth.ts +36 -65
  94. package/skills/wix-headless-fast/references/members/app/wix/members/client.ts +1 -1
  95. package/skills/wix-headless-fast/references/members/app/wix/members/members-core.ts +43 -0
  96. package/skills/wix-headless-fast/references/members/app/wix/members/members.ts +11 -32
  97. package/skills/wix-headless-fast/references/members/rest/auth.ts +215 -0
  98. package/skills/wix-headless-fast/references/members/rest/members.ts +26 -0
  99. package/skills/wix-headless-fast/references/portfolio/INSTRUCTIONS.md +238 -59
  100. package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/CollectionProjectsView.tsx +2 -2
  101. package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/CollectionsView.tsx +2 -2
  102. package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/GalleryMedia.tsx +35 -0
  103. package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/ProjectDetailView.tsx +8 -38
  104. package/skills/wix-headless-fast/references/portfolio/app/hooks/portfolio/useCollectionProjects.ts +23 -50
  105. package/skills/wix-headless-fast/references/portfolio/app/hooks/portfolio/useCollections.ts +16 -34
  106. package/skills/wix-headless-fast/references/portfolio/app/hooks/portfolio/useProjectDetail.ts +22 -49
  107. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/collection-projects-store.ts +82 -0
  108. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/collections-store.ts +58 -0
  109. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/portfolio-core.ts +177 -0
  110. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/portfolio.ts +31 -118
  111. package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/project-detail-store.ts +81 -0
  112. package/skills/wix-headless-fast/references/portfolio/rest/portfolio.ts +75 -0
  113. package/skills/wix-headless-fast/references/portfolio/seed/SEED.md +8 -0
  114. package/skills/wix-headless-fast/references/pricing-plans/INSTRUCTIONS.md +212 -51
  115. package/skills/wix-headless-fast/references/pricing-plans/app/components/pricing-plans/PlanDetailView.tsx +9 -20
  116. package/skills/wix-headless-fast/references/pricing-plans/app/components/pricing-plans/PlansView.tsx +7 -32
  117. package/skills/wix-headless-fast/references/pricing-plans/app/components/pricing-plans/SubscribeButton.tsx +41 -0
  118. package/skills/wix-headless-fast/references/pricing-plans/app/hooks/pricing-plans/usePlanPurchase.ts +12 -33
  119. package/skills/wix-headless-fast/references/pricing-plans/app/hooks/pricing-plans/usePlans.ts +17 -34
  120. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plan-purchase-store.ts +54 -0
  121. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plans-core.ts +106 -0
  122. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plans-store.ts +64 -0
  123. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plans.ts +7 -55
  124. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/purchase-core.ts +43 -0
  125. package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/purchase.ts +11 -34
  126. package/skills/wix-headless-fast/references/pricing-plans/rest/plans.ts +31 -0
  127. package/skills/wix-headless-fast/references/pricing-plans/rest/purchase.ts +20 -0
  128. package/skills/wix-headless-fast/references/pricing-plans/seed/SEED.md +8 -0
  129. package/skills/wix-headless-fast/references/restaurants/INSTRUCTIONS.md +240 -47
  130. package/skills/wix-headless-fast/references/restaurants/app/components/restaurants/MenuView.tsx +2 -2
  131. package/skills/wix-headless-fast/references/restaurants/app/components/restaurants/OrderCartDrawer.tsx +2 -2
  132. package/skills/wix-headless-fast/references/restaurants/app/hooks/restaurants/useMenus.ts +14 -35
  133. package/skills/wix-headless-fast/references/restaurants/app/hooks/restaurants/useReservation.ts +28 -171
  134. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/menu-core.ts +155 -0
  135. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/menu-store.ts +89 -0
  136. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/menu.ts +23 -117
  137. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/ordering-core.ts +124 -0
  138. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/ordering.ts +45 -114
  139. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/reservation-store.ts +193 -0
  140. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/reservations-core.ts +94 -0
  141. package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/reservations.ts +15 -87
  142. package/skills/wix-headless-fast/references/restaurants/rest/menu.ts +60 -0
  143. package/skills/wix-headless-fast/references/restaurants/rest/ordering.ts +169 -0
  144. package/skills/wix-headless-fast/references/restaurants/rest/reservations.ts +68 -0
  145. package/skills/wix-headless-fast/references/restaurants/seed/SEED.md +8 -0
  146. package/skills/wix-headless-fast/references/shared/SUPPLIED-CONTENT.md +23 -0
  147. package/skills/wix-headless-fast/references/shared/app/wix/media.ts +55 -1
  148. package/skills/wix-headless-fast/references/shared/app/wix/sdk.ts +13 -0
  149. package/skills/wix-headless-fast/references/shared/rest/client.ts +155 -0
  150. package/skills/wix-headless-fast/references/shared/rest/config.ts +3 -0
  151. package/skills/wix-headless-fast/references/shared/rest/media.ts +65 -0
  152. package/skills/wix-headless-fast/references/storefront/INSTRUCTIONS.md +371 -81
  153. package/skills/wix-headless-fast/references/storefront/app/components/storefront/CartDrawer.tsx +37 -3
  154. package/skills/wix-headless-fast/references/storefront/app/components/storefront/FilterPanel.tsx +266 -0
  155. package/skills/wix-headless-fast/references/storefront/app/components/storefront/OptionPicker.tsx +95 -0
  156. package/skills/wix-headless-fast/references/storefront/app/components/storefront/QuickAdd.tsx +136 -0
  157. package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useProductDetail.ts +29 -130
  158. package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useShop.ts +26 -182
  159. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart-core.ts +121 -0
  160. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart.ts +53 -136
  161. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog-core.ts +309 -0
  162. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog.ts +73 -230
  163. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/product-detail-store.ts +147 -0
  164. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/shop-store.ts +289 -0
  165. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/types.ts +62 -4
  166. package/skills/wix-headless-fast/references/storefront/app-astro/layouts/SiteLayout.astro +21 -2
  167. package/skills/wix-headless-fast/references/storefront/rest/cart.ts +132 -0
  168. package/skills/wix-headless-fast/references/storefront/rest/catalog.ts +123 -0
  169. package/skills/wix-headless-fast/references/storefront/seed/SEED.md +25 -5
  170. package/skills/wix-headless-fast/references/storefront/seed/seed-store.mjs +24 -0
  171. package/skills/wix-manage/SKILL.md +4 -1
  172. package/skills/wix-manage/references/ecommerce/ecom-shipping.md +1 -1
  173. package/skills/wix-manage/references/events/create-wix-event.md +46 -0
  174. package/skills/wix-manage/references/events/manage-wix-events.md +51 -1
  175. package/skills/wix-manage/references/google-ads/manage-campaign-lifecycle.md +6 -6
  176. package/skills/wix-manage/references/seo/manage-seo-suggestions.md +351 -0
  177. package/skills/wix-vibe-headless/install/deploy.cjs +33 -2
  178. package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed-with-install.md +1 -1
  179. package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed.md +13 -4
  180. package/skills/wix-vibe-headless/platforms/base44-ecom-light.md +1 -1
  181. package/skills/wix-vibe-headless/platforms/base44.md +19 -4
  182. package/skills/wix-vibe-headless/platforms/generic.md +6 -0
  183. package/skills/wix-vibe-headless/references/_shared/nav/nav.tanstack.js +30 -0
  184. package/skills/wix-vibe-headless/references/_shared/routing.md +86 -0
  185. package/skills/wix-vibe-headless/references/blog/INSTRUCTIONS.md +44 -0
  186. package/skills/wix-vibe-headless/references/blog/app/components/PostCard.jsx +1 -1
  187. package/skills/wix-vibe-headless/references/blog/app/components/PostChips.jsx +1 -1
  188. package/skills/wix-vibe-headless/references/blog/app/pages/CategoryPage.jsx +1 -1
  189. package/skills/wix-vibe-headless/references/blog/app/pages/PostDetail.jsx +1 -1
  190. package/skills/wix-vibe-headless/references/blog/app/pages/TagPage.jsx +1 -1
  191. package/skills/wix-vibe-headless/references/bookings/INSTRUCTIONS.md +42 -0
  192. package/skills/wix-vibe-headless/references/bookings/app/components/ServiceCard.jsx +1 -1
  193. package/skills/wix-vibe-headless/references/bookings/app/pages/ServiceDetail.jsx +1 -1
  194. package/skills/wix-vibe-headless/references/events/INSTRUCTIONS.md +42 -0
  195. package/skills/wix-vibe-headless/references/events/app/components/EventCard.jsx +1 -1
  196. package/skills/wix-vibe-headless/references/events/app/pages/EventDetail.jsx +1 -1
  197. package/skills/wix-vibe-headless/references/members/INSTRUCTIONS.md +42 -0
  198. package/skills/wix-vibe-headless/references/members/app/components/MemberMenu.jsx +1 -1
  199. package/skills/wix-vibe-headless/references/members/app/components/RequireAuth.jsx +1 -1
  200. package/skills/wix-vibe-headless/references/members/app/pages/Login.jsx +1 -1
  201. package/skills/wix-vibe-headless/references/portfolio/INSTRUCTIONS.md +43 -0
  202. package/skills/wix-vibe-headless/references/portfolio/app/components/CollectionCard.jsx +1 -1
  203. package/skills/wix-vibe-headless/references/portfolio/app/components/ProjectCard.jsx +1 -1
  204. package/skills/wix-vibe-headless/references/portfolio/app/pages/CollectionPage.jsx +1 -1
  205. package/skills/wix-vibe-headless/references/portfolio/app/pages/ProjectDetail.jsx +1 -1
  206. package/skills/wix-vibe-headless/references/pricing-plans/INSTRUCTIONS.md +43 -0
  207. package/skills/wix-vibe-headless/references/pricing-plans/app/components/PlanCard.jsx +1 -1
  208. package/skills/wix-vibe-headless/references/pricing-plans/app/pages/PlanDetail.jsx +1 -1
  209. package/skills/wix-vibe-headless/references/restaurants/INSTRUCTIONS.md +36 -0
  210. package/skills/wix-vibe-headless/references/shared/app/lib/nav.js +9 -0
  211. package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +42 -41
  212. package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductCard.js +7 -1
  213. package/skills/wix-vibe-headless/references/storefront/seed/SEED.md +3 -0
  214. package/skills/wix-vibe-headless/references/storefront/seed/seed-store.cjs +6 -0
  215. package/skills/wix-headless-fast/install/pin-agents-md.mjs +0 -57
@@ -0,0 +1,332 @@
1
+ // One form as a framework-free store — the logic behind useWixForm, usable from React (useWixForm
2
+ // wraps it with useSyncExternalStore), from a static page or Vue/Svelte (subscribe and render), or
3
+ // as the specification for a port. Schema in, validated submission out, minus the markup: load the
4
+ // form, hold the visitor's values, validate them against the schema's own rules in our wording,
5
+ // upload attachments, create the submission, and map a rejection back onto the controls.
6
+ //
7
+ // It imports the data layer by names the REST twin exports identically (getForm, uploadFiles,
8
+ // createSubmission, submissionErrors, toSubmissionValues), so the same file runs over the SDK in
9
+ // Astro/React and over REST on a static page.
10
+ //
11
+ // SSR-friendly: pass a server-fetched FormDto as `initialForm` and no client fetch happens;
12
+ // `start()` then does nothing. Without it `start()` loads the schema. One store per mounted form:
13
+ // createFormStore(), not a singleton — a page can hold two forms.
14
+ import { getForm } from "./forms";
15
+ import {
16
+ createSubmission,
17
+ normalizePhone,
18
+ submissionErrors,
19
+ toSubmissionValues,
20
+ uploadFiles,
21
+ } from "./submissions";
22
+ import type { FormDto, FormErrors, FormFieldDto, FormValues } from "./types";
23
+
24
+ /**
25
+ * The key in `errors` for a message that belongs to the FORM rather than one field — a schema
26
+ * that failed to load, or a rejection with no per-field violations. `@` cannot appear in a
27
+ * form `target`, so this never collides with a field's own error.
28
+ */
29
+ export const FORM_ERROR = "@form";
30
+
31
+ /** The empty form: every field at a value of the shape its control binds to. */
32
+ export function defaultValues(fields: FormFieldDto[]): FormValues {
33
+ const values: FormValues = {};
34
+ for (const f of fields) values[f.target] = Array.isArray(f.defaultValue) ? [...f.defaultValue] : f.defaultValue;
35
+ return values;
36
+ }
37
+
38
+ /**
39
+ * Check one field's value against its own schema. A plain function — usable outside the store,
40
+ * and the place to look when a message needs rewording.
41
+ *
42
+ * Every rule comes from the schema, never from a field's NAME. (The classic mistake is keying
43
+ * the email check on `target === "email"`; deriving it from `format` means an owner-added
44
+ * PHONE/URL/length rule is honored with no code change.)
45
+ *
46
+ * A client check LAXER than the server's is worse than none — the visitor then learns about
47
+ * the problem only after a round trip, in the server's wording rather than yours.
48
+ */
49
+ export function validateValue(field: FormFieldDto, value: unknown): string {
50
+ const v = String(value ?? "").trim();
51
+ const rules = field.validation;
52
+
53
+ if (field.required && !v) return `${field.label} is required.`;
54
+ if (!v) return ""; // optional and empty → fine
55
+
56
+ if (field.control === "number" || field.control === "rating") {
57
+ // The control hands back a string, so parse before comparing: "9" > 10 is false but
58
+ // "9" > "10" is true.
59
+ const n = Number(v);
60
+ if (!Number.isFinite(n)) return `${field.label} must be a number.`;
61
+ if (rules.minimum != null && n < rules.minimum) return `${field.label} must be ${rules.minimum} or more.`;
62
+ if (rules.maximum != null && n > rules.maximum) return `${field.label} must be ${rules.maximum} or less.`;
63
+ return "";
64
+ }
65
+
66
+ if (rules.minLength && v.length < rules.minLength)
67
+ return `${field.label} must be at least ${rules.minLength} characters.`;
68
+ if (rules.maxLength && v.length > rules.maxLength)
69
+ return `${field.label} must be at most ${rules.maxLength} characters.`;
70
+ if (rules.format === "EMAIL" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v))
71
+ return "Please enter a valid email address.";
72
+ if (rules.format === "URL" && !/^https?:\/\/.+/.test(v)) return "Please enter a valid URL.";
73
+ // PHONE is E.164 server-side: leading +, country code, digits. Strip formatting first —
74
+ // visitors add spaces, dashes and parens, and rejecting those is a UX bug, not validation.
75
+ if (rules.format === "PHONE" && !/^\+[1-9]\d{6,14}$/.test(normalizePhone(v)))
76
+ return "Use international format, starting with + and the country code.";
77
+ if (rules.pattern && !new RegExp(rules.pattern).test(v))
78
+ return `${field.label} is not in the expected format.`;
79
+ return "";
80
+ }
81
+
82
+ /**
83
+ * One field's error entries, keyed the way the controls are named. A plain field yields at most
84
+ * one (`target`); an ADDRESS yields one per failing subfield (`target/sub`).
85
+ *
86
+ * An address subfield gets the `required` check only — `country` and `subdivision` are
87
+ * country-dependent enums the schema does not enumerate, so their content is the server's call.
88
+ */
89
+ export function errorsForField(field: FormFieldDto, values: FormValues): FormErrors {
90
+ const errors: FormErrors = {};
91
+
92
+ if (field.control === "address") {
93
+ const parts = (values[field.target] ?? {}) as Record<string, unknown>;
94
+ for (const { sub, label, required } of field.addressParts) {
95
+ if (required && !String(parts[sub] ?? "").trim()) errors[`${field.target}/${sub}`] = `${label} is required.`;
96
+ }
97
+ return errors;
98
+ }
99
+
100
+ if (field.control === "file" || field.control === "signature") {
101
+ // Files are File objects, which no string rule can judge — check the count instead.
102
+ const picked = ([] as unknown[]).concat(values[field.target] ?? []).filter(Boolean);
103
+ const limit = field.validation.fileLimit;
104
+ if (field.required && !picked.length) errors[field.target] = `${field.label} is required.`;
105
+ else if (limit && picked.length > limit)
106
+ errors[field.target] = `Attach at most ${limit} file${limit === 1 ? "" : "s"}.`;
107
+ return errors;
108
+ }
109
+
110
+ if (field.inputType === "ARRAY") {
111
+ const picked = (Array.isArray(values[field.target]) ? (values[field.target] as unknown[]) : []).filter(Boolean);
112
+ const { minItems, maxItems } = field.validation;
113
+ if (field.required && !picked.length) errors[field.target] = `${field.label} is required.`;
114
+ else if (minItems && picked.length < minItems) errors[field.target] = `Choose at least ${minItems}.`;
115
+ else if (maxItems && picked.length > maxItems) errors[field.target] = `Choose at most ${maxItems}.`;
116
+ return errors;
117
+ }
118
+
119
+ if (field.control === "checkbox") {
120
+ if (field.required && values[field.target] !== true) errors[field.target] = `${field.label} is required.`;
121
+ return errors;
122
+ }
123
+
124
+ const message = validateValue(field, values[field.target]);
125
+ if (message) errors[field.target] = message;
126
+ return errors;
127
+ }
128
+
129
+ export function errorsForForm(fields: FormFieldDto[], values: FormValues): FormErrors {
130
+ const errors: FormErrors = {};
131
+ for (const field of fields) Object.assign(errors, errorsForField(field, values));
132
+ return errors;
133
+ }
134
+
135
+ /**
136
+ * Move focus to a control by input name. `namedItem` returns a RadioNodeList for a radio or
137
+ * checkbox group and an element for everything else — a guard checking only for an element
138
+ * silently skips every choice group. FOCUS, not scrollIntoView: scrolling moves the viewport and
139
+ * nothing else, leaving a keyboard or screen-reader user where they were.
140
+ */
141
+ export function focusControl(formEl: HTMLFormElement | null, name: string): void {
142
+ const control = formEl?.elements?.namedItem?.(name) as unknown;
143
+ const node =
144
+ typeof RadioNodeList !== "undefined" && control instanceof RadioNodeList
145
+ ? (control[0] as HTMLElement | undefined)
146
+ : (control as HTMLElement | undefined);
147
+ node?.focus?.();
148
+ }
149
+
150
+ export interface FormStoreOptions {
151
+ /** The form to load (the seed's `formId`). Ignored when `initialForm` is given. */
152
+ formId: string;
153
+ /** Server-fetched form (Astro frontmatter) — skips the client fetch entirely. */
154
+ initialForm?: FormDto;
155
+ }
156
+
157
+ /** Everything a form surface renders from. Read it with getState() or through a subscription. */
158
+ export interface FormState {
159
+ /** null while the schema is loading — render a skeleton, not an empty form. */
160
+ form: FormDto | null;
161
+ /** `target` → current value. Arrays for multi-choice and files, objects for an address. */
162
+ values: FormValues;
163
+ /** `target` (or `target/sub`) → a visitor-facing message; errors[FORM_ERROR] is form-level. */
164
+ errors: FormErrors;
165
+ /** Loading the schema, or submitting. */
166
+ loading: boolean;
167
+ }
168
+
169
+ /** A submit event as the store needs it — a React SyntheticEvent or a native Event both fit. */
170
+ export type FormSubmitEvent = { preventDefault?: () => void; currentTarget?: unknown };
171
+
172
+ export interface FormStore {
173
+ getState(): FormState;
174
+ subscribe(listener: () => void): () => void;
175
+ /** Load the schema when no `initialForm` was given. Call once when mounted. */
176
+ start(): void;
177
+ /** Stop reacting; drop a late schema response. */
178
+ stop(): void;
179
+ setValues(next: FormValues | ((prev: FormValues) => FormValues)): void;
180
+ /** One field's value — what a control's change handler calls. */
181
+ setValue(target: string, value: unknown): void;
182
+ /** One field, one address subfield (`target/sub`), or the whole form when called with nothing. */
183
+ validate(target?: string): boolean;
184
+ /**
185
+ * The `onSubmit` handler. Client validation in our wording first (focus lands on the first
186
+ * invalid control), then uploads, then the create. Resolves TRUE when the submission was
187
+ * created — that IS the success signal; the values are back at the schema's defaults.
188
+ */
189
+ submit(event?: FormSubmitEvent): Promise<boolean>;
190
+ }
191
+
192
+ export function createFormStore({ formId, initialForm }: FormStoreOptions): FormStore {
193
+ let form: FormDto | null = initialForm ?? null;
194
+ // The empty form to reset to after a successful submit — the schema's own defaults.
195
+ let empty: FormValues = defaultValues(form?.fields ?? []);
196
+ let values: FormValues = empty;
197
+ let errors: FormErrors = {};
198
+ let loading = !initialForm;
199
+ let started = false;
200
+ let generation = 0;
201
+ const listeners = new Set<() => void>();
202
+ let snapshot: FormState | null = null;
203
+ const emit = () => { snapshot = null; for (const fn of listeners) fn(); };
204
+ const fields = (): FormFieldDto[] => form?.fields ?? [];
205
+
206
+ function getState(): FormState {
207
+ if (snapshot) return snapshot;
208
+ snapshot = { form, values, errors, loading };
209
+ return snapshot;
210
+ }
211
+
212
+ function adopt(loaded: FormDto): void {
213
+ form = loaded;
214
+ // Seed the controls once the schema is in: every control is controlled from the first
215
+ // render, so each target holds a value of the right shape before any of them mount.
216
+ empty = defaultValues(loaded.fields);
217
+ values = empty;
218
+ errors = {};
219
+ loading = false;
220
+ emit();
221
+ }
222
+
223
+ function setErrors(next: FormErrors): void { errors = next; emit(); }
224
+
225
+ return {
226
+ getState,
227
+ subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); },
228
+ start() {
229
+ if (started) return;
230
+ started = true;
231
+ if (form) return; // the SSR pass already answered this
232
+ if (!formId) {
233
+ loading = false;
234
+ errors = { [FORM_ERROR]: "No formId — pass one from the seed's forms map." };
235
+ emit();
236
+ return;
237
+ }
238
+ const id = ++generation;
239
+ loading = true;
240
+ emit();
241
+ getForm(formId)
242
+ .then((loaded) => { if (started && generation === id) adopt(loaded); })
243
+ .catch((e: unknown) => {
244
+ if (!started || generation !== id) return;
245
+ // Fail loudly. A form that cannot load is a setup problem — never fall back to a
246
+ // hand-built form, which would drop real enquiries silently.
247
+ loading = false;
248
+ errors = { [FORM_ERROR]: e instanceof Error ? e.message : "Could not load the form." };
249
+ emit();
250
+ });
251
+ },
252
+ stop() { started = false; generation++; },
253
+ setValues(next) {
254
+ values = typeof next === "function" ? next(values) : next;
255
+ emit();
256
+ },
257
+ setValue(target, value) {
258
+ values = { ...values, [target]: value };
259
+ emit();
260
+ },
261
+ validate(target) {
262
+ if (!target) {
263
+ const all = errorsForForm(fields(), values);
264
+ setErrors(all);
265
+ return Object.keys(all).length === 0;
266
+ }
267
+ const key = String(target);
268
+ const field = fields().find((f) => f.target === key.split("/")[0]);
269
+ if (!field) return true;
270
+
271
+ // The keys this call owns, so a re-check CLEARS what it fixed as well as flagging what it
272
+ // did not: one key for a plain field, or every subfield when an address itself is named.
273
+ const owned = key.includes("/")
274
+ ? [key]
275
+ : field.control === "address"
276
+ ? field.addressParts.map(({ sub }) => `${field.target}/${sub}`)
277
+ : [field.target];
278
+
279
+ const found = errorsForField(field, values);
280
+ const next = { ...errors };
281
+ for (const k of owned) {
282
+ delete next[k];
283
+ if (found[k]) next[k] = found[k];
284
+ }
285
+ setErrors(next);
286
+ return owned.every((k) => !found[k]);
287
+ },
288
+ async submit(event) {
289
+ event?.preventDefault?.();
290
+ // Capture the <form> NOW: React clears currentTarget once the handler returns, so reading
291
+ // it after the await below (to focus a server-rejected control) comes back null.
292
+ const formEl = (event?.currentTarget ?? null) as HTMLFormElement | null;
293
+ if (!form) return false;
294
+ const current = form;
295
+ const currentFields = current.fields;
296
+
297
+ // Client pass first, so the visitor gets inline feedback in OUR wording before a round trip.
298
+ const clientErrors = errorsForForm(currentFields, values);
299
+ if (Object.keys(clientErrors).length) {
300
+ setErrors(clientErrors);
301
+ focusControl(formEl, Object.keys(clientErrors)[0]);
302
+ return false;
303
+ }
304
+
305
+ loading = true;
306
+ emit();
307
+ try {
308
+ // Attachments go up FIRST — a File is not something the submission API takes, and its
309
+ // value is the upload URL this hands back. No file fields → nothing happens here.
310
+ const uploaded = await uploadFiles(current.id, currentFields, values);
311
+ values = uploaded; // keep the URLs, so a rejection on another field never re-uploads
312
+ emit();
313
+ await createSubmission(current.id, toSubmissionValues(currentFields, uploaded));
314
+ errors = {};
315
+ values = empty; // back to the schema's defaults, ready for another
316
+ return true;
317
+ } catch (e) {
318
+ const mapped = submissionErrors(e, currentFields);
319
+ if (Object.keys(mapped).length) {
320
+ errors = mapped;
321
+ focusControl(formEl, Object.keys(mapped)[0]);
322
+ } else {
323
+ errors = { [FORM_ERROR]: e instanceof Error ? e.message : "Could not send the form. Please try again." };
324
+ }
325
+ return false;
326
+ } finally {
327
+ loading = false;
328
+ emit();
329
+ }
330
+ },
331
+ };
332
+ }
@@ -0,0 +1,238 @@
1
+ // Form-schema rules and DTO mapping — transport-agnostic, imported by BOTH transports: ./forms.ts
2
+ // (the SDK, managed Astro and React) and the REST twin in references/forms/rest/forms.ts (fetch, a
3
+ // static site or a port to another language). Every rule about how a raw Form Schemas v4 entity
4
+ // flattens into a FormDto lives HERE, once. A raw form may come from the SDK (`_id`) or from REST
5
+ // (`id`); the mappers accept both. Imports are type-only so a strip to JS emits no imports.
6
+ // docs: https://dev.wix.com/docs/api-reference/crm/forms/form-schemas/about-form-fields.md
7
+ import type {
8
+ FormAddressPart,
9
+ FormChoice,
10
+ FormControl,
11
+ FormDto,
12
+ FormFieldDto,
13
+ FormValidation,
14
+ } from "./types";
15
+
16
+ /** A raw Form Schemas v4 entity as either transport returns it. */
17
+ export type Raw = Record<string, any>;
18
+
19
+ /** The Wix Forms app namespace. Every form this vertical touches lives here. */
20
+ export const FORMS_NAMESPACE = "wix.form_app.form";
21
+
22
+ const id = (raw: Raw | undefined): string => raw?._id ?? raw?.id ?? "";
23
+
24
+ // A field's settings nest TWO levels deep, under blocks named after its own enums:
25
+ // inputOptions.<inputType block>.<componentType block>.label
26
+ // These tables resolve those names. Spelling them at a call site is one typo away from
27
+ // silently reading back no label at all — which is the whole reason this file exists.
28
+ const INPUT_BLOCK: Record<string, string> = {
29
+ STRING: "stringOptions",
30
+ NUMBER: "numberOptions",
31
+ BOOLEAN: "booleanOptions",
32
+ ARRAY: "arrayOptions",
33
+ ADDRESS: "addressOptions",
34
+ WIX_FILE: "wixFileOptions",
35
+ PAYMENT: "paymentOptions",
36
+ SCHEDULING: "schedulingOptions",
37
+ };
38
+
39
+ const COMPONENT_BLOCK: Record<string, string> = {
40
+ TEXT_INPUT: "textInputOptions",
41
+ NUMBER_INPUT: "numberInputOptions",
42
+ RATING_INPUT: "ratingInputOptions",
43
+ PHONE_INPUT: "phoneInputOptions",
44
+ DATE_INPUT: "dateInputOptions",
45
+ DATE_PICKER: "datePickerOptions",
46
+ DATE_TIME: "dateTimeOptions",
47
+ TIME_INPUT: "timeInputOptions",
48
+ CHECKBOX: "checkboxOptions",
49
+ CHECKBOX_GROUP: "checkboxGroupOptions",
50
+ RADIO_GROUP: "radioGroupOptions",
51
+ DROPDOWN: "dropdownOptions",
52
+ TAGS: "tagsOptions",
53
+ MULTILINE_ADDRESS: "multilineAddressOptions",
54
+ FILE_UPLOAD: "fileUploadOptions",
55
+ SIGNATURE: "signatureOptions",
56
+ FIXED_PAYMENT: "fixedPaymentOptions",
57
+ PAYMENT_INPUT: "paymentInputOptions",
58
+ DONATION_INPUT: "donationInputOptions",
59
+ APPOINTMENT: "appointmentOptions",
60
+ SERVICES_DROPDOWN: "servicesDropdownOptions",
61
+ SERVICES_CHECKBOX_GROUP: "servicesCheckboxGroupOptions",
62
+ };
63
+
64
+ // componentType → the control to render. Several field kinds share one component (short and
65
+ // long answer are both TEXT_INPUT), so `format` and `inputType` refine it below.
66
+ const CONTROL: Record<string, FormControl> = {
67
+ TEXT_INPUT: "text",
68
+ NUMBER_INPUT: "number",
69
+ RATING_INPUT: "rating",
70
+ PHONE_INPUT: "phone",
71
+ DATE_INPUT: "date",
72
+ DATE_PICKER: "date",
73
+ DATE_TIME: "datetime",
74
+ TIME_INPUT: "time",
75
+ CHECKBOX: "checkbox",
76
+ CHECKBOX_GROUP: "checkboxGroup",
77
+ RADIO_GROUP: "radio",
78
+ DROPDOWN: "select",
79
+ TAGS: "tags",
80
+ MULTILINE_ADDRESS: "address",
81
+ FILE_UPLOAD: "file",
82
+ SIGNATURE: "signature",
83
+ FIXED_PAYMENT: "payment",
84
+ PAYMENT_INPUT: "payment",
85
+ DONATION_INPUT: "payment",
86
+ SERVICES_DROPDOWN: "select",
87
+ SERVICES_CHECKBOX_GROUP: "checkboxGroup",
88
+ APPOINTMENT: "appointment",
89
+ };
90
+
91
+ // An enum missing from a table means Wix added a type. Say so ONCE — a silent empty block
92
+ // renders a field labelled by its storage key with none of its settings.
93
+ const warned = new Set<string>();
94
+ function blockName(table: Record<string, string>, key: string | undefined, what: string): string | undefined {
95
+ const name = key ? table[key] : undefined;
96
+ if (!name && key && !warned.has(what + key)) {
97
+ warned.add(what + key);
98
+ console.warn(
99
+ `forms: no ${what} block mapped for "${key}" — that field renders without its label or ` +
100
+ `options. Add it to the table in wix/forms/forms-core.ts.`,
101
+ );
102
+ }
103
+ return name;
104
+ }
105
+
106
+ /** A BOOLEAN consent field labels itself with Ricos rich content; take its plain text. */
107
+ export function plainText(label: unknown): string {
108
+ if (typeof label === "string") return label;
109
+ const nodes = (label as Raw)?.nodes;
110
+ if (!Array.isArray(nodes)) return "";
111
+ const walk = (list: Raw[]): string =>
112
+ list
113
+ .map((n) => (n?.textData?.text ?? "") + (Array.isArray(n?.nodes) ? walk(n.nodes) : ""))
114
+ .join("");
115
+ return walk(nodes).trim();
116
+ }
117
+
118
+ /** `postalCode` → "Postal code". A subfield is a key; the schema carries no label for it. */
119
+ export function humanizeSub(sub: string): string {
120
+ const words = sub.replace(/([A-Z])|(\d+)/g, " $1$2").toLowerCase().trim();
121
+ return words.charAt(0).toUpperCase() + words.slice(1);
122
+ }
123
+
124
+ export function toField(raw: Raw): FormFieldDto {
125
+ const input = raw.inputOptions ?? {};
126
+ const inputType: string = input.inputType ?? "";
127
+ const optionsBlock: Raw = input[blockName(INPUT_BLOCK, inputType, "inputType") ?? ""] ?? {};
128
+ const componentType: string = optionsBlock.componentType ?? "";
129
+ const component: Raw = optionsBlock[blockName(COMPONENT_BLOCK, componentType, "componentType") ?? ""] ?? {};
130
+ const rules: Raw = optionsBlock.validation ?? {};
131
+
132
+ const validation: FormValidation = {
133
+ ...(rules.format ? { format: rules.format } : {}),
134
+ ...(rules.minLength != null ? { minLength: rules.minLength } : {}),
135
+ ...(rules.maxLength != null ? { maxLength: rules.maxLength } : {}),
136
+ ...(rules.pattern ? { pattern: rules.pattern } : {}),
137
+ // JSON-Schema spelling everywhere else in this block; minValue/maxValue is read too since
138
+ // the public reference documents neither and an older schema may carry them.
139
+ ...((rules.minimum ?? rules.minValue) != null ? { minimum: rules.minimum ?? rules.minValue } : {}),
140
+ ...((rules.maximum ?? rules.maxValue) != null ? { maximum: rules.maximum ?? rules.maxValue } : {}),
141
+ ...(rules.fileLimit != null ? { fileLimit: rules.fileLimit } : {}),
142
+ ...(rules.minItems != null ? { minItems: rules.minItems } : {}),
143
+ ...(rules.maxItems != null ? { maxItems: rules.maxItems } : {}),
144
+ };
145
+
146
+ // TEXT_INPUT covers short answer, long answer, email, url and phone — and a long answer is
147
+ // NOT flagged on the component (no `multiline`, no `numberOfLines`). Only `identifier` says
148
+ // TEXT_AREA, which is why it is carried on the DTO.
149
+ const identifier: string = raw.identifier ?? "";
150
+ let control: FormControl = CONTROL[componentType] ?? "unknown";
151
+ // A product list is a PAYMENT field wearing a CHECKBOX_GROUP component — typed by its
152
+ // component it would look like a multi-choice and be bound to an array of strings, when what
153
+ // it submits is a payment structure. The input type wins.
154
+ if (inputType === "PAYMENT") control = "payment";
155
+ if (control === "text") {
156
+ if (identifier === "TEXT_AREA" || component.multiline || component.numberOfLines > 1) control = "textarea";
157
+ else if (rules.format === "EMAIL") control = "email";
158
+ else if (rules.format === "URL") control = "url";
159
+ else if (rules.format === "PHONE") control = "phone";
160
+ }
161
+
162
+ const choices: FormChoice[] = Array.isArray(component.options)
163
+ ? component.options.map((o: Raw) => ({ value: String(o.value), label: String(o.label ?? o.value) }))
164
+ : [];
165
+
166
+ const addressParts: FormAddressPart[] = Object.entries((rules.fields ?? {}) as Raw)
167
+ // Only addressLine2 carries a visibility setting; hide it when the owner turned it off.
168
+ .filter(([sub]) => component.fieldSettings?.[sub]?.show !== false)
169
+ .map(([sub, cfg]) => ({ sub, label: humanizeSub(sub), required: (cfg as Raw)?.required ?? false }));
170
+
171
+ const target: string = input.target ?? "";
172
+ const label = plainText(component.label) || target;
173
+
174
+ // Every control is controlled from the first render, so each field starts at a value of the
175
+ // right SHAPE — [] for multi-choice and files, {} for an address, the owner's prefill else "".
176
+ // Wix spells the prefill `default` on most components and `defaultValue` on a rating.
177
+ const prefill = component.default ?? component.defaultValue;
178
+ const defaultValue =
179
+ control === "address" ? {} :
180
+ inputType === "ARRAY" || inputType === "WIX_FILE" ? [] :
181
+ // a consent checkbox is a BOOLEAN: "" would bind to `checked` as false and work by
182
+ // accident, but the DTO promises the shape its control writes back
183
+ inputType === "BOOLEAN" ? ((prefill as boolean | undefined) ?? false) :
184
+ (prefill as string | number | boolean | undefined) ?? "";
185
+
186
+ return {
187
+ target,
188
+ label,
189
+ control,
190
+ required: input.required ?? false,
191
+ ...(component.placeholder ? { placeholder: String(component.placeholder) } : {}),
192
+ ...(component.description ? { description: plainText(component.description) } : {}),
193
+ defaultValue,
194
+ choices,
195
+ addressParts,
196
+ validation,
197
+ ...(component.defaultCountryCode || rules.phoneOptions?.allowedCountryCodes?.[0]
198
+ ? { phoneCountry: component.defaultCountryCode ?? rules.phoneOptions.allowedCountryCodes[0] }
199
+ : {}),
200
+ identifier,
201
+ inputType,
202
+ componentType,
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Display order comes from `steps[].layout`, NOT from `formFields[]` array order. Sort WITHIN
208
+ * each step and concatenate in step order: `row` restarts at 0 in every step, so one sort
209
+ * across the flattened list interleaves them. A field the owner never placed sorts LAST — it
210
+ * still stores values. The submit button is `fieldType: "DISPLAY"`, so filtering to INPUT
211
+ * drops it automatically.
212
+ */
213
+ export function orderedInputs(raw: Raw): Raw[] {
214
+ const order = new Map<string, number>(
215
+ ((raw.steps ?? []) as Raw[])
216
+ .flatMap((s) =>
217
+ ((s.layout?.large?.items ?? s.layout?.medium?.items ?? s.layout?.small?.items ?? []) as Raw[])
218
+ .slice()
219
+ .sort((a, b) => a.row - b.row || a.column - b.column),
220
+ )
221
+ .map((item, i) => [item.fieldId as string, i] as const),
222
+ );
223
+ return ((raw.formFields ?? []) as Raw[])
224
+ .filter((f) => f.fieldType === "INPUT" && !f.hidden)
225
+ .sort((a, b) => (order.get(a.id) ?? Infinity) - (order.get(b.id) ?? Infinity));
226
+ }
227
+
228
+ export function toForm(raw: Raw): FormDto {
229
+ const submit = ((raw.formFields ?? []) as Raw[]).find((f) => f.identifier === "SUBMIT_BUTTON");
230
+ return {
231
+ id: id(raw),
232
+ name: raw.name ?? "",
233
+ fields: orderedInputs(raw).map(toField),
234
+ // The submit wording lives on a DISPLAY field, nested under pageNavigationOptions because
235
+ // one control drives both multi-page navigation and the final submit.
236
+ submitText: submit?.displayOptions?.pageNavigationOptions?.submitText ?? "",
237
+ };
238
+ }