@wix/agent-skills 1.15.1 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. package/README.md +3 -7
  2. package/package.json +1 -1
  3. package/skills/wix-app/SKILL.md +4 -1
  4. package/skills/wix-app/references/APP_TOOLS.md +134 -0
  5. package/skills/wix-app/references/CUSTOM_ELEMENT_WIDGET.md +1 -1
  6. package/skills/wix-app/references/DASHBOARD_PAGE.md +0 -10
  7. package/skills/wix-app/references/DATA_COLLECTION.md +1 -1
  8. package/skills/wix-app/references/EDITOR_REACT_COMPONENT.md +28 -9
  9. package/skills/wix-app/references/SERVICE_PLUGIN.md +12 -10
  10. package/skills/wix-app/references/app-tools/TOOLS.md +75 -0
  11. package/skills/wix-app/references/dashboard-page/DASHBOARD_API.md +2 -2
  12. package/skills/wix-app/references/editor-react-component/A11Y-REVIEW-REFERENCE.md +172 -0
  13. package/skills/wix-app/references/editor-react-component/A11Y-REVIEW.md +138 -0
  14. package/skills/wix-app/references/editor-react-component/ANIMATED-COMPONENTS.md +23 -2
  15. package/skills/wix-app/references/editor-react-component/BRANDED-COMPONENTS.md +29 -0
  16. package/skills/wix-app/references/editor-react-component/COMPONENT-API.md +1 -1
  17. package/skills/wix-app/references/editor-react-component/CSS-GUIDELINES.md +27 -2
  18. package/skills/wix-app/references/editor-react-component/DESIGN-STATES.md +5 -3
  19. package/skills/wix-app/references/editor-react-component/DIRECTIONALITY.md +18 -1
  20. package/skills/wix-app/references/editor-react-component/PARTS.md +15 -2
  21. package/skills/wix-app/references/editor-react-component/REACT-GUIDELINES.md +2 -3
  22. package/skills/wix-app/references/service-plugin/BOOKINGS-STAFF-SORTING.md +13 -9
  23. package/skills/wix-app/references/service-plugin/DISCOUNT-TRIGGERS.md +4 -0
  24. package/skills/wix-app/references/service-plugin/GIFT-CARDS.md +4 -0
  25. package/skills/wix-app/references/service-plugin/PAYMENT-SETTINGS.md +3 -1
  26. package/skills/wix-app/references/service-plugin/SHIPPING-RATES.md +4 -0
  27. package/skills/wix-app/references/service-plugin/TOOLS_PROVIDER.md +54 -0
  28. package/skills/wix-app/references/stores/QUERY.md +32 -0
  29. package/skills/wix-docs/SKILL.md +35 -3
  30. package/skills/wix-headless/SKILL.md +3 -1
  31. package/skills/wix-headless/references/CONTENT.md +36 -0
  32. package/skills/wix-headless/references/DESIGN.md +54 -0
  33. package/skills/wix-headless/references/DISCOVERY.md +24 -0
  34. package/skills/wix-headless/references/IMAGE_GENERATION.md +10 -0
  35. package/skills/wix-headless/references/inline-recipes/how-to-code-a-store.md +3 -2
  36. package/skills/wix-headless/references/inline-recipes/setup-online-store.md +32 -23
  37. package/skills/wix-headless/references/managed/CONNECT.md +1 -1
  38. package/skills/wix-headless/references/managed/CREATE.md +14 -0
  39. package/skills/wix-headless/references/managed/DEPLOYMENT.md +25 -0
  40. package/skills/wix-manage/SKILL.md +123 -7
  41. package/skills/wix-manage/references/analytics/analytics-dashboard-navigation.md +41 -0
  42. package/skills/wix-manage/references/app-installation/app-installation-dashboard-navigation.md +28 -0
  43. package/skills/wix-manage/references/blog/blog-dashboard-navigation.md +56 -0
  44. package/skills/wix-manage/references/blog/how-to-create-blog-posts.md +31 -6
  45. package/skills/wix-manage/references/bookings/booking-service-policy-setup.md +3 -1
  46. package/skills/wix-manage/references/bookings/bookings-dashboard-navigation.md +63 -0
  47. package/skills/wix-manage/references/bookings/diagnose-availability-issues.md +10 -1
  48. package/skills/wix-manage/references/cms/cms-dashboard-navigation.md +44 -0
  49. package/skills/wix-manage/references/cms/cms-data-items-crud.md +31 -3
  50. package/skills/wix-manage/references/cms/cms-publishing-flow.md +287 -0
  51. package/skills/wix-manage/references/cms/cms-schema-management.md +27 -1
  52. package/skills/wix-manage/references/contacts/contacts-dashboard-navigation.md +46 -0
  53. package/skills/wix-manage/references/contacts/create-a-contact.md +82 -0
  54. package/skills/wix-manage/references/contacts/update-a-contact.md +119 -0
  55. package/skills/wix-manage/references/dashboard-navigation/dashboard-navigation.md +82 -0
  56. package/skills/wix-manage/references/dashboard-navigation/routes.json +1733 -0
  57. package/skills/wix-manage/references/domains/domains-dashboard-navigation.md +32 -0
  58. package/skills/wix-manage/references/ecommerce/ecom-pricing.md +4 -4
  59. package/skills/wix-manage/references/ecommerce/ecom-shipping.md +1 -1
  60. package/skills/wix-manage/references/ecommerce/gift-cards/ecom-gift-cards-goal-sell-gift-cards.md +338 -0
  61. package/skills/wix-manage/references/ecommerce/pricing-promotions/ecom-pricing-create-discount-rule.md +42 -78
  62. package/skills/wix-manage/references/ecommerce/pricing-promotions/ecom-pricing-flow-bundle-and-save.md +1 -1
  63. package/skills/wix-manage/references/ecommerce/pricing-promotions/ecom-pricing-flow-stock-mover.md +1 -1
  64. package/skills/wix-manage/references/ecommerce/pricing-promotions/ecom-pricing-flow-upsell-boost.md +1 -1
  65. package/skills/wix-manage/references/ecommerce/recommend-ecommerce-strategy.md +90 -92
  66. package/skills/wix-manage/references/events/create-wix-event.md +176 -0
  67. package/skills/wix-manage/references/events/manage-wix-events.md +174 -0
  68. package/skills/wix-manage/references/forms/forms-dashboard-navigation.md +44 -0
  69. package/skills/wix-manage/references/get-paid/get-paid-dashboard-navigation.md +52 -0
  70. package/skills/wix-manage/references/google-ads/billing-and-payment.md +80 -0
  71. package/skills/wix-manage/references/google-ads/create-performance-max-campaign.md +212 -0
  72. package/skills/wix-manage/references/google-ads/get-campaign-suggestions.md +110 -0
  73. package/skills/wix-manage/references/google-ads/google-ads-dashboard-navigation.md +25 -0
  74. package/skills/wix-manage/references/google-ads/install-and-create-account.md +155 -0
  75. package/skills/wix-manage/references/google-ads/manage-campaign-lifecycle.md +63 -0
  76. package/skills/wix-manage/references/google-ads/query-campaign-analytics.md +155 -0
  77. package/skills/wix-manage/references/marketing/marketing-dashboard-navigation.md +52 -0
  78. package/skills/wix-manage/references/pricing-plans/create-and-update-pricing-plans.md +99 -1
  79. package/skills/wix-manage/references/pricing-plans/pricing-plans-dashboard-navigation.md +45 -0
  80. package/skills/wix-manage/references/restaurants/restaurants-dashboard-navigation.md +68 -0
  81. package/skills/wix-manage/references/restaurants/wix-restaurants-setup.md +94 -125
  82. package/skills/wix-manage/references/site-properties/change-payment-currency-site-properties.md +48 -9
  83. package/skills/wix-manage/references/site-properties/site-properties-dashboard-navigation.md +31 -0
  84. package/skills/wix-manage/references/sites/create-headless-site.md +52 -0
  85. package/skills/wix-manage/references/sites/create-site-from-template.md +2 -25
  86. package/skills/wix-manage/references/sites/site-import.md +163 -190
  87. package/skills/wix-manage/references/sites/sites-dashboard-navigation.md +29 -0
  88. package/skills/wix-manage/references/stores/add-store-pages-to-site.md +17 -1
  89. package/skills/wix-manage/references/stores/find-products-query-and-search-catalog-v3.md +26 -8
  90. package/skills/wix-manage/references/stores/stores-dashboard-navigation.md +65 -0
  91. package/skills/wix-manage/references/stores/update-product-with-options.md +39 -7
  92. package/skills/wix-replatform/.publish-manifest.json +8 -0
  93. package/skills/wix-replatform/SKILL.md +693 -0
  94. package/skills/wix-replatform/lib/artifact-freshness.js +215 -0
  95. package/skills/wix-replatform/lib/completion-report.js +200 -0
  96. package/skills/wix-replatform/lib/config-env.js +59 -0
  97. package/skills/wix-replatform/lib/execution-manifest.js +185 -0
  98. package/skills/wix-replatform/lib/execution-state.js +173 -0
  99. package/skills/wix-replatform/lib/import-recovery.js +313 -0
  100. package/skills/wix-replatform/lib/local-state.js +642 -0
  101. package/skills/wix-replatform/lib/orchestration-preflight.js +143 -0
  102. package/skills/wix-replatform/lib/orchestration-router.js +122 -0
  103. package/skills/wix-replatform/lib/orchestration-state.js +455 -0
  104. package/skills/wix-replatform/lib/progress-log.js +224 -0
  105. package/skills/wix-replatform/lib/progress-log.sh +140 -0
  106. package/skills/wix-replatform/lib/url-preservation-state.js +391 -0
  107. package/skills/wix-replatform/resources/rp-discovery/SKILL.md +184 -0
  108. package/skills/wix-replatform/resources/rp-execute-import/SKILL.md +529 -0
  109. package/skills/wix-replatform/resources/rp-execute-setup/SKILL.md +316 -0
  110. package/skills/wix-replatform/resources/rp-execute-setup/scripts/mint-token.sh +64 -0
  111. package/skills/wix-replatform/resources/rp-import-codegen/SKILL.md +1114 -0
  112. package/skills/wix-replatform/resources/rp-mapper/SKILL.md +430 -0
  113. package/skills/wix-replatform/resources/rp-mapper/lib/mapping-resolve.js +217 -0
  114. package/skills/wix-replatform/resources/rp-mapper/scripts/resolve-mapping.js +148 -0
  115. package/skills/wix-replatform/resources/rp-mapper/wix-business-solutions-taxonomy.md +378 -0
  116. package/skills/wix-replatform/resources/rp-setup-discovery/SKILL.md +183 -0
  117. package/skills/wix-replatform/resources/rp-source-csv/SKILL.md +478 -0
  118. package/skills/wix-replatform/resources/rp-source-csv/lib/canonical-record.js +136 -0
  119. package/skills/wix-replatform/resources/rp-source-csv/lib/csv-fileset.js +0 -0
  120. package/skills/wix-replatform/resources/rp-source-csv/lib/csv-fingerprint.js +310 -0
  121. package/skills/wix-replatform/resources/rp-source-csv/lib/csv-layout.js +1081 -0
  122. package/skills/wix-replatform/resources/rp-source-csv/lib/csv-parse.js +618 -0
  123. package/skills/wix-replatform/resources/rp-source-csv/scripts/csv-discovery.js +949 -0
  124. package/skills/wix-replatform/resources/rp-source-csv/source-schema.example.json +196 -0
  125. package/skills/wix-replatform/resources/rp-source-csv/vendors/bigcommerce.json +66 -0
  126. package/skills/wix-replatform/resources/rp-source-csv/vendors/magento.json +60 -0
  127. package/skills/wix-replatform/resources/rp-source-csv/vendors/shopify.json +95 -0
  128. package/skills/wix-replatform/resources/rp-source-csv/vendors/woocommerce.json +82 -0
  129. package/skills/wix-replatform/resources/rp-source-wordpress/SKILL.md +256 -0
  130. package/skills/wix-replatform/resources/rp-source-wordpress/lib/wp-http.js +249 -0
  131. package/skills/wix-replatform/resources/rp-source-wordpress/lib/wp-route-classifier.js +444 -0
  132. package/skills/wix-replatform/resources/rp-source-wordpress/scripts/wp-discovery.js +774 -0
  133. package/skills/wix-replatform/resources/rp-source-wordpress/source-schema.example.json +71 -0
  134. package/skills/wix-replatform/resources/rp-target-wix/SKILL.md +632 -0
  135. package/skills/wix-replatform/resources/rp-target-wix/SPEC-product-reviews-and-blog-authorship.md +330 -0
  136. package/skills/wix-replatform/resources/rp-target-wix/domains/README.md +21 -0
  137. package/skills/wix-replatform/resources/rp-target-wix/domains/blog/domain.json +19 -0
  138. package/skills/wix-replatform/resources/rp-target-wix/domains/blog/entities/category.json +37 -0
  139. package/skills/wix-replatform/resources/rp-target-wix/domains/blog/entities/comment.json +37 -0
  140. package/skills/wix-replatform/resources/rp-target-wix/domains/blog/entities/post.json +39 -0
  141. package/skills/wix-replatform/resources/rp-target-wix/domains/blog/entities/tag.json +35 -0
  142. package/skills/wix-replatform/resources/rp-target-wix/domains/cms/domain.json +14 -0
  143. package/skills/wix-replatform/resources/rp-target-wix/domains/cms/entities/collection.json +35 -0
  144. package/skills/wix-replatform/resources/rp-target-wix/domains/cms/entities/data-item.json +36 -0
  145. package/skills/wix-replatform/resources/rp-target-wix/domains/cms/entities/reference.json +35 -0
  146. package/skills/wix-replatform/resources/rp-target-wix/domains/crm/domain.json +14 -0
  147. package/skills/wix-replatform/resources/rp-target-wix/domains/crm/entities/contact.json +131 -0
  148. package/skills/wix-replatform/resources/rp-target-wix/domains/crm/entities/email-subscription.json +37 -0
  149. package/skills/wix-replatform/resources/rp-target-wix/domains/crm/entities/label.json +37 -0
  150. package/skills/wix-replatform/resources/rp-target-wix/domains/ecom/domain.json +21 -0
  151. package/skills/wix-replatform/resources/rp-target-wix/domains/ecom/entities/order-line-item.json +37 -0
  152. package/skills/wix-replatform/resources/rp-target-wix/domains/ecom/entities/order-refund.json +37 -0
  153. package/skills/wix-replatform/resources/rp-target-wix/domains/ecom/entities/order.json +97 -0
  154. package/skills/wix-replatform/resources/rp-target-wix/domains/index.json +528 -0
  155. package/skills/wix-replatform/resources/rp-target-wix/domains/media/domain.json +14 -0
  156. package/skills/wix-replatform/resources/rp-target-wix/domains/media/entities/media-file.json +38 -0
  157. package/skills/wix-replatform/resources/rp-target-wix/domains/media/entities/media-folder.json +37 -0
  158. package/skills/wix-replatform/resources/rp-target-wix/domains/members/domain.json +13 -0
  159. package/skills/wix-replatform/resources/rp-target-wix/domains/members/entities/badge.json +37 -0
  160. package/skills/wix-replatform/resources/rp-target-wix/domains/members/entities/member.json +53 -0
  161. package/skills/wix-replatform/resources/rp-target-wix/domains/schema.json +84 -0
  162. package/skills/wix-replatform/resources/rp-target-wix/domains/stores/domain.json +25 -0
  163. package/skills/wix-replatform/resources/rp-target-wix/domains/stores/entities/category.json +37 -0
  164. package/skills/wix-replatform/resources/rp-target-wix/domains/stores/entities/coupon.json +37 -0
  165. package/skills/wix-replatform/resources/rp-target-wix/domains/stores/entities/inventory-item.json +38 -0
  166. package/skills/wix-replatform/resources/rp-target-wix/domains/stores/entities/product-tag.json +37 -0
  167. package/skills/wix-replatform/resources/rp-target-wix/domains/stores/entities/product.json +90 -0
  168. package/skills/wix-replatform/resources/rp-target-wix/lib/audit-summary.js +64 -0
  169. package/skills/wix-replatform/resources/rp-target-wix/lib/contract-ledger.js +141 -0
  170. package/skills/wix-replatform/resources/rp-target-wix/lib/domain-knowledge.js +384 -0
  171. package/skills/wix-replatform/resources/rp-target-wix/lib/ndjson.js +260 -0
  172. package/skills/wix-replatform/resources/rp-target-wix/lib/stores-verification.js +238 -0
  173. package/skills/wix-replatform/resources/rp-target-wix/lib/wix-build.js +237 -0
  174. package/skills/wix-replatform/resources/rp-target-wix/lib/wix-target-spec.js +238 -0
  175. package/skills/wix-replatform/resources/rp-target-wix/lib/wix-writers.js +2219 -0
  176. package/skills/wix-replatform/resources/rp-target-wix/scripts/domain-knowledge-validate.js +31 -0
  177. package/skills/wix-replatform/resources/rp-target-wix/scripts/domain-knowledge.js +100 -0
  178. package/skills/wix-replatform/resources/rp-target-wix/scripts/verify-stores.js +107 -0
  179. package/skills/wix-replatform/resources/rp-telemetry/SKILL.md +344 -0
  180. package/skills/wix-replatform/resources/rp-telemetry/lib/bi-sink.js +250 -0
  181. package/skills/wix-replatform/resources/rp-telemetry/lib/telemetry-recorder.js +1927 -0
  182. package/skills/wix-replatform/resources/rp-telemetry/scripts/meter-from-audit.js +83 -0
  183. package/skills/wix-replatform/resources/rp-telemetry/scripts/rp-telemetry.js +141 -0
  184. package/skills/wix-replatform/scripts/artifact-freshness.js +76 -0
  185. package/skills/wix-replatform/scripts/execution-manifest-validate.js +43 -0
  186. package/skills/wix-replatform/scripts/execution-state-prepare.js +128 -0
  187. package/skills/wix-replatform/scripts/orchestration-preflight.js +48 -0
  188. package/skills/wix-replatform/scripts/orchestration-route.js +46 -0
  189. package/skills/wix-replatform/scripts/orchestration-state.js +245 -0
  190. package/skills/wix-vibe-headless/SKILL.md +48 -25
  191. package/skills/wix-vibe-headless/install/deploy.cjs +95 -0
  192. package/skills/wix-vibe-headless/install/pin-agents-md.cjs +34 -0
  193. package/skills/wix-vibe-headless/platforms/README.md +8 -4
  194. package/skills/wix-vibe-headless/platforms/base44.md +133 -98
  195. package/skills/wix-vibe-headless/platforms/generic.md +99 -64
  196. package/skills/wix-vibe-headless/references/blog/INSTRUCTIONS.md +230 -119
  197. package/skills/wix-vibe-headless/references/blog/app/components/PostCard.jsx +43 -0
  198. package/skills/wix-vibe-headless/references/blog/app/components/PostChips.jsx +25 -0
  199. package/skills/wix-vibe-headless/references/blog/app/components/PostGrid.jsx +15 -0
  200. package/skills/wix-vibe-headless/references/blog/app/context/TaxonomyContext.jsx +35 -0
  201. package/skills/wix-vibe-headless/references/blog/app/hooks/usePostDetail.js +38 -0
  202. package/skills/wix-vibe-headless/references/blog/app/pages/Blog.jsx +40 -0
  203. package/skills/wix-vibe-headless/references/blog/app/pages/CategoryPage.jsx +54 -0
  204. package/skills/wix-vibe-headless/references/blog/app/pages/PostDetail.jsx +65 -0
  205. package/skills/wix-vibe-headless/references/blog/app/pages/TagPage.jsx +50 -0
  206. package/skills/wix-vibe-headless/references/blog/seed/SEED.md +103 -0
  207. package/skills/wix-vibe-headless/references/blog/seed/seed-blog.js +270 -0
  208. package/skills/wix-vibe-headless/references/bookings/INSTRUCTIONS.md +260 -149
  209. package/skills/wix-vibe-headless/references/bookings/app/components/BookingForm.jsx +65 -0
  210. package/skills/wix-vibe-headless/references/bookings/app/components/ServiceCard.jsx +47 -0
  211. package/skills/wix-vibe-headless/references/bookings/app/components/ServiceGrid.jsx +13 -0
  212. package/skills/wix-vibe-headless/references/bookings/app/components/SlotPicker.jsx +126 -0
  213. package/skills/wix-vibe-headless/references/bookings/app/hooks/useServiceDetail.js +108 -0
  214. package/skills/wix-vibe-headless/references/bookings/app/hooks/useServices.js +58 -0
  215. package/skills/wix-vibe-headless/references/bookings/app/lib/serviceFacts.js +95 -0
  216. package/skills/wix-vibe-headless/references/bookings/app/pages/ServiceDetail.jsx +94 -0
  217. package/skills/wix-vibe-headless/references/bookings/app/pages/Services.jsx +89 -0
  218. package/skills/wix-vibe-headless/references/bookings/{wix-bookings-checkout.js → app/rest/wix-bookings-checkout.js} +1 -1
  219. package/skills/wix-vibe-headless/references/bookings/{wix-bookings-services.js → app/rest/wix-bookings-services.js} +87 -11
  220. package/skills/wix-vibe-headless/references/bookings/seed/SEED.md +117 -0
  221. package/skills/wix-vibe-headless/references/bookings/seed/seed-bookings.js +333 -0
  222. package/skills/wix-vibe-headless/references/cms/INSTRUCTIONS.md +169 -147
  223. package/skills/wix-vibe-headless/references/cms/app/collection.config.js +31 -0
  224. package/skills/wix-vibe-headless/references/cms/app/lib/wixImage.js +18 -0
  225. package/skills/wix-vibe-headless/references/cms/{wix-cms.js → app/rest/wix-cms.js} +47 -2
  226. package/skills/wix-vibe-headless/references/cms/seed/SEED.md +145 -0
  227. package/skills/wix-vibe-headless/references/events/INSTRUCTIONS.md +228 -131
  228. package/skills/wix-vibe-headless/references/events/app/components/CategoryFilter.jsx +23 -0
  229. package/skills/wix-vibe-headless/references/events/app/components/EventCard.jsx +43 -0
  230. package/skills/wix-vibe-headless/references/events/app/components/EventGrid.jsx +13 -0
  231. package/skills/wix-vibe-headless/references/events/app/components/EventRegistration.jsx +29 -0
  232. package/skills/wix-vibe-headless/references/events/app/components/RsvpForm.jsx +57 -0
  233. package/skills/wix-vibe-headless/references/events/app/components/TicketPicker.jsx +78 -0
  234. package/skills/wix-vibe-headless/references/events/app/hooks/useEventDetail.js +27 -0
  235. package/skills/wix-vibe-headless/references/events/app/hooks/useEventsList.js +54 -0
  236. package/skills/wix-vibe-headless/references/events/app/hooks/useRsvpForm.js +40 -0
  237. package/skills/wix-vibe-headless/references/events/app/hooks/useTicketing.js +81 -0
  238. package/skills/wix-vibe-headless/references/events/app/pages/EventDetail.jsx +48 -0
  239. package/skills/wix-vibe-headless/references/events/app/pages/Events.jsx +36 -0
  240. package/skills/wix-vibe-headless/references/events/{wix-events-browse.js → app/rest/wix-events-browse.js} +1 -1
  241. package/skills/wix-vibe-headless/references/events/seed/SEED.md +107 -0
  242. package/skills/wix-vibe-headless/references/events/seed/seed-events.js +251 -0
  243. package/skills/wix-vibe-headless/references/members/INSTRUCTIONS.md +276 -188
  244. package/skills/wix-vibe-headless/references/members/app/components/LoginForm.jsx +101 -0
  245. package/skills/wix-vibe-headless/references/members/app/components/MemberMenu.jsx +33 -0
  246. package/skills/wix-vibe-headless/references/members/app/components/RequireAuth.jsx +21 -0
  247. package/skills/wix-vibe-headless/references/members/app/components/SocialButtons.jsx +29 -0
  248. package/skills/wix-vibe-headless/references/members/app/context/MemberContext.jsx +54 -0
  249. package/skills/wix-vibe-headless/references/members/app/hooks/useLoginForm.js +77 -0
  250. package/skills/wix-vibe-headless/references/members/app/pages/Account.jsx +36 -0
  251. package/skills/wix-vibe-headless/references/members/app/pages/Callback.jsx +32 -0
  252. package/skills/wix-vibe-headless/references/members/app/pages/Login.jsx +35 -0
  253. package/skills/wix-vibe-headless/references/members/{wix-members-auth.js → app/rest/wix-members-auth.js} +6 -3
  254. package/skills/wix-vibe-headless/references/members/install/Register.jsx +22 -0
  255. package/skills/wix-vibe-headless/references/members/seed/SEED.md +22 -0
  256. package/skills/wix-vibe-headless/references/portfolio/INSTRUCTIONS.md +233 -104
  257. package/skills/wix-vibe-headless/references/portfolio/app/components/CollectionCard.jsx +31 -0
  258. package/skills/wix-vibe-headless/references/portfolio/app/components/CollectionGrid.jsx +15 -0
  259. package/skills/wix-vibe-headless/references/portfolio/app/components/ProjectCard.jsx +39 -0
  260. package/skills/wix-vibe-headless/references/portfolio/app/components/ProjectGrid.jsx +15 -0
  261. package/skills/wix-vibe-headless/references/portfolio/app/components/ProjectMedia.jsx +29 -0
  262. package/skills/wix-vibe-headless/references/portfolio/app/hooks/useCollectionProjects.js +35 -0
  263. package/skills/wix-vibe-headless/references/portfolio/app/hooks/usePortfolioGallery.js +27 -0
  264. package/skills/wix-vibe-headless/references/portfolio/app/hooks/useProjectDetail.js +25 -0
  265. package/skills/wix-vibe-headless/references/portfolio/app/pages/CollectionPage.jsx +39 -0
  266. package/skills/wix-vibe-headless/references/portfolio/app/pages/Portfolio.jsx +27 -0
  267. package/skills/wix-vibe-headless/references/portfolio/app/pages/ProjectDetail.jsx +60 -0
  268. package/skills/wix-vibe-headless/references/portfolio/seed/SEED.md +94 -0
  269. package/skills/wix-vibe-headless/references/portfolio/seed/seed-portfolio.js +236 -0
  270. package/skills/wix-vibe-headless/references/pricing-plans/INSTRUCTIONS.md +240 -117
  271. package/skills/wix-vibe-headless/references/pricing-plans/app/components/OrderCard.jsx +35 -0
  272. package/skills/wix-vibe-headless/references/pricing-plans/app/components/PlanCard.jsx +48 -0
  273. package/skills/wix-vibe-headless/references/pricing-plans/app/components/PlanGrid.jsx +16 -0
  274. package/skills/wix-vibe-headless/references/pricing-plans/app/components/PlanPrice.jsx +35 -0
  275. package/skills/wix-vibe-headless/references/pricing-plans/app/hooks/useMyPlans.js +25 -0
  276. package/skills/wix-vibe-headless/references/pricing-plans/app/hooks/usePlanDetail.js +28 -0
  277. package/skills/wix-vibe-headless/references/pricing-plans/app/hooks/usePlans.js +30 -0
  278. package/skills/wix-vibe-headless/references/pricing-plans/app/pages/MyPlans.jsx +30 -0
  279. package/skills/wix-vibe-headless/references/pricing-plans/app/pages/PlanDetail.jsx +53 -0
  280. package/skills/wix-vibe-headless/references/pricing-plans/app/pages/Plans.jsx +28 -0
  281. package/skills/wix-vibe-headless/references/pricing-plans/seed/SEED.md +86 -0
  282. package/skills/wix-vibe-headless/references/pricing-plans/seed/seed-pricing-plans.js +251 -0
  283. package/skills/wix-vibe-headless/references/restaurants/INSTRUCTIONS.md +248 -124
  284. package/skills/wix-vibe-headless/references/restaurants/app/components/ItemDialog.jsx +81 -0
  285. package/skills/wix-vibe-headless/references/restaurants/app/components/MenuItemCard.jsx +71 -0
  286. package/skills/wix-vibe-headless/references/restaurants/app/components/MenuList.jsx +43 -0
  287. package/skills/wix-vibe-headless/references/restaurants/app/components/OrderCartButton.jsx +21 -0
  288. package/skills/wix-vibe-headless/references/restaurants/app/components/OrderCartDrawer.jsx +60 -0
  289. package/skills/wix-vibe-headless/references/restaurants/app/context/OrderCartContext.jsx +63 -0
  290. package/skills/wix-vibe-headless/references/restaurants/app/hooks/useItemOrder.js +32 -0
  291. package/skills/wix-vibe-headless/references/restaurants/app/hooks/useReservation.js +73 -0
  292. package/skills/wix-vibe-headless/references/restaurants/app/pages/Menu.jsx +28 -0
  293. package/skills/wix-vibe-headless/references/restaurants/app/pages/Reservations.jsx +102 -0
  294. package/skills/wix-vibe-headless/references/restaurants/seed/SEED.md +180 -0
  295. package/skills/wix-vibe-headless/references/restaurants/seed/seed-restaurants.js +452 -0
  296. package/skills/wix-vibe-headless/references/shared/app/components/WixManageBanner.jsx +56 -0
  297. package/skills/wix-vibe-headless/references/shared/{wix-client.js → app/rest/wix-client.js} +6 -8
  298. package/skills/wix-vibe-headless/references/shared/app/rest/wix-config.js +6 -0
  299. package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +263 -157
  300. package/skills/wix-vibe-headless/references/storefront/app/components/CartButton.jsx +20 -0
  301. package/skills/wix-vibe-headless/references/storefront/app/components/CartDrawer.jsx +150 -0
  302. package/skills/wix-vibe-headless/references/storefront/app/components/ProductCard.jsx +167 -0
  303. package/skills/wix-vibe-headless/references/storefront/app/components/ProductGallery.jsx +51 -0
  304. package/skills/wix-vibe-headless/references/storefront/app/components/ProductGrid.jsx +54 -0
  305. package/skills/wix-vibe-headless/references/storefront/app/components/VariantPicker.jsx +119 -0
  306. package/skills/wix-vibe-headless/references/storefront/app/context/CartContext.jsx +71 -0
  307. package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductDetail.js +109 -0
  308. package/skills/wix-vibe-headless/references/storefront/app/hooks/useShop.js +98 -0
  309. package/skills/wix-vibe-headless/references/storefront/app/lib/storeImage.js +27 -0
  310. package/skills/wix-vibe-headless/references/storefront/app/pages/ProductDetail.jsx +139 -0
  311. package/skills/wix-vibe-headless/references/storefront/app/pages/Shop.jsx +75 -0
  312. package/skills/wix-vibe-headless/references/storefront/{wix-store-cart.js → app/rest/wix-store-cart.js} +1 -1
  313. package/skills/wix-vibe-headless/references/storefront/{wix-store-catalog.js → app/rest/wix-store-catalog.js} +20 -1
  314. package/skills/wix-vibe-headless/references/storefront/seed/SEED.md +111 -0
  315. package/skills/wix-vibe-headless/references/storefront/seed/seed-store.js +299 -0
  316. /package/skills/wix-vibe-headless/references/blog/{wix-blog.js → app/rest/wix-blog.js} +0 -0
  317. /package/skills/wix-vibe-headless/references/events/{wix-events-registration.js → app/rest/wix-events-registration.js} +0 -0
  318. /package/skills/wix-vibe-headless/references/portfolio/{wix-portfolio.js → app/rest/wix-portfolio.js} +0 -0
  319. /package/skills/wix-vibe-headless/references/pricing-plans/{wix-pricing-plans.js → app/rest/wix-pricing-plans.js} +0 -0
  320. /package/skills/wix-vibe-headless/references/restaurants/{wix-restaurants-menu.js → app/rest/wix-restaurants-menu.js} +0 -0
  321. /package/skills/wix-vibe-headless/references/restaurants/{wix-restaurants-ordering.js → app/rest/wix-restaurants-ordering.js} +0 -0
  322. /package/skills/wix-vibe-headless/references/restaurants/{wix-restaurants-reservations.js → app/rest/wix-restaurants-reservations.js} +0 -0
@@ -0,0 +1,2219 @@
1
+ 'use strict';
2
+
3
+ // rp-target-wix — verified-once Wix write primitives.
4
+ //
5
+ // The Wix API surface is identical for every migration regardless of source
6
+ // platform, so it is pre-built and verified HERE once rather than re-derived (and
7
+ // re-broken) by codegen on every run. `rp-import-codegen` vendors a copy of this
8
+ // module into the project (like the wp-http transport) and the generated writers
9
+ // call these primitives — they hold only per-project field maps, not API plumbing.
10
+ //
11
+ // Each call is split into a PURE request builder (`build*Request`, testable + usable
12
+ // by dry-runs) and an executor that sends it via the injected client.
13
+ //
14
+ // READ/RETURN CONTRACT for `query*` executors — every one of them (`queryStoresProducts`,
15
+ // `queryStoresCategories`, `queryContacts`, `queryCoupons`, `queryOrders`) UNWRAPS the
16
+ // response to the entity array and returns ONE PAGE, discarding `pagingMetadata`. Two
17
+ // consequences that generated code has repeatedly got wrong:
18
+ // 1. **The returned value IS the array.** Reading `.products` / `.categories` off it a
19
+ // second time yields `undefined`, which `|| []` turns into an empty array — so a dedupe
20
+ // index or an existing-entity safety net comes back EMPTY instead of failing. It looks
21
+ // like a working sweep of a fresh site, and the import duplicates everything.
22
+ // 2. **A cursor loop cannot be built on these at all** — the cursor it would need is inside
23
+ // the metadata they threw away. Use a `queryAll*` primitive where one exists
24
+ // (`queryAllStoresCategories`, `queryAllStoresProducts`, `queryAllDataItems`); otherwise
25
+ // send `wix.send(build<X>Request(body))` and read `pagingMetadata.cursors.next` off the
26
+ // raw response.
27
+ // A partial sweep must THROW, never return what it has: "empty net" and "empty store" are
28
+ // indistinguishable downstream, and it is the latter that a caller assumes.
29
+ //
30
+ // Endpoints + request shapes marked `// VERIFIED:` were validated by REAL CALLS against
31
+ // a live Wix site (not just docs — see SKILL.md "Validate by real call"). Shapes marked
32
+ // `// UNVERIFIED:` are docs-schema/MCP-derived bootstrap primitives. They must be
33
+ // surfaced in execution plans until a live contract call promotes them to VERIFIED.
34
+
35
+ const fs = require('node:fs/promises');
36
+ const path = require('node:path');
37
+ const crypto = require('node:crypto');
38
+
39
+ const WIXAPIS = 'https://www.wixapis.com';
40
+
41
+ const SAFE_MODE_TRUE_VALUES = new Set(['true', '1', 'yes', 'on']);
42
+ const SAFE_MODE_FALSE_VALUES = new Set(['false', '0', 'no', 'off']);
43
+ const DEFAULT_SAFE_MODE_PHONE_NUMBER = '+972 50 0000000';
44
+ const NATIVE_EMAIL_FALLBACK_PATHS = [
45
+ 'info.emails.items[].email',
46
+ 'emails.items[].email',
47
+ 'buyerInfo.email',
48
+ 'billingInfo.email',
49
+ 'shippingInfo.email',
50
+ 'contact.email',
51
+ 'order.buyerInfo.email',
52
+ 'order.billingInfo.email',
53
+ 'order.shippingInfo.email',
54
+ 'member.loginEmail',
55
+ 'loginEmail',
56
+ // Contacts V5 (GA) — flat contact shape; single create/update wraps as { contact },
57
+ // bulk upsert wraps each item as { contact } under contacts[]
58
+ 'contact.email.email',
59
+ 'contact.additionalEmails[].email',
60
+ 'contacts[].contact.email.email',
61
+ 'contacts[].contact.additionalEmails[].email',
62
+ ];
63
+ const NATIVE_PHONE_FALLBACK_PATHS = [
64
+ 'info.phones.items[].phone',
65
+ 'phones.items[].phone',
66
+ 'buyerInfo.phone',
67
+ 'billingInfo.phone',
68
+ 'shippingInfo.phone',
69
+ 'order.buyerInfo.phone',
70
+ 'order.billingInfo.phone',
71
+ 'order.shippingInfo.phone',
72
+ // Import Order (POST /ecom/v1/orders/import) — contact details carry phone, not email
73
+ 'order.billingInfo.contactDetails.phone',
74
+ 'order.shippingInfo.logistics.shippingDestination.contactDetails.phone',
75
+ 'order.recipientInfo.contactDetails.phone',
76
+ // Contacts V5 (GA) — flat contact shape; single create/update wraps as { contact },
77
+ // bulk upsert wraps each item as { contact } under contacts[]
78
+ 'contact.phone.phone',
79
+ 'contact.additionalPhones[].phone',
80
+ 'contact.addresses[].recipient.phone',
81
+ 'contacts[].contact.phone.phone',
82
+ 'contacts[].contact.additionalPhones[].phone',
83
+ 'contacts[].contact.addresses[].recipient.phone',
84
+ ];
85
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
86
+
87
+ class SafeModeBlockedError extends Error {
88
+ constructor(message, details = {}) {
89
+ super(message);
90
+ this.name = 'SafeModeBlockedError';
91
+ this.code = 'SAFE_MODE_SUSPICIOUS_EMAIL';
92
+ this.safeMode = true;
93
+ this.blockedPaths = details.blockedPaths || [];
94
+ this.sanitizerResult = details.sanitizerResult;
95
+ }
96
+ }
97
+
98
+ function normalizeSafeModeValue(value, { defaultValue }) {
99
+ if (value == null || String(value).trim() === '') return defaultValue;
100
+ const normalized = String(value).trim().toLowerCase();
101
+ if (SAFE_MODE_TRUE_VALUES.has(normalized)) return true;
102
+ if (SAFE_MODE_FALSE_VALUES.has(normalized)) return false;
103
+ throw new Error(`SAFE_MODE must be one of true, 1, yes, on, false, 0, no, off; got ${JSON.stringify(value)}`);
104
+ }
105
+
106
+ function normalizeDryRunValue(value, { defaultValue = false } = {}) {
107
+ if (value == null || String(value).trim() === '') return defaultValue;
108
+ const normalized = String(value).trim().toLowerCase();
109
+ if (SAFE_MODE_TRUE_VALUES.has(normalized)) return true;
110
+ if (SAFE_MODE_FALSE_VALUES.has(normalized)) return false;
111
+ throw new Error(`DRY_RUN must be one of true, 1, yes, on, false, 0, no, off; got ${JSON.stringify(value)}`);
112
+ }
113
+
114
+ function createDryRunConfig(env = {}, argv = []) {
115
+ let dryRun = normalizeDryRunValue(env.DRY_RUN, { defaultValue: false });
116
+ for (const arg of argv || []) {
117
+ if (arg === '--dry-run') dryRun = true;
118
+ if (arg === '--no-dry-run') dryRun = false;
119
+ }
120
+ return { dryRun };
121
+ }
122
+
123
+ function createSafeModeConfig(env = {}) {
124
+ const safeMode = normalizeSafeModeValue(env.SAFE_MODE, { defaultValue: true });
125
+ const configuredPhone = env.SAFE_MODE_PHONE_NUMBER == null ? '' : String(env.SAFE_MODE_PHONE_NUMBER).trim();
126
+ return {
127
+ safeMode,
128
+ safeModePhoneNumber: safeMode ? (configuredPhone || DEFAULT_SAFE_MODE_PHONE_NUMBER) : configuredPhone,
129
+ };
130
+ }
131
+
132
+ function safeEmailLocalPartComponent(value, label, { allowHyphen = false } = {}) {
133
+ const pattern = allowHyphen ? /[^a-z0-9-]+/g : /[^a-z0-9]+/g;
134
+ const normalized = String(value == null ? '' : value)
135
+ .trim()
136
+ .toLowerCase()
137
+ .replace(pattern, '_')
138
+ .replace(/^[_-]+|[_-]+$/g, '');
139
+ if (!normalized) throw new Error(`mockEmailForEntity: ${label} must normalize to a non-empty value`);
140
+ return normalized;
141
+ }
142
+
143
+ function mockEmailForEntity(entityType, entityId) {
144
+ const safeEntityType = safeEmailLocalPartComponent(entityType, 'entityType');
145
+ const safeEntityId = safeEmailLocalPartComponent(entityId, 'entityId', { allowHyphen: true });
146
+ return `replatform+${safeEntityType}_${safeEntityId}@wix.com`;
147
+ }
148
+
149
+ function deepClone(value) {
150
+ if (Array.isArray(value)) return value.map((item) => deepClone(item));
151
+ if (value && typeof value === 'object') {
152
+ const out = {};
153
+ for (const [key, item] of Object.entries(value)) out[key] = deepClone(item);
154
+ return out;
155
+ }
156
+ return value;
157
+ }
158
+
159
+ function parseSafeModePath(pathValue) {
160
+ if (!pathValue || typeof pathValue !== 'string') {
161
+ throw new Error('safe-mode replacement path must be a non-empty string');
162
+ }
163
+ return pathValue.split('.').map((rawSegment) => {
164
+ const match = rawSegment.match(/^([A-Za-z0-9_$-]+)(\[\])?$/);
165
+ if (!match) throw new Error(`invalid safe-mode replacement path segment: ${rawSegment}`);
166
+ return { key: match[1], array: Boolean(match[2]) };
167
+ });
168
+ }
169
+
170
+ function pathToString(segments) {
171
+ return segments.map((segment) => `${segment.key}${segment.array ? '[]' : ''}`).join('.');
172
+ }
173
+
174
+ function setValuesAtPath(root, pathValue, replacement) {
175
+ const segments = parseSafeModePath(pathValue);
176
+ let changed = 0;
177
+ function visit(node, index) {
178
+ if (!node || typeof node !== 'object') return;
179
+ const segment = segments[index];
180
+ if (!(segment.key in node)) return;
181
+ if (segment.array) {
182
+ const items = node[segment.key];
183
+ if (!Array.isArray(items)) return;
184
+ for (const item of items) {
185
+ if (index === segments.length - 1) {
186
+ continue;
187
+ }
188
+ visit(item, index + 1);
189
+ }
190
+ return;
191
+ }
192
+ if (index === segments.length - 1) {
193
+ // Primitive-only: a generic path like `contact.email` must not clobber the Contacts
194
+ // V5 email OBJECT ({ email, subscriptionStatus }) with a mock string. Raw emails
195
+ // left inside skipped objects are still caught by collectSuspiciousEmailPaths.
196
+ const current = node[segment.key];
197
+ if (current !== undefined && current !== null && typeof current !== 'object') {
198
+ if (current !== replacement) {
199
+ node[segment.key] = replacement;
200
+ changed += 1;
201
+ }
202
+ }
203
+ return;
204
+ }
205
+ visit(node[segment.key], index + 1);
206
+ }
207
+ visit(root, 0);
208
+ return changed;
209
+ }
210
+
211
+ function collectSuspiciousEmailPaths(value, { mockEmail }) {
212
+ const paths = [];
213
+ function visit(node, segments) {
214
+ if (typeof node === 'string') {
215
+ if (node !== mockEmail && EMAIL_PATTERN.test(node.trim())) paths.push(pathToString(segments));
216
+ return;
217
+ }
218
+ if (Array.isArray(node)) {
219
+ node.forEach((item, index) => visit(item, segments.concat({ key: String(index), array: false })));
220
+ return;
221
+ }
222
+ if (node && typeof node === 'object') {
223
+ for (const [key, item] of Object.entries(node)) visit(item, segments.concat({ key, array: false }));
224
+ }
225
+ }
226
+ visit(value, []);
227
+ return paths;
228
+ }
229
+
230
+ function normalizeReplacePaths(replacePaths = []) {
231
+ if (!Array.isArray(replacePaths)) throw new Error('safeModeOptions.replacePaths must be an array');
232
+ return replacePaths.map((entry) => {
233
+ const kind = entry && entry.kind;
234
+ const path = entry && (entry.path || entry.targetPath);
235
+ if (kind !== 'email' && kind !== 'phone') throw new Error(`safe-mode replacement kind must be email or phone; got ${JSON.stringify(kind)}`);
236
+ parseSafeModePath(path);
237
+ return { kind, path };
238
+ });
239
+ }
240
+
241
+ function isSafeModeEnabled(options = {}) {
242
+ if (options.safeMode === undefined) return false;
243
+ return normalizeSafeModeValue(options.safeMode, { defaultValue: true });
244
+ }
245
+
246
+ function sanitizeContactFieldsForSafeMode(value, options = {}) {
247
+ if (!isSafeModeEnabled(options)) {
248
+ return {
249
+ value: deepClone(value),
250
+ blocked: false,
251
+ blockedPaths: [],
252
+ emailFieldsReplaced: 0,
253
+ phoneFieldsReplaced: 0,
254
+ };
255
+ }
256
+ if (!options.entityType) throw new Error('safe mode requires origin entityType');
257
+ if (!options.entityId) throw new Error('safe mode requires origin entityId');
258
+ const safeModePhoneNumber = options.safeModePhoneNumber || DEFAULT_SAFE_MODE_PHONE_NUMBER;
259
+ const mockEmail = mockEmailForEntity(options.entityType, options.entityId);
260
+ const sanitized = deepClone(value);
261
+ const replacePaths = normalizeReplacePaths(options.replacePaths || []);
262
+ let emailFieldsReplaced = 0;
263
+ let phoneFieldsReplaced = 0;
264
+
265
+ for (const entry of replacePaths) {
266
+ if (entry.kind === 'email') emailFieldsReplaced += setValuesAtPath(sanitized, entry.path, mockEmail);
267
+ else phoneFieldsReplaced += setValuesAtPath(sanitized, entry.path, safeModePhoneNumber);
268
+ }
269
+ for (const pathValue of NATIVE_EMAIL_FALLBACK_PATHS) {
270
+ emailFieldsReplaced += setValuesAtPath(sanitized, pathValue, mockEmail);
271
+ }
272
+ for (const pathValue of NATIVE_PHONE_FALLBACK_PATHS) {
273
+ phoneFieldsReplaced += setValuesAtPath(sanitized, pathValue, safeModePhoneNumber);
274
+ }
275
+
276
+ const blockedPaths = collectSuspiciousEmailPaths(sanitized, { mockEmail });
277
+ return {
278
+ value: sanitized,
279
+ blocked: blockedPaths.length > 0,
280
+ blockedPaths,
281
+ emailFieldsReplaced,
282
+ phoneFieldsReplaced,
283
+ };
284
+ }
285
+
286
+ function sanitizeWixRequestBody(body, options = {}) {
287
+ return sanitizeContactFieldsForSafeMode(body, options);
288
+ }
289
+
290
+ function buildSafeModeEvidence(result, safeModeOptions) {
291
+ if (!isSafeModeEnabled(safeModeOptions)) return null;
292
+ return {
293
+ enabled: true,
294
+ entityType: safeModeOptions.entityType || null,
295
+ entityId: safeModeOptions.entityId == null ? null : String(safeModeOptions.entityId),
296
+ replacePathCount: normalizeReplacePaths(safeModeOptions.replacePaths || []).length,
297
+ emailFieldsReplaced: result.emailFieldsReplaced,
298
+ phoneFieldsReplaced: result.phoneFieldsReplaced,
299
+ blockedPaths: result.blockedPaths.slice(),
300
+ };
301
+ }
302
+
303
+ function applySafeModeToRequest(body, safeModeOptions) {
304
+ const result = sanitizeWixRequestBody(body, safeModeOptions);
305
+ if (result.blocked) {
306
+ throw new SafeModeBlockedError('safe mode blocked suspicious non-replaced email value before Wix write', {
307
+ blockedPaths: result.blockedPaths,
308
+ sanitizerResult: result,
309
+ });
310
+ }
311
+ return {
312
+ body: result.value,
313
+ safeMode: buildSafeModeEvidence(result, safeModeOptions),
314
+ };
315
+ }
316
+
317
+ function applySafeModeToRequestBody(body, safeModeOptions) {
318
+ return applySafeModeToRequest(body, safeModeOptions).body;
319
+ }
320
+
321
+ // --- client ----------------------------------------------------------------
322
+ // config: { authToken, siteId }. authToken is an OAuth access token / API key with
323
+ // scopes for the selected writers, for example Blog manage, Wix Data collections manage,
324
+ // media import, Contacts manage/schema, and Members manage.
325
+ //
326
+ // Auth scheme normalization: Wix API keys (`IST.…`) are sent RAW in the Authorization
327
+ // header; OAuth access tokens (e.g. a Wix CLI token from `npx @wix/cli token --site …`)
328
+ // must be sent as `Bearer <token>`. Detect and prefix so both credential kinds work.
329
+ function authHeaderValue(token) {
330
+ const t = String(token).trim();
331
+ if (/^Bearer\s/i.test(t)) return t; // already carries a scheme
332
+ if (/^IST\./.test(t)) return t; // Wix API key — sent as-is, no Bearer
333
+ return `Bearer ${t}`; // OAuth / CLI access token
334
+ }
335
+
336
+ function stripWixOrigin(url) {
337
+ const value = String(url || '');
338
+ if (value.startsWith(WIXAPIS)) return value.slice(WIXAPIS.length) || '/';
339
+ try {
340
+ const parsed = new URL(value);
341
+ return `${parsed.pathname}${parsed.search}`;
342
+ } catch {
343
+ return value;
344
+ }
345
+ }
346
+
347
+ function stableHash(value, length = 10) {
348
+ return crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, length);
349
+ }
350
+
351
+ function safePlaceholderPart(value, fallback = 'wix') {
352
+ const normalized = String(value || fallback)
353
+ .toLowerCase()
354
+ .replace(/[^a-z0-9]+/g, '_')
355
+ .replace(/^_+|_+$/g, '');
356
+ return normalized || fallback;
357
+ }
358
+
359
+ function dryRunPlaceholderId({ runId, entity, operation, sourceId, method, url, body }) {
360
+ const entityPart = safePlaceholderPart(entity || 'wix');
361
+ const hashInput = JSON.stringify({
362
+ runId: runId || 'dry-run',
363
+ entity: entity || null,
364
+ operation: operation || null,
365
+ sourceId: sourceId || null,
366
+ method,
367
+ endpoint: stripWixOrigin(url),
368
+ body,
369
+ });
370
+ return `dry-run_${entityPart}_${stableHash(hashInput, 8)}`;
371
+ }
372
+
373
+ function responseShapeFromRequest(request) {
374
+ if (request.responseShape) return request.responseShape;
375
+ const url = String(request.url || '');
376
+ const method = String(request.method || '').toUpperCase();
377
+ if (url.includes('/ricos/v1/ricos-document/convert/to-ricos')) return { type: 'object', field: 'document' };
378
+ if (url.includes('/site-media/v1/files/import')) return { type: 'object', field: 'file' };
379
+ if (url.includes('/site-media/v1/files/') && method === 'GET') return { type: 'object', field: 'file' };
380
+ if (url.includes('/blog/v3/categories')) return method === 'GET' || url.includes('/query') ? { type: 'array', field: 'categories' } : { type: 'object', field: 'category' };
381
+ if (url.includes('/blog/v3/tags')) return method === 'GET' || url.includes('/query') ? { type: 'array', field: 'tags' } : { type: 'object', field: 'tag' };
382
+ if (url.includes('/blog/v3/draft-posts') && url.includes('/publish')) return { type: 'raw' };
383
+ if (url.includes('/blog/v3/draft-posts')) return { type: 'object', field: 'draftPost' };
384
+ if (url.includes('/wix-data/v2/items/query')) return { type: 'array', field: 'dataItems' };
385
+ if (url.includes('/wix-data/v2/items')) return { type: 'object', field: 'dataItem', idFields: ['id', '_id'] };
386
+ if (url.includes('/stores/v3/products/query')) return { type: 'array', field: 'products' };
387
+ if (url.includes('/stores/v3/products/slug/') || (url.includes('/stores/v3/products/') && method === 'GET')) return { type: 'object', field: 'product' };
388
+ if (url.includes('/stores/v3/products')) return { type: 'object', field: 'product' };
389
+ if (url.includes('/categories/v1/categories/query')) return { type: 'array', field: 'categories' };
390
+ if (url.includes('/categories/v1/categories')) return { type: 'object', field: 'category' };
391
+ if (url.includes('/categories/v1/bulk/categories/add-item')) return { type: 'raw' };
392
+ if (url.includes('/stores/v3/inventory-items')) return { type: 'object', field: 'inventoryItem' };
393
+ if (url.includes('/contacts/v5/bulk/contacts/upsert')) return { type: 'raw' };
394
+ if (url.includes('/contacts/v5/contacts/query') || url.includes('/contacts/v4/contacts/query')) return { type: 'array', field: 'contacts' };
395
+ if (url.includes('/contacts/v5/contacts') || url.includes('/contacts/v4/contacts')) return { type: 'object', field: 'contact' };
396
+ if (url.includes('/stores/v2/coupons/query')) return { type: 'array', field: 'coupons' };
397
+ if (url.includes('/stores/v2/coupons')) return { type: 'object', field: 'coupon' };
398
+ if (url.includes('/ecom/v1/orders/query')) return { type: 'array', field: 'orders' };
399
+ if (url.includes('/ecom/v1/orders')) return { type: 'object', field: 'order' };
400
+ if (url.includes('/members/v1/members') && method === 'GET') return { type: 'array', field: 'members' };
401
+ if (url.includes('/members/v1/members')) return { type: 'object', field: 'member' };
402
+ return { type: 'raw' };
403
+ }
404
+
405
+ function placeholderPayload(shape, request, context) {
406
+ if (!shape || shape.type === 'raw') return {};
407
+ if (shape.type === 'array') return { [shape.field]: [] };
408
+ const id = dryRunPlaceholderId({ ...request, ...context });
409
+ const payload = { id, _dryRunPlaceholder: true };
410
+ for (const field of shape.idFields || []) payload[field] = id;
411
+ if (shape.field === 'document') return { document: { nodes: [], _dryRunPlaceholder: true } };
412
+ if (shape.field === 'file') return { file: { id, operationStatus: 'PENDING', _dryRunPlaceholder: true } };
413
+ return { [shape.field]: payload };
414
+ }
415
+
416
+ function redactHeaders(headers = {}) {
417
+ const out = {};
418
+ for (const [key, value] of Object.entries(headers || {})) {
419
+ if (/^authorization$/i.test(key)) continue;
420
+ if (/cookie|token|api[-_]?key|secret/i.test(key)) {
421
+ out[key] = '[REDACTED]';
422
+ } else {
423
+ out[key] = value;
424
+ }
425
+ }
426
+ return out;
427
+ }
428
+
429
+ function redactSecrets(value) {
430
+ if (Array.isArray(value)) return value.map((item) => redactSecrets(item));
431
+ if (value && typeof value === 'object') {
432
+ const out = {};
433
+ for (const [key, item] of Object.entries(value)) {
434
+ out[key] = /authorization|cookie|token|api[-_]?key|secret|password/i.test(key) ? '[REDACTED]' : redactSecrets(item);
435
+ }
436
+ return out;
437
+ }
438
+ return value;
439
+ }
440
+
441
+ async function appendJsonLine(filePath, row) {
442
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
443
+ await fs.appendFile(filePath, `${JSON.stringify(row)}\n`, 'utf8');
444
+ }
445
+
446
+ async function defaultCaptureSink(capture, config) {
447
+ if (typeof config.captureSink === 'function') {
448
+ await config.captureSink(capture);
449
+ }
450
+ if (config.auditSink && typeof config.auditSink.appendRequestCapture === 'function') {
451
+ await config.auditSink.appendRequestCapture(capture);
452
+ } else if (typeof config.auditSink === 'function') {
453
+ await config.auditSink(capture);
454
+ }
455
+ if (config.requestCapturePath) {
456
+ await appendJsonLine(config.requestCapturePath, capture);
457
+ } else if (config.projectDir) {
458
+ await appendJsonLine(path.join(config.projectDir, 'state', 'attempts', 'wix-request-captures.ndjson'), capture);
459
+ }
460
+ }
461
+
462
+ async function dryRunSend(request, config, defaultHeaders) {
463
+ const method = String(request.method || '').toUpperCase();
464
+ if (!method) throw new Error('wix.send: method is required');
465
+ if (!request.url) throw new Error('wix.send: url is required');
466
+ const headers = { ...defaultHeaders, ...(request.headers || {}) };
467
+ const body = request.body === undefined ? undefined : request.body;
468
+ const runId = config.runContext?.runId || config.runId || 'dry-run';
469
+ const phase = request.phase || config.runContext?.phase || config.phase || 'import';
470
+ const requestCaptureId = `reqcap_${stableHash(JSON.stringify({ runId, method, url: request.url, body, operation: request.operation, sourceId: request.sourceId }), 12)}`;
471
+ const capture = {
472
+ schemaVersion: 1,
473
+ requestCaptureId,
474
+ timestamp: new Date().toISOString(),
475
+ runId,
476
+ dryRun: true,
477
+ phase,
478
+ ...(request.entity ? { entity: request.entity } : {}),
479
+ ...(request.operation ? { operation: request.operation } : {}),
480
+ ...(request.sourceId ? { sourceId: String(request.sourceId) } : {}),
481
+ method,
482
+ endpoint: stripWixOrigin(request.url),
483
+ headers: redactHeaders(headers),
484
+ body: redactSecrets(body),
485
+ verification: request.verification || request.verificationLevel || 'unverified',
486
+ expectedLiveBehavior: request.expectedLiveBehavior || request.operation || method.toLowerCase(),
487
+ result: 'dry_run_skipped_wix_call',
488
+ authTokenStatus: config.authToken ? 'present' : 'would_block_live',
489
+ siteIdStatus: config.siteId ? 'present' : 'would_block_live',
490
+ ...(request.safeMode ? { safeMode: request.safeMode } : {}),
491
+ };
492
+ await defaultCaptureSink(capture, config);
493
+ const shape = responseShapeFromRequest(request);
494
+ return {
495
+ dryRun: true,
496
+ result: 'dry_run_skipped_wix_call',
497
+ requestCaptureId,
498
+ ...(shape.type === 'array' ? { stateKnown: false, kind: 'wix_call_skipped' } : {}),
499
+ ...placeholderPayload(shape, request, {
500
+ runId,
501
+ entity: request.entity || shape.field,
502
+ operation: request.operation || request.expectedLiveBehavior,
503
+ sourceId: request.sourceId,
504
+ }),
505
+ };
506
+ }
507
+
508
+ function createWixClient(config) {
509
+ const dryRun = normalizeDryRunValue(config && config.dryRun, { defaultValue: false });
510
+ if (!dryRun && (!config || !config.authToken)) {
511
+ throw new Error(
512
+ 'createWixClient: no Wix write credentials. Provide an OAuth access token / API ' +
513
+ 'key with the scopes required by the selected writers. In an autonomous run this ' +
514
+ 'is injected at provisioning time.',
515
+ );
516
+ }
517
+ const headers = {
518
+ 'Content-Type': 'application/json',
519
+ ...(config && config.authToken ? { Authorization: authHeaderValue(config.authToken) } : {}),
520
+ ...(config && config.siteId ? { 'wix-site-id': config.siteId } : {}),
521
+ };
522
+ const fetchImpl = config.fetch || fetch;
523
+ return {
524
+ async send(request) {
525
+ if (dryRun) return dryRunSend(request, config, headers);
526
+ const { method, url, body } = request;
527
+ const requestHeaders = { ...headers, ...(request.headers || {}) };
528
+ const res = await fetchImpl(url, { method, headers: requestHeaders, body: body ? JSON.stringify(body) : undefined });
529
+ const text = await res.text();
530
+ const json = text ? JSON.parse(text) : null;
531
+ if (!res.ok) throw new Error(`${method} ${url} → ${res.status}: ${text.slice(0, 400)}`);
532
+ return json;
533
+ },
534
+ };
535
+ }
536
+
537
+ function intentToWixRequest(intent) {
538
+ if (!intent || typeof intent !== 'object') {
539
+ throw new Error('setup intent must be an object');
540
+ }
541
+ if (intent.type === 'rest') {
542
+ return {
543
+ method: intent.method,
544
+ url: intent.url || `${WIXAPIS}${String(intent.path || '').startsWith('/') ? intent.path : `/${intent.path}`}`,
545
+ body: intent.body,
546
+ headers: intent.headers,
547
+ phase: 'setup',
548
+ operation: intent.operation,
549
+ entity: intent.entity,
550
+ sourceId: intent.sourceId,
551
+ verification: intent.verification,
552
+ expectedLiveBehavior: intent.expectedLiveBehavior,
553
+ responseShape: intent.responseShape,
554
+ };
555
+ }
556
+ return {
557
+ method: intent.method || intent.type || 'SETUP',
558
+ url: intent.url || `wix-${intent.type || 'setup'}:${intent.operation || intent.command || intent.tool || 'step'}`,
559
+ body: intent.body || intent.args || intent.commandArgs || {},
560
+ headers: intent.headers || {},
561
+ phase: 'setup',
562
+ operation: intent.operation || intent.command || intent.tool,
563
+ entity: intent.entity,
564
+ sourceId: intent.sourceId,
565
+ verification: intent.verification,
566
+ expectedLiveBehavior: intent.expectedLiveBehavior || intent.type,
567
+ responseShape: intent.responseShape || { type: 'raw' },
568
+ };
569
+ }
570
+
571
+ function createWixSetupExecutor(config = {}) {
572
+ const dryRun = normalizeDryRunValue(config.dryRun, { defaultValue: false });
573
+ let wixClient = config.wixClient || (dryRun ? createWixClient({
574
+ ...config,
575
+ dryRun,
576
+ runContext: { ...(config.runContext || {}), phase: 'setup' },
577
+ }) : null);
578
+ const transports = config.transports || {};
579
+
580
+ return {
581
+ async executeSetupStep(step) {
582
+ if (!step || typeof step !== 'object') {
583
+ throw new Error('setup step must be an object');
584
+ }
585
+ const intent = step.intent || (typeof step.buildIntent === 'function' ? await step.buildIntent(step) : step);
586
+ const request = intentToWixRequest(intent);
587
+ if (dryRun) {
588
+ const response = await wixClient.send(request);
589
+ return {
590
+ dryRun: true,
591
+ status: 'planned_dry_run',
592
+ stepId: step.id || intent.id || null,
593
+ intent,
594
+ requestCaptureId: response.requestCaptureId,
595
+ result: response.result,
596
+ };
597
+ }
598
+ if (intent.type === 'rest') {
599
+ if (!wixClient) {
600
+ wixClient = createWixClient({
601
+ ...config,
602
+ dryRun,
603
+ runContext: { ...(config.runContext || {}), phase: 'setup' },
604
+ });
605
+ }
606
+ return wixClient.send(request);
607
+ }
608
+ if (intent.type === 'mcp' && typeof transports.mcp === 'function') {
609
+ return transports.mcp(intent);
610
+ }
611
+ if (intent.type === 'cli' && typeof transports.cli === 'function') {
612
+ return transports.cli(intent);
613
+ }
614
+ if (intent.type === 'sdk' && typeof transports.sdk === 'function') {
615
+ return transports.sdk(intent);
616
+ }
617
+ throw new Error(`unsupported setup transport: ${intent.type || '<missing>'}`);
618
+ },
619
+ };
620
+ }
621
+
622
+ // --- missing-writer bootstrap ---------------------------------------------
623
+ // Generated migrations use this when Wix has a native entity but rp-target-wix does not
624
+ // yet ship a dedicated writer primitive. This keeps the write path explicit and logged
625
+ // without pretending generic CMS is an acceptable substitute for a native Wix entity.
626
+ function buildDirectRestRequest({ method, path, url, body }, safeModeOptions) {
627
+ if (!method) throw new Error('buildDirectRestRequest: method is required');
628
+ if (!path && !url) throw new Error('buildDirectRestRequest: path or url is required');
629
+ const prepared = applySafeModeToRequest(body, safeModeOptions);
630
+ return {
631
+ method,
632
+ url: url || `${WIXAPIS}${path.startsWith('/') ? path : `/${path}`}`,
633
+ body: prepared.body,
634
+ ...(prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
635
+ };
636
+ }
637
+ async function sendDirectRest(wix, request, safeModeOptions) {
638
+ return wix.send(buildDirectRestRequest(request, safeModeOptions));
639
+ }
640
+ async function notifyMissingWriter({ sourceEntity, wixEntity, method, path, reason }) {
641
+ // NOOP for now. Replace with Slack/Jira/telemetry once the RePlatform team chooses a
642
+ // destination. Keep the return value structured so callers can log/report it.
643
+ return {
644
+ notified: false,
645
+ noop: true,
646
+ sourceEntity,
647
+ wixEntity,
648
+ method,
649
+ path,
650
+ reason,
651
+ };
652
+ }
653
+
654
+ // --- slugs ------------------------------------------------------------------
655
+ // Slug sanitizing lives in `wix-build.js` (`toWixSlug`, applied automatically by the
656
+ // `coerce: 'slug'` rule on `product.slug` in wix-target-spec.js) — NOT here, and deliberately
657
+ // not inside normalizeStoresProductV3. Two reasons it stays in the build layer: URL preservation
658
+ // needs the caller to record the original source slug alongside the `plannedTargetSlug` it
659
+ // derived, which a silent rewrite inside the writer would falsify; and the build layer is where
660
+ // the canonical→Wix payload rules are regression-locked. Do not add a second copy here.
661
+
662
+ // --- rich content: HTML → Ricos document -----------------------------------
663
+ // VERIFIED: POST /ricos/v1/ricos-document/convert/to-ricos with HTML input.
664
+ // VERIFIED-TRAP: `options.plugins` enum values are UPPERCASE. The public
665
+ // docs example shows lowercase (["image","link"]); lowercase returns HTTP 400.
666
+ // VERIFIED-TRAP: `source.html` is capped at 30000 chars (400 MAX_LENGTH).
667
+ // `convertHtmlToRichContent` transparently chunks larger HTML and merges the Ricos
668
+ // node arrays, so callers never have to think about the cap.
669
+ const RICOS_PLUGINS = ['IMAGE', 'LINK', 'VIDEO', 'AUDIO', 'HEADING', 'DIVIDER', 'CODE_BLOCK', 'TABLE', 'GALLERY'];
670
+ const RICOS_HTML_CAP = 30000; // hard limit on source.html (400 MAX_LENGTH above this)
671
+ const RICOS_CHUNK_TARGET = 28000; // headroom under the cap
672
+ function buildConvertToRicosRequest(html, plugins = RICOS_PLUGINS) {
673
+ return { method: 'POST', url: `${WIXAPIS}/ricos/v1/ricos-document/convert/to-ricos`, body: { html, options: { plugins } } };
674
+ }
675
+ // split HTML at block-level close tags so each chunk stays under the cap
676
+ // without slicing through an element. A single block bigger than `max` is hard-split
677
+ // as a last resort (rare; logged by the caller).
678
+ function splitHtmlIntoChunks(html, max = RICOS_CHUNK_TARGET) {
679
+ if (html.length <= max) return [html];
680
+ const parts = html.split(/(?<=<\/(?:p|div|section|article|h[1-6]|ul|ol|li|blockquote|pre|figure|table|tbody|thead|tr)>)/i);
681
+ const chunks = [];
682
+ let cur = '';
683
+ for (const part of parts) {
684
+ if (part.length > max) {
685
+ if (cur) { chunks.push(cur); cur = ''; }
686
+ for (let i = 0; i < part.length; i += max) chunks.push(part.slice(i, i + max));
687
+ continue;
688
+ }
689
+ if (cur && cur.length + part.length > max) { chunks.push(cur); cur = ''; }
690
+ cur += part;
691
+ }
692
+ if (cur) chunks.push(cur);
693
+ return chunks;
694
+ }
695
+ // OBSERVED (2026-07-29): this endpoint throttles a sustained burst with **403** (empty message,
696
+ // empty details) rather than 429. A 50-product bulk create converts one description per product,
697
+ // and the run died partway with 49 products unwritten; a single call and a burst of 12 succeeded
698
+ // moments later, so the condition is transient. Retry with backoff instead of failing the batch.
699
+ // A genuine permission 403 still surfaces, just after the attempts are exhausted.
700
+ const RICOS_RETRY_DELAYS_MS = [500, 1500, 4000, 9000, 20000];
701
+ function isRetryableRicosError(err) {
702
+ return /\b(403|429|500|502|503|504)\b/.test(err && err.message ? err.message : '');
703
+ }
704
+ async function convertHtmlToRichContent(wix, html, { plugins, mediaBySourceUrl } = {}) {
705
+ const chunks = splitHtmlIntoChunks(html || '');
706
+ let merged = null;
707
+ for (const chunk of chunks) {
708
+ let document;
709
+ for (let attempt = 0; ; attempt += 1) {
710
+ try {
711
+ ({ document } = await wix.send(buildConvertToRicosRequest(chunk, plugins)));
712
+ break;
713
+ } catch (err) {
714
+ if (attempt >= RICOS_RETRY_DELAYS_MS.length || !isRetryableRicosError(err)) throw err;
715
+ await new Promise((resolve) => setTimeout(resolve, RICOS_RETRY_DELAYS_MS[attempt]));
716
+ }
717
+ }
718
+ if (!merged) merged = document;
719
+ else merged.nodes = (merged.nodes || []).concat(document.nodes || []);
720
+ }
721
+ return mediaBySourceUrl ? rewriteInlineMedia(merged, mediaBySourceUrl) : merged;
722
+ }
723
+ // VERIFIED-TRAP (2026-08-04, live to-ricos call): the converter nests the media object
724
+ // under a type-named key — `imageData.image.src.url`, `videoData.video.src.url`,
725
+ // `audioData.audio.src.url`. The earlier `media.src` / bare `src` paths matched nothing,
726
+ // so inline rewrites were silently a no-op (posts kept hot-linking the source host).
727
+ function rewriteInlineMedia(ricosDocument, mediaBySourceUrl) {
728
+ const MEDIA_KEYS = { imageData: 'image', videoData: 'video', audioData: 'audio' };
729
+ const visit = (node) => {
730
+ if (!node || typeof node !== 'object') return;
731
+ for (const [key, inner] of Object.entries(MEDIA_KEYS)) {
732
+ const holder = node[key]?.[inner] || node[key]?.media || node[key];
733
+ const src = holder?.src?.url;
734
+ if (src && mediaBySourceUrl.has(src)) {
735
+ holder.src = { id: mediaBySourceUrl.get(src) };
736
+ }
737
+ }
738
+ (node.nodes || []).forEach(visit);
739
+ };
740
+ (ricosDocument?.nodes || []).forEach(visit);
741
+ return ricosDocument;
742
+ }
743
+
744
+ // --- media (import-from-URL) -----------------------------------------------
745
+ // VERIFIED: POST /site-media/v1/files/import. ASYNC — the response file has
746
+ // operationStatus PENDING. VERIFIED (2026-08-04, live): a PENDING id is immediately
747
+ // referenceable in BLOG content (heroImage.id + inline Ricos src.id) — create/publish
748
+ // succeed while PENDING, the reference survives, and the CDN URL serves pre-READY —
749
+ // so blog writers must NOT block on waitUntilFileReady per file. Poll only when the
750
+ // flow reads the descriptor back (dimensions land at READY) or must surface a FAILED
751
+ // import before content ships. Unverified for product media / CMS reference fields —
752
+ // keep the wait there (README Part 5 item 20).
753
+ function buildImportMediaRequest({ sourceUrl, displayName, mimeType, mediaType, wpId }) {
754
+ return {
755
+ method: 'POST',
756
+ url: `${WIXAPIS}/site-media/v1/files/import`,
757
+ body: {
758
+ url: sourceUrl,
759
+ displayName,
760
+ mimeType: mimeType || undefined,
761
+ mediaType: mediaType ? String(mediaType).toUpperCase() : undefined, // IMAGE | AUDIO | VIDEO | DOCUMENT
762
+ externalInfo: wpId != null ? { origin: 'wordpress', externalId: String(wpId) } : undefined,
763
+ },
764
+ };
765
+ }
766
+ async function importMedia(wix, payload) {
767
+ const { file } = await wix.send(buildImportMediaRequest(payload));
768
+ return file; // { id, url, operationStatus, ... }
769
+ }
770
+ // VERIFIED: GET /site-media/v1/files/{id} returns the descriptor; poll until ready.
771
+ async function waitUntilFileReady(wix, fileId, { tries = 10, delayMs = 1500 } = {}) {
772
+ for (let i = 0; i < tries; i++) {
773
+ const r = await wix.send({ method: 'GET', url: `${WIXAPIS}/site-media/v1/files/${fileId}` });
774
+ const status = r?.file?.operationStatus;
775
+ if (status === 'READY') return r.file;
776
+ if (status === 'FAILED') throw new Error(`media import failed for ${fileId}`);
777
+ await new Promise((res) => setTimeout(res, delayMs));
778
+ }
779
+ return null; // caller decides whether to proceed with a still-PENDING file
780
+ }
781
+
782
+ // --- blog taxonomies -------------------------------------------------------
783
+ // VERIFIED: POST /blog/v3/categories with { category: { label, slug, description } }.
784
+ function buildCreateCategoryRequest({ label, slug, description }, safeModeOptions) {
785
+ const prepared = applySafeModeToRequest({ category: { label, slug, description: description || '' } }, safeModeOptions);
786
+ return {
787
+ method: 'POST',
788
+ url: `${WIXAPIS}/blog/v3/categories`,
789
+ body: prepared.body,
790
+ ...(prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
791
+ };
792
+ }
793
+ async function createBlogCategory(wix, payload, safeModeOptions) {
794
+ return (await wix.send(buildCreateCategoryRequest(payload, safeModeOptions))).category;
795
+ }
796
+ // VERIFIED: POST /blog/v3/tags. Body is TOP-LEVEL { label, language } — NOT
797
+ // { tag: { label, slug } }. `slug` is derived by Wix from the label.
798
+ function buildCreateTagRequest({ label, language = 'en' }, safeModeOptions) {
799
+ const prepared = applySafeModeToRequest({ label, language }, safeModeOptions);
800
+ return {
801
+ method: 'POST',
802
+ url: `${WIXAPIS}/blog/v3/tags`,
803
+ body: prepared.body,
804
+ ...(prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
805
+ };
806
+ }
807
+ async function createBlogTag(wix, payload, safeModeOptions) {
808
+ return (await wix.send(buildCreateTagRequest(payload, safeModeOptions))).tag;
809
+ }
810
+ // VERIFIED: GET /blog/v3/tags lists tags as { id, label, slug, ... }. Used to resolve a
811
+ // tag id after a 409 ALREADY_EXISTS so it can still be attached to a post.
812
+ async function listBlogTags(wix, { limit = 500 } = {}) {
813
+ const r = await wix.send({ method: 'GET', url: `${WIXAPIS}/blog/v3/tags?paging.limit=${limit}` });
814
+ return r.tags || [];
815
+ }
816
+
817
+ // --- blog posts ------------------------------------------------------------
818
+ // VERIFIED: POST /blog/v3/draft-posts then POST /blog/v3/draft-posts/{id}/publish.
819
+ // memberId is REQUIRED for 3rd-party app creates. Visible custom cover media requires
820
+ // BOTH `heroImage.id` and `media.{displayed,custom,wixMedia.image.id}` — `heroImage.id`
821
+ // alone leaves the cover hidden in Wix Blog.
822
+ // VERIFIED (2026-08-02): the site owner's auto-created user-member (present on our
823
+ // API-provisioned test site with zero Members-area interaction — single-site
824
+ // observation; resolve it via listMembers + loginEmail, never derive it from
825
+ // the account/user GUID — the observed id equality is undocumented) is accepted as
826
+ // memberId — attribute-to-owner needs no member provisioning. Author is re-assignable
827
+ // AFTER publish: PATCH
828
+ // /blog/v3/draft-posts/{id} { draftPost: { memberId } } then republish updates the
829
+ // published post (post id == draft id). Republish events are NOT suppressed by
830
+ // saveType=IMPORT — run author-upgrade passes inside the notification-mute window.
831
+ // VERIFIED (2026-06-10): tags attach via `tagIds` (array of tag GUIDs) on create — the
832
+ // builder must pass them or tags are created but never linked (postCount stays 0).
833
+ // VERIFIED-TRAP (2026-07-19): the draft-post REQUEST field for the slug is `seoSlug` —
834
+ // a `slug` key is silently ignored and Wix derives the slug from the title (only the
835
+ // RESPONSE carries `slug`). Fix-up after the fact: PATCH /blog/v3/draft-posts/{id} with
836
+ // { draftPost: { seoSlug } } then republish. Wix also reserves some slugs and coerces
837
+ // them (e.g. `pts` → `__pts`), which no request shape can override.
838
+ // VERIFIED-TRAP (2026-07-21, coffeeshop51): Wix rejects seoSlug whose percent-encoded
839
+ // form exceeds 100 chars (common for non-ASCII/Hebrew slugs: a 10-char Hebrew slug
840
+ // encodes to ~60 chars, so anything over ~15 chars blows the limit). Omit the slug
841
+ // when it is too long and let Wix derive it from the title.
842
+ function safeSeoslug(slug) {
843
+ if (!slug) return undefined;
844
+ try { return encodeURIComponent(slug).length <= 100 ? slug : undefined; } catch { return undefined; }
845
+ }
846
+ function toDraftPostBody({ title, memberId, richContent, excerpt, slug, categoryIds, tagIds, firstPublishedDate, heroImageId }) {
847
+ return {
848
+ title,
849
+ memberId, // REQUIRED
850
+ richContent, // Ricos document
851
+ excerpt: excerpt || undefined,
852
+ seoSlug: safeSeoslug(slug),
853
+ categoryIds: categoryIds || [],
854
+ tagIds: tagIds && tagIds.length ? tagIds : undefined,
855
+ firstPublishedDate: firstPublishedDate || undefined,
856
+ heroImage: heroImageId ? { id: heroImageId } : undefined,
857
+ media: heroImageId ? { displayed: true, custom: true, wixMedia: { image: { id: heroImageId } } } : undefined,
858
+ };
859
+ }
860
+ function buildCreateDraftPostRequest(payload) {
861
+ return {
862
+ method: 'POST',
863
+ url: `${WIXAPIS}/blog/v3/draft-posts`,
864
+ body: { draftPost: toDraftPostBody(payload) },
865
+ };
866
+ }
867
+ async function createDraftPost(wix, payload) {
868
+ return (await wix.send(buildCreateDraftPostRequest(payload))).draftPost;
869
+ }
870
+ async function publishDraftPost(wix, draftPostId) {
871
+ return wix.send({ method: 'POST', url: `${WIXAPIS}/blog/v3/draft-posts/${draftPostId}/publish`, body: {} });
872
+ }
873
+
874
+ // UNVERIFIED: POST /blog/v3/bulk/draft-posts/create — bulk draft-post create, max 20
875
+ // posts per call (docs `draftPosts` validation: minItems 1, maxItems 20). Surfaced by the
876
+ // wix/skills `wix-manage` recipe (which recommends it "for any N ≥ 2", citing ~25–30s per
877
+ // single-post call) and confirmed against the public docs page; no live call yet, so per
878
+ // adapter policy it must be surfaced in the execution plan until the contract test
879
+ // promotes it. Whether the bulk create can publish directly (a `publish` flag) is
880
+ // unverified — publish remains per-post via publishDraftPost until proven otherwise.
881
+ const BLOG_BULK_CREATE_MAX = 20;
882
+ function buildBulkCreateDraftPostsRequest(payloads) {
883
+ if (!Array.isArray(payloads) || payloads.length < 1 || payloads.length > BLOG_BULK_CREATE_MAX) {
884
+ throw new Error(`buildBulkCreateDraftPostsRequest: expected 1..${BLOG_BULK_CREATE_MAX} payloads, got ${Array.isArray(payloads) ? payloads.length : typeof payloads}`);
885
+ }
886
+ return {
887
+ method: 'POST',
888
+ url: `${WIXAPIS}/blog/v3/bulk/draft-posts/create`,
889
+ body: { draftPosts: payloads.map(toDraftPostBody) },
890
+ };
891
+ }
892
+ // Chunks any number of payloads into ≤20-post calls, sequentially, and returns the
893
+ // concatenated raw per-call responses (response item shape unverified — callers must
894
+ // inspect until the live contract call pins it down).
895
+ async function bulkCreateDraftPosts(wix, payloads) {
896
+ const responses = [];
897
+ for (let i = 0; i < payloads.length; i += BLOG_BULK_CREATE_MAX) {
898
+ responses.push(await wix.send(buildBulkCreateDraftPostsRequest(payloads.slice(i, i + BLOG_BULK_CREATE_MAX))));
899
+ }
900
+ return responses;
901
+ }
902
+
903
+ // --- CMS items (Wix Data) --------------------------------------------------
904
+ // VERIFIED: POST /wix-data/v2/items with { dataCollectionId, dataItem: { data } }.
905
+ // Requires Wix Data enabled on the site (WDE0110 otherwise — see rp-execute-setup).
906
+ // `data` is project-specific (the generated writer supplies the field map).
907
+ function buildInsertItemRequest(collectionId, data, safeModeOptions) {
908
+ const prepared = applySafeModeToRequest({ dataCollectionId: collectionId, dataItem: { data } }, safeModeOptions);
909
+ return {
910
+ method: 'POST',
911
+ url: `${WIXAPIS}/wix-data/v2/items`,
912
+ body: prepared.body,
913
+ ...(prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
914
+ };
915
+ }
916
+ async function insertDataItem(wix, collectionId, data, safeModeOptions) {
917
+ return (await wix.send(buildInsertItemRequest(collectionId, data, safeModeOptions))).dataItem;
918
+ }
919
+ // VERIFIED: POST /wix-data/v2/items/query with { dataCollectionId, query }. Paginates via
920
+ // query.paging {limit,offset}; returns dataItems[] (we return their `.data`). Required for
921
+ // optional CMS mirror fetch: only for pre-execution seeding when an existing-site flow has
922
+ // site-local reference data and valid local crosswalk state does not already exist. Runtime
923
+ // resume/idempotency is owned by state/crosswalk/crosswalk.ndjson, not CMS.
924
+ async function queryAllDataItems(wix, collectionId, { pageSize = 100 } = {}) {
925
+ const out = [];
926
+ let offset = 0;
927
+ for (;;) {
928
+ const r = await wix.send({ method: 'POST', url: `${WIXAPIS}/wix-data/v2/items/query`,
929
+ body: { dataCollectionId: collectionId, query: { paging: { limit: pageSize, offset } } } });
930
+ const items = (r.dataItems || []).map((d) => d.data);
931
+ out.push(...items);
932
+ if (items.length < pageSize) break;
933
+ offset += pageSize;
934
+ }
935
+ return out;
936
+ }
937
+
938
+ // --- Stores catalog (Catalog V3 ONLY) --------------------------------------
939
+ // Catalog V1 is NOT a supported destination: these primitives target V3 exclusively, there is
940
+ // no V1 fallback (it only masked real V3 errors as spurious 428s), and none should be added.
941
+ // A Stores install is not automatically V3 — a `blank`-scaffolded site comes up V1_CATALOG and
942
+ // cannot be converted, so callers must confirm V3_CATALOG (GET /stores/v3/provision/version)
943
+ // before the first write and halt on V1. Wix Stores app id (installing it pulls in Wix
944
+ // eCommerce): 215238eb-…
945
+ const WIX_STORES_APP_ID = '215238eb-22a5-4c36-9e7b-e7c08025e04e';
946
+ // Categories V3 require a top-level treeReference; appNamespace is always "@wix/stores".
947
+ const STORES_TREE_REFERENCE = { appNamespace: '@wix/stores' };
948
+ const PRODUCT_NAME_MAX = 80;
949
+ const CHOICE_NAME_MAX = 50;
950
+ // Products V3 schema: plainDescription is `string, maxLength 16000`. Unlike the Ricos path — which
951
+ // chunked at 28k and merged node arrays, so it was effectively unbounded — this is a hard cap.
952
+ const PLAIN_DESCRIPTION_MAX = 16000;
953
+ const STORES_SUBSCRIPTION_DESCRIPTION_MAX = 60;
954
+ const STORES_SUBSCRIPTION_FREQUENCIES = ['DAY', 'WEEK', 'MONTH', 'YEAR'];
955
+ const STORES_SUBSCRIPTION_CONTRACT = {
956
+ domain: 'stores',
957
+ entity: 'product',
958
+ surface: 'catalog-v3',
959
+ operation: 'createProduct',
960
+ path: 'product.subscriptionDetails',
961
+ verificationLevel: 'live-create-and-readback',
962
+ lastVerified: '2026-07-26',
963
+ verifiedBy: 'migration-20260726-01',
964
+ requiredPaths: [
965
+ 'product.subscriptionDetails.allowOneTimePurchases',
966
+ 'product.subscriptionDetails.subscriptions[]',
967
+ 'product.subscriptionDetails.subscriptions[].title',
968
+ 'product.subscriptionDetails.subscriptions[].description',
969
+ 'product.subscriptionDetails.subscriptions[].frequency',
970
+ 'product.subscriptionDetails.subscriptions[].interval',
971
+ 'product.subscriptionDetails.subscriptions[].autoRenewal',
972
+ ],
973
+ constraints: [
974
+ {
975
+ path: 'product.subscriptionDetails.subscriptions[].description',
976
+ maxLength: STORES_SUBSCRIPTION_DESCRIPTION_MAX,
977
+ source: 'live-validation',
978
+ },
979
+ {
980
+ path: 'product.subscriptionDetails.subscriptions[].frequency',
981
+ enum: STORES_SUBSCRIPTION_FREQUENCIES,
982
+ source: 'live-create',
983
+ },
984
+ {
985
+ path: 'product.subscriptionDetails.subscriptions[].interval',
986
+ minimum: 1,
987
+ integer: true,
988
+ source: 'live-create',
989
+ },
990
+ ],
991
+ readback: {
992
+ 'product.subscriptionDetails': 'returned-after-create',
993
+ 'product.subscriptionDetails.subscriptions[].id': 'server-assigned',
994
+ 'product.subscriptionDetails.subscriptions[].title': 'preserved',
995
+ 'product.subscriptionDetails.subscriptions[].description': 'preserved',
996
+ 'product.subscriptionDetails.subscriptions[].frequency': 'preserved',
997
+ 'product.subscriptionDetails.subscriptions[].interval': 'preserved',
998
+ 'product.subscriptionDetails.subscriptions[].autoRenewal': 'preserved',
999
+ },
1000
+ };
1001
+
1002
+ function omitEmptyStringFields(input, fields) {
1003
+ const out = { ...input };
1004
+ for (const field of fields) {
1005
+ if (typeof out[field] === 'string' && out[field].trim() === '') delete out[field];
1006
+ }
1007
+ return out;
1008
+ }
1009
+
1010
+ // Normalize a Catalog V3 product payload so callers never hit the known create traps.
1011
+ // All rules below are VERIFIED by live calls (2026-07-05, ilovecupcakes + suteka2):
1012
+ // - product name is capped at 80 chars; longer names 400 MAX_LENGTH.
1013
+ // - productType PHYSICAL requires a product-level physicalProperties object present
1014
+ // (400 ONE_OF_ALIGNMENT otherwise), even though the docs create example omits it.
1015
+ // - Option choice `name` is capped at 50 chars; option and variant choice names must be
1016
+ // truncated IDENTICALLY or the variant fails MISSING_VARIANT_OPTION_CHOICE.
1017
+ // - Variant optionChoiceNames require a `renderType` (default TEXT_CHOICES); omitting it
1018
+ // 428s MISSING_VARIANT_OPTION_CHOICE.
1019
+ // - compareAtPrice must be strictly greater than actualPrice; drop it otherwise (Wix
1020
+ // rejects a compare-at <= the actual price).
1021
+ function clampChoiceName(name) {
1022
+ const s = String(name);
1023
+ return s.length > CHOICE_NAME_MAX ? s.slice(0, CHOICE_NAME_MAX) : s;
1024
+ }
1025
+ function clampProductName(name) {
1026
+ const s = String(name || '');
1027
+ return s.length > PRODUCT_NAME_MAX ? s.slice(0, PRODUCT_NAME_MAX) : s;
1028
+ }
1029
+ function isPublicHttpUrl(value) {
1030
+ try {
1031
+ const url = new URL(String(value));
1032
+ return url.protocol === 'http:' || url.protocol === 'https:';
1033
+ } catch {
1034
+ return false;
1035
+ }
1036
+ }
1037
+ function normalizeStoresProductMediaItems(items = []) {
1038
+ return items
1039
+ .map((item) => {
1040
+ if (!item) return null;
1041
+ if (typeof item === 'string') {
1042
+ return isPublicHttpUrl(item) ? { url: item } : { id: item };
1043
+ }
1044
+ if (item.id) return { id: item.id };
1045
+ if (item.mediaId) return { id: item.mediaId };
1046
+ if (item.url && isPublicHttpUrl(item.url)) return { url: item.url };
1047
+ if (item.image?.id) return { id: item.image.id };
1048
+ return null;
1049
+ })
1050
+ .filter(Boolean);
1051
+ }
1052
+ function buildStoresProductMedia(items = []) {
1053
+ const normalizedItems = normalizeStoresProductMediaItems(items);
1054
+ return normalizedItems.length ? { itemsInfo: { items: normalizedItems } } : undefined;
1055
+ }
1056
+ function compactText(value) {
1057
+ return String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
1058
+ }
1059
+ function clampStoresSubscriptionDescription(value) {
1060
+ const text = compactText(value);
1061
+ if (text.length <= STORES_SUBSCRIPTION_DESCRIPTION_MAX) return text;
1062
+ return `${text.slice(0, STORES_SUBSCRIPTION_DESCRIPTION_MAX - 3).trimEnd()}...`;
1063
+ }
1064
+ function normalizeStoresSubscriptionFrequency(value) {
1065
+ if (value == null) return value;
1066
+ const frequency = String(value).trim().toUpperCase();
1067
+ return STORES_SUBSCRIPTION_FREQUENCIES.includes(frequency) ? frequency : value;
1068
+ }
1069
+ function normalizeStoresSubscriptionInterval(value) {
1070
+ if (value == null || value === '') return value;
1071
+ const interval = Number(value);
1072
+ return Number.isInteger(interval) && interval >= 1 ? interval : value;
1073
+ }
1074
+ function synthesizeStoresSubscriptionDescription(subscription) {
1075
+ if (subscription.description) return subscription.description;
1076
+ if (subscription.title) return subscription.title;
1077
+ const interval = normalizeStoresSubscriptionInterval(subscription.interval);
1078
+ const frequency = normalizeStoresSubscriptionFrequency(subscription.frequency);
1079
+ if (Number.isInteger(interval) && STORES_SUBSCRIPTION_FREQUENCIES.includes(frequency)) {
1080
+ const unit = frequency.toLowerCase();
1081
+ return interval === 1 ? `Every ${unit}` : `Every ${interval} ${unit}s`;
1082
+ }
1083
+ return 'Subscription';
1084
+ }
1085
+ function normalizeStoresProductSubscriptions(subscriptionDetails) {
1086
+ if (!subscriptionDetails || typeof subscriptionDetails !== 'object') return subscriptionDetails;
1087
+ const normalized = { ...subscriptionDetails };
1088
+ if (typeof normalized.allowOneTimePurchases !== 'boolean') normalized.allowOneTimePurchases = Boolean(normalized.allowOneTimePurchases);
1089
+ if (Array.isArray(subscriptionDetails.subscriptions)) {
1090
+ normalized.subscriptions = subscriptionDetails.subscriptions
1091
+ .filter(Boolean)
1092
+ .map((subscription) => ({
1093
+ ...subscription,
1094
+ description: clampStoresSubscriptionDescription(synthesizeStoresSubscriptionDescription(subscription)),
1095
+ frequency: normalizeStoresSubscriptionFrequency(subscription.frequency),
1096
+ interval: normalizeStoresSubscriptionInterval(subscription.interval),
1097
+ }));
1098
+ }
1099
+ return normalized;
1100
+ }
1101
+ function validateStoresProductSubscriptionDetails(product) {
1102
+ const details = product && product.subscriptionDetails;
1103
+ const errors = [];
1104
+ const add = (path, code, message) => errors.push({ path, code, message });
1105
+ if (!details || typeof details !== 'object') return { ok: true, errors };
1106
+ if (typeof details.allowOneTimePurchases !== 'boolean') {
1107
+ add('product.subscriptionDetails.allowOneTimePurchases', 'required_boolean', 'allowOneTimePurchases must be boolean');
1108
+ }
1109
+ if (!Array.isArray(details.subscriptions) || details.subscriptions.length === 0) {
1110
+ add('product.subscriptionDetails.subscriptions[]', 'required_array', 'subscriptions must contain at least one entry');
1111
+ return { ok: false, errors };
1112
+ }
1113
+ details.subscriptions.forEach((subscription, index) => {
1114
+ const base = `product.subscriptionDetails.subscriptions[${index}]`;
1115
+ if (!compactText(subscription.title)) add(`${base}.title`, 'required', 'title is required');
1116
+ if (!compactText(subscription.description)) {
1117
+ add(`${base}.description`, 'required', 'description is required');
1118
+ } else if (compactText(subscription.description).length > STORES_SUBSCRIPTION_DESCRIPTION_MAX) {
1119
+ add(`${base}.description`, 'max_length', `description must be at most ${STORES_SUBSCRIPTION_DESCRIPTION_MAX} characters`);
1120
+ }
1121
+ if (!STORES_SUBSCRIPTION_FREQUENCIES.includes(subscription.frequency)) {
1122
+ add(`${base}.frequency`, 'enum', `frequency must be one of ${STORES_SUBSCRIPTION_FREQUENCIES.join(', ')}`);
1123
+ }
1124
+ if (!Number.isInteger(subscription.interval) || subscription.interval < 1) {
1125
+ add(`${base}.interval`, 'minimum', 'interval must be an integer >= 1');
1126
+ }
1127
+ if (typeof subscription.autoRenewal !== 'boolean') add(`${base}.autoRenewal`, 'required_boolean', 'autoRenewal must be boolean');
1128
+ });
1129
+ return { ok: errors.length === 0, errors };
1130
+ }
1131
+ // VERIFIED-TRAP (2026-07-19, nopong migration): variant `price` must be a MONEY OBJECT
1132
+ // ({ actualPrice: { amount: "14.95" } }) — a bare string/number 400s "Expected an object".
1133
+ // Generated transforms kept emitting scalars, so coerce here instead of failing at create.
1134
+ function toMoneyObject(price) {
1135
+ if (price == null || typeof price === 'object') return price;
1136
+ return { actualPrice: { amount: String(price) } };
1137
+ }
1138
+ function normalizeStoresProductV3(input) {
1139
+ const product = { ...input };
1140
+ if (product.name != null) product.name = clampProductName(product.name);
1141
+
1142
+ // A `description` STRING is HTML that belongs in plainDescription; `description` proper is a
1143
+ // Ricos document object. Callers that hand-build a product (or predate the plainDescription
1144
+ // switch) still pass the string, so route it here rather than sending HTML where an object
1145
+ // is expected.
1146
+ if (typeof product.description === 'string') {
1147
+ const html = product.description.trim();
1148
+ delete product.description;
1149
+ if (html && product.plainDescription == null) product.plainDescription = html;
1150
+ }
1151
+ // TRAP (Products V3 schema): "plainDescription is ignored when a value is also passed to the
1152
+ // description field." Sending both is a SILENT failure — a 200 with an empty description — so
1153
+ // it is rejected here rather than discovered on a live site.
1154
+ if (product.plainDescription != null && product.description != null) {
1155
+ throw new Error(
1156
+ `normalizeStoresProductV3: "${product.name}" sets both description and plainDescription; Wix ignores plainDescription when description is present. Set exactly one.`,
1157
+ );
1158
+ }
1159
+ if (typeof product.plainDescription === 'string' && product.plainDescription.length > PLAIN_DESCRIPTION_MAX) {
1160
+ throw new Error(
1161
+ `normalizeStoresProductV3: "${product.name}" has a ${product.plainDescription.length}-character plainDescription; Wix caps it at ${PLAIN_DESCRIPTION_MAX}. Truncate it or move the overflow into an info section, and record the loss in mapping-gaps.json.`,
1162
+ );
1163
+ }
1164
+ if (product.productType) product.productType = String(product.productType).toUpperCase();
1165
+ if (product.subscriptionDetails) {
1166
+ // Catalog V3 carries recurring offers directly on the product object. Keep the
1167
+ // nested shape stable here so create/patch flows preserve subscription payloads
1168
+ // instead of relying on incidental shallow-copy behavior.
1169
+ product.subscriptionDetails = normalizeStoresProductSubscriptions(product.subscriptionDetails);
1170
+ }
1171
+
1172
+ const topLevelPrice = product.price;
1173
+ const topLevelSku = product.sku;
1174
+ const topLevelPhysicalProperties = product.physicalProperties;
1175
+ delete product.price;
1176
+ delete product.sku;
1177
+ if (product.media && Array.isArray(product.media.itemsInfo?.items || product.media.items)) {
1178
+ product.media = buildStoresProductMedia(product.media.itemsInfo?.items || product.media.items);
1179
+ }
1180
+
1181
+ if (!product.variantsInfo && (topLevelPrice || topLevelSku || topLevelPhysicalProperties)) {
1182
+ product.variantsInfo = {
1183
+ variants: [{
1184
+ visible: product.visible !== false,
1185
+ ...(topLevelSku ? { sku: topLevelSku } : {}),
1186
+ ...(topLevelPrice ? { price: toMoneyObject(topLevelPrice) } : {}),
1187
+ ...(topLevelPhysicalProperties ? { physicalProperties: topLevelPhysicalProperties } : {}),
1188
+ }],
1189
+ };
1190
+ }
1191
+
1192
+ if (String(product.productType || '').toUpperCase() === 'PHYSICAL') {
1193
+ product.physicalProperties = {};
1194
+ }
1195
+ if (Array.isArray(product.options)) {
1196
+ product.options = product.options.map((o) => ({
1197
+ ...o,
1198
+ optionRenderType: o.optionRenderType || 'TEXT_CHOICES',
1199
+ choicesSettings: o.choicesSettings && Array.isArray(o.choicesSettings.choices)
1200
+ // VERIFIED-TRAP (2026-07-21, coffeeshop51): `choiceType` is required on every choice — omitting it returns PRODUCT_OPTION_CHOICE_NAME_AND_TYPE_REQUIRED.
1201
+ ? { ...o.choicesSettings, choices: o.choicesSettings.choices.map((c) => ({ ...c, name: clampChoiceName(c.name), choiceType: c.choiceType || 'CHOICE_TEXT' })) }
1202
+ : o.choicesSettings,
1203
+ }));
1204
+ }
1205
+ const variants = product.variantsInfo && Array.isArray(product.variantsInfo.variants) ? product.variantsInfo.variants : null;
1206
+ if (variants) {
1207
+ product.variantsInfo = {
1208
+ ...product.variantsInfo,
1209
+ variants: variants.map((v) => {
1210
+ const nv = { ...v };
1211
+ if (nv.price != null) nv.price = toMoneyObject(nv.price);
1212
+ const price = nv.price;
1213
+ if (price && price.compareAtPrice && price.actualPrice) {
1214
+ const cmp = Number(price.compareAtPrice.amount);
1215
+ const act = Number(price.actualPrice.amount);
1216
+ if (!(cmp > act)) { const { compareAtPrice, ...rest } = price; nv.price = rest; }
1217
+ }
1218
+ if (Array.isArray(nv.choices)) {
1219
+ nv.choices = nv.choices.map((ch) => ch.optionChoiceNames
1220
+ ? { ...ch, optionChoiceNames: { renderType: 'TEXT_CHOICES', ...ch.optionChoiceNames, choiceName: clampChoiceName(ch.optionChoiceNames.choiceName) } }
1221
+ : ch);
1222
+ }
1223
+ return nv;
1224
+ }),
1225
+ };
1226
+ }
1227
+ return product;
1228
+ }
1229
+
1230
+ // VERIFIED (Products V3 schema): `plainDescription` is a STRING of HTML (max 16000) that Wix
1231
+ // converts to rich content SERVER-SIDE. It is not a plain-text flattening and costs no fidelity
1232
+ // against `description` — it is the same conversion, just not ours to run.
1233
+ //
1234
+ // So an HTML string never routes through /ricos/v1/... on the product path. That matters at
1235
+ // scale: the previous behaviour converted one description PER PRODUCT before a bulk create, so a
1236
+ // 100-product batch was 100 serial round-trips plus the bulk call, and that burst is exactly what
1237
+ // the endpoint throttles with a 403 (see convertHtmlToRichContent above). It is now one call.
1238
+ // convertHtmlToRichContent stays for the blog path, where `richContent` really is a Ricos document.
1239
+ //
1240
+ // `wix` is retained (unused) so the (wix, input) call shape stays valid: flipping the signature
1241
+ // would make an existing `f(wix, product)` call normalize the CLIENT object and silently return
1242
+ // garbage. Now synchronous — `await` on the result is harmless.
1243
+ // eslint-disable-next-line no-unused-vars
1244
+ function normalizeStoresProductV3ForCreate(wix, input) {
1245
+ return normalizeStoresProductV3(input);
1246
+ }
1247
+
1248
+ // VERIFIED (2026-07-05): POST /stores/v3/products with { product } (+ optional fields[]).
1249
+ function buildCreateStoresProductRequest(product, safeModeOptions, fields) {
1250
+ const prepared = applySafeModeToRequest({ product: normalizeStoresProductV3(product) }, safeModeOptions);
1251
+ const body = prepared.body;
1252
+ if (fields) body.fields = fields;
1253
+ return {
1254
+ method: 'POST',
1255
+ url: `${WIXAPIS}/stores/v3/products`,
1256
+ body,
1257
+ ...(prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1258
+ };
1259
+ }
1260
+ async function createStoresProduct(wix, product, safeModeOptions, fields) {
1261
+ const normalized = normalizeStoresProductV3ForCreate(wix, product);
1262
+ return (await wix.send(buildCreateStoresProductRequest(normalized, safeModeOptions, fields))).product;
1263
+ }
1264
+ // --- bulk product create (the scale path) ----------------------------------
1265
+ // UNVERIFIED: POST /stores/v3/bulk/products-with-inventory/create — up to 100 products with
1266
+ // their options, variants, inline brand/ribbon/infoSections AND per-variant inventory items
1267
+ // in ONE request. This is the path a migration of any real size must use; creating products
1268
+ // one at a time is only acceptable for a handful.
1269
+ //
1270
+ // PER-REQUEST LIMITS (all of them, simultaneously — exceeding ANY ONE rejects the whole
1271
+ // request, so batch with ndjson.readBatchesByLimits, not on record count alone):
1272
+ // products <= 100
1273
+ // variantsInfo.variants <= 1000 (total across the request)
1274
+ // options <= 100 (total; 2 options per product caps a batch at 50)
1275
+ // modifiers <= 100 (total)
1276
+ // infoSections <= 100 (total)
1277
+ // BULK_LIMITS below is the machine-readable copy — use it rather than re-typing the numbers.
1278
+ //
1279
+ // TRAP: bulk is NOT atomic. Each item succeeds or fails independently via
1280
+ // `results[i].itemMetadata.success`; a 200 response can still contain failures. Callers MUST
1281
+ // walk the per-item results and never infer success from the HTTP status.
1282
+ //
1283
+ // TRAP: `itemMetadata.originalIndex` correlates a result back to the request array. Do not
1284
+ // assume the response preserves request order — key on originalIndex, and fall back to
1285
+ // position only when it is absent.
1286
+ //
1287
+ // TRAP: `bulkActionMetadata.undetailedFailures` counts failures whose detail was dropped
1288
+ // because the threshold was exceeded. Ignoring it silently loses failed records.
1289
+ //
1290
+ // `returnEntity: false` (the default) still returns `itemMetadata.id`, which is all a
1291
+ // crosswalk needs — pass `returnEntity: true` only when the caller must inspect the created
1292
+ // entity (e.g. a contract probe verifying variant counts), because the payload is large.
1293
+ const BULK_PRODUCT_LIMITS = { records: 100, variants: 1000, options: 100, modifiers: 100, infoSections: 100 };
1294
+
1295
+ // Cost of one product against those limits, for readBatchesByLimits.
1296
+ function storesProductBulkCost(product) {
1297
+ const v = product && product.variantsInfo && product.variantsInfo.variants;
1298
+ return {
1299
+ variants: Array.isArray(v) ? Math.max(1, v.length) : 1,
1300
+ options: Array.isArray(product && product.options) ? product.options.length : 0,
1301
+ modifiers: Array.isArray(product && product.modifiers) ? product.modifiers.length : 0,
1302
+ infoSections: Array.isArray(product && product.infoSections) ? product.infoSections.length : 0,
1303
+ };
1304
+ }
1305
+
1306
+ function buildBulkCreateStoresProductsRequest(products, { returnEntity = false, fields } = {}) {
1307
+ if (!Array.isArray(products) || products.length === 0) {
1308
+ throw new Error('buildBulkCreateStoresProductsRequest: products must be a non-empty array');
1309
+ }
1310
+ if (products.length > BULK_PRODUCT_LIMITS.records) {
1311
+ throw new Error(
1312
+ `buildBulkCreateStoresProductsRequest: ${products.length} products exceeds the per-request limit of ${BULK_PRODUCT_LIMITS.records}. ` +
1313
+ 'Batch with ndjson.readBatchesByLimits using BULK_PRODUCT_LIMITS.',
1314
+ );
1315
+ }
1316
+ const body = { products: products.map((p) => normalizeStoresProductV3(p)), returnEntity };
1317
+ if (fields) body.fields = fields;
1318
+ return { method: 'POST', url: `${WIXAPIS}/stores/v3/bulk/products-with-inventory/create`, body };
1319
+ }
1320
+
1321
+ // Normalizes each product, sends ONE bulk request, and returns a per-item outcome list already
1322
+ // correlated back to the input index. Callers get a flat shape they cannot accidentally read as
1323
+ // all-or-nothing.
1324
+ //
1325
+ // Normalization is local — HTML descriptions travel as `plainDescription` and Wix converts them
1326
+ // server-side, so this is one HTTP call, not one-per-product plus the bulk call.
1327
+ async function bulkCreateStoresProductsWithInventory(wix, products, { returnEntity = false, fields } = {}) {
1328
+ const normalized = products.map((product) => normalizeStoresProductV3ForCreate(wix, product));
1329
+
1330
+ const response = await wix.send(buildBulkCreateStoresProductsRequest(normalized, { returnEntity, fields }));
1331
+ // VERIFIED (2026-07-29) against the BulkCreateProductsWithInventoryResponse schema:
1332
+ // TRAP: products-with-inventory nests the per-item results ONE LEVEL DEEPER than its
1333
+ // sibling /stores/v3/bulk/products/create. Here they are `productResults.results` +
1334
+ // `productResults.bulkActionMetadata`; only `inventoryResults` is top-level. Reading
1335
+ // `response.results` yields undefined, which the unaccounted guard correctly reports as a
1336
+ // correlation failure AFTER the products have already been created. The flat fallback keeps
1337
+ // this tolerant of the sibling envelope.
1338
+ const productResults = (response && response.productResults) || {};
1339
+ const rawResults = productResults.results || (response && response.results) || [];
1340
+ const meta = productResults.bulkActionMetadata || (response && response.bulkActionMetadata) || {};
1341
+
1342
+ const results = rawResults.map((r, position) => {
1343
+ const im = (r && r.itemMetadata) || {};
1344
+ // originalIndex is authoritative; position is the documented fallback only.
1345
+ const index = Number.isInteger(im.originalIndex) ? im.originalIndex : position;
1346
+ return {
1347
+ index,
1348
+ inputProduct: products[index],
1349
+ success: im.success === true,
1350
+ productId: im.id || (r.item && r.item.id) || null,
1351
+ revision: (r.item && r.item.revision) || null,
1352
+ product: r.item || null,
1353
+ errorCode: im.error && im.error.code ? im.error.code : null,
1354
+ errorDescription: im.error && im.error.description ? im.error.description : null,
1355
+ };
1356
+ });
1357
+
1358
+ const succeeded = results.filter((r) => r.success);
1359
+ const failed = results.filter((r) => !r.success);
1360
+ const undetailedFailures = meta.undetailedFailures || 0;
1361
+
1362
+ // A result set that does not account for every input is a correlation bug, not a partial
1363
+ // success — surface it rather than silently crosswalking the wrong ids.
1364
+ const unaccounted = products.length - results.length - undetailedFailures;
1365
+
1366
+ return {
1367
+ results,
1368
+ succeeded,
1369
+ failed,
1370
+ totalSuccesses: meta.totalSuccesses !== undefined ? meta.totalSuccesses : succeeded.length,
1371
+ totalFailures: meta.totalFailures !== undefined ? meta.totalFailures : failed.length,
1372
+ undetailedFailures,
1373
+ unaccounted: unaccounted > 0 ? unaccounted : 0,
1374
+ inventoryResults: (response && response.inventoryResults) || null,
1375
+ };
1376
+ }
1377
+
1378
+ function buildQueryStoresProductsRequest(query = { paging: { limit: 100 } }) {
1379
+ return { method: 'POST', url: `${WIXAPIS}/stores/v3/products/query`, body: { query } };
1380
+ }
1381
+ // ONE PAGE, unwrapped to the products array, pagingMetadata discarded — see the READ/RETURN
1382
+ // CONTRACT at the top of this file. Do not build a dedupe index or a safety net on this.
1383
+ async function queryStoresProducts(wix, query) {
1384
+ return (await wix.send(buildQueryStoresProductsRequest(query))).products || [];
1385
+ }
1386
+ // OBSERVED (2026-07-29, shopify-mysite1): the only correct way to sweep the catalog, and the
1387
+ // primitive any crosswalk-recovery / name-match safety net must use. The unwrapping executor
1388
+ // above cannot be cursor-paged (it discards the cursor), and the hand-rolled loop that reads
1389
+ // `.products` off its already-unwrapped return value produces an EMPTY set — which reads as
1390
+ // "the store is empty" and is exactly the state under which an import re-creates the whole
1391
+ // catalog it already imported. Hence: throw on an incomplete sweep, never return a partial index.
1392
+ //
1393
+ // UNVERIFIED for the first-page request form: the live shopify-mysite1 sweep opened with
1394
+ // `paging: { limit }` and only switched to `cursorPaging` once it held a cursor. This mirrors
1395
+ // `queryAllStoresCategories` instead and sends `cursorPaging` from the start, which is the
1396
+ // documented Wix convention (`paging` and `cursorPaging` are mutually exclusive) but has not
1397
+ // been confirmed against /stores/v3/products/query by a real call. Promote on first live run.
1398
+ async function queryAllStoresProducts(wix, { pageSize = 100, maxPages = 200 } = {}) {
1399
+ const all = [];
1400
+ const seen = new Set();
1401
+ let cursor = null;
1402
+ let pages = 0;
1403
+ do {
1404
+ const query = cursor ? { cursorPaging: { limit: pageSize, cursor } } : { cursorPaging: { limit: pageSize } };
1405
+ const response = await wix.send(buildQueryStoresProductsRequest(query));
1406
+ for (const product of response.products || []) {
1407
+ if (product && !seen.has(product.id)) { seen.add(product.id); all.push(product); }
1408
+ }
1409
+ const meta = response.pagingMetadata || {};
1410
+ cursor = (meta.cursors && meta.cursors.next) || null;
1411
+ pages += 1;
1412
+ } while (cursor && pages < maxPages);
1413
+ if (cursor) throw new Error(`queryAllStoresProducts: still paging after ${maxPages} pages; refusing to return a partial product index.`);
1414
+ return all;
1415
+ }
1416
+ // VERIFIED (migration-20260715-01): PATCH /stores/v3/products/{id} with
1417
+ // { product: { revision, media: { itemsInfo: { items: [{id}|{url}] } } } } updates
1418
+ // product gallery media. Prefer external URLs here when the source media is publicly
1419
+ // reachable: the Stores product API ingests them in the background, which avoids the
1420
+ // slower, heavily-throttled Media Manager pre-import path.
1421
+ function buildPatchStoresProductMediaRequest({ productId, revision, items = [] }) {
1422
+ return {
1423
+ method: 'PATCH',
1424
+ url: `${WIXAPIS}/stores/v3/products/${productId}`,
1425
+ body: {
1426
+ product: {
1427
+ revision,
1428
+ media: buildStoresProductMedia(items),
1429
+ },
1430
+ },
1431
+ };
1432
+ }
1433
+ async function patchStoresProductMedia(wix, payload) {
1434
+ return wix.send(buildPatchStoresProductMediaRequest(payload));
1435
+ }
1436
+
1437
+ // UNVERIFIED: GET /stores/v3/products/{id} and GET /stores/v3/products/slug/{slug}.
1438
+ // Used by upsert flows to check whether a product already exists before creating it.
1439
+ // Both return 404 when the product is not found — callers should catch and treat as null.
1440
+ function buildGetStoresProductRequest(id) {
1441
+ return { method: 'GET', url: `${WIXAPIS}/stores/v3/products/${encodeURIComponent(id)}` };
1442
+ }
1443
+ async function getStoresProduct(wix, id) {
1444
+ return (await wix.send(buildGetStoresProductRequest(id))).product;
1445
+ }
1446
+ function buildGetStoresProductBySlugRequest(slug) {
1447
+ return { method: 'GET', url: `${WIXAPIS}/stores/v3/products/slug/${encodeURIComponent(slug)}` };
1448
+ }
1449
+ async function getStoresProductBySlug(wix, slug) {
1450
+ return (await wix.send(buildGetStoresProductBySlugRequest(slug))).product;
1451
+ }
1452
+ function buildDeleteStoresProductRequest(id) {
1453
+ return { method: 'DELETE', url: `${WIXAPIS}/stores/v3/products/${encodeURIComponent(id)}` };
1454
+ }
1455
+ async function deleteStoresProduct(wix, id) {
1456
+ return wix.send(buildDeleteStoresProductRequest(id));
1457
+ }
1458
+
1459
+ // UNVERIFIED (endpoint VERIFIED via patchStoresProductMedia): PATCH /stores/v3/products/{id}
1460
+ // with arbitrary product fields. The `revision` from the existing product is required.
1461
+ // A string `description` is moved to `plainDescription` by normalizeStoresProductV3 (same as
1462
+ // createStoresProduct); Wix converts that HTML to rich content server-side.
1463
+ // Do not use this for media-only updates — patchStoresProductMedia is the verified path for that.
1464
+ function buildPatchStoresProductRequest({ productId, revision, ...productFields }, safeModeOptions) {
1465
+ const prepared = applySafeModeToRequest({ product: { revision, ...normalizeStoresProductV3(productFields) } }, safeModeOptions);
1466
+ return {
1467
+ method: 'PATCH',
1468
+ url: `${WIXAPIS}/stores/v3/products/${productId}`,
1469
+ body: prepared.body,
1470
+ ...(prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1471
+ };
1472
+ }
1473
+ async function patchStoresProduct(wix, { productId, revision, ...productFields }, safeModeOptions) {
1474
+ // No description handling here: buildPatchStoresProductRequest runs normalizeStoresProductV3,
1475
+ // which moves a string `description` to `plainDescription` for Wix to convert server-side.
1476
+ return (await wix.send(buildPatchStoresProductRequest({ productId, revision, ...productFields }, safeModeOptions))).product;
1477
+ }
1478
+
1479
+ // UNVERIFIED: POST /categories/v1/categories/query returns ONE PAGE of Stores categories —
1480
+ // NOT all of them, whatever this comment used to say. treeReference is TOP-LEVEL (same trap as
1481
+ // create). Used to seed a name→id cache for upsert flows so existing categories are reused
1482
+ // instead of duplicated — which means the cache must be built with queryAllStoresCategories,
1483
+ // since a truncated cache duplicates exactly the categories it failed to read.
1484
+ function buildQueryStoresCategoriesRequest(query = { paging: { limit: 100 } }) {
1485
+ return {
1486
+ method: 'POST',
1487
+ url: `${WIXAPIS}/categories/v1/categories/query`,
1488
+ body: { query, treeReference: STORES_TREE_REFERENCE },
1489
+ };
1490
+ }
1491
+ // ONE PAGE, unwrapped to the categories array, pagingMetadata discarded — see the READ/RETURN
1492
+ // CONTRACT at the top of this file. Use queryAllStoresCategories below for any dedupe index.
1493
+ async function queryStoresCategories(wix, query) {
1494
+ return (await wix.send(buildQueryStoresCategoriesRequest(query))).categories || [];
1495
+ }
1496
+ // OBSERVED (2026-07-29): `queryStoresCategories` returns ONE PAGE (100 max) and, by unwrapping to
1497
+ // the array, discards the pagingMetadata needed to fetch the rest. A dedupe index built from it is
1498
+ // silently truncated once a site passes 100 categories — a site with 119 read as 100, which would
1499
+ // duplicate the missing 19 on the next import. Any upsert/dedupe flow must use this instead.
1500
+ async function queryAllStoresCategories(wix, { pageSize = 100, maxPages = 200 } = {}) {
1501
+ const all = [];
1502
+ const seen = new Set();
1503
+ let cursor = null;
1504
+ let pages = 0;
1505
+ do {
1506
+ const query = cursor ? { cursorPaging: { limit: pageSize, cursor } } : { cursorPaging: { limit: pageSize } };
1507
+ const response = await wix.send(buildQueryStoresCategoriesRequest(query));
1508
+ for (const category of response.categories || []) {
1509
+ if (category && !seen.has(category.id)) { seen.add(category.id); all.push(category); }
1510
+ }
1511
+ const meta = response.pagingMetadata || {};
1512
+ cursor = (meta.cursors && meta.cursors.next) || null;
1513
+ pages += 1;
1514
+ } while (cursor && pages < maxPages);
1515
+ if (cursor) throw new Error(`queryAllStoresCategories: still paging after ${maxPages} pages; refusing to return a partial category index.`);
1516
+ return all;
1517
+ }
1518
+
1519
+ // VERIFIED (2026-07-05): POST /categories/v1/categories with { category, treeReference }.
1520
+ // treeReference is TOP-LEVEL (sibling of category), NOT a category property — nesting it
1521
+ // 400s "treeReference must not be empty".
1522
+ function buildCreateStoresCategoryRequest(category, safeModeOptions) {
1523
+ const prepared = applySafeModeToRequest({
1524
+ category: omitEmptyStringFields(category, ['description']),
1525
+ treeReference: STORES_TREE_REFERENCE,
1526
+ }, safeModeOptions);
1527
+ return {
1528
+ method: 'POST',
1529
+ url: `${WIXAPIS}/categories/v1/categories`,
1530
+ body: prepared.body,
1531
+ ...(prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1532
+ };
1533
+ }
1534
+ async function createStoresCategory(wix, category, safeModeOptions) {
1535
+ return (await wix.send(buildCreateStoresCategoryRequest(category, safeModeOptions))).category;
1536
+ }
1537
+
1538
+ // VERIFIED (2026-07-05): add one product to categories in bulk —
1539
+ // POST /categories/v1/bulk/categories/add-item with
1540
+ // { item:{ catalogItemId, appId }, categoryIds[], treeReference }. catalogItemId is the Wix
1541
+ // product id; appId is the Wix Stores app id.
1542
+ function buildBulkAddItemToCategoriesRequest({ productId, categoryIds }) {
1543
+ return {
1544
+ method: 'POST',
1545
+ url: `${WIXAPIS}/categories/v1/bulk/categories/add-item`,
1546
+ body: { item: { catalogItemId: productId, appId: WIX_STORES_APP_ID }, categoryIds, treeReference: STORES_TREE_REFERENCE },
1547
+ };
1548
+ }
1549
+ async function bulkAddItemToCategories(wix, payload) {
1550
+ return wix.send(buildBulkAddItemToCategoriesRequest(payload));
1551
+ }
1552
+
1553
+ // --- Contacts --------------------------------------------------------------
1554
+ // Contacts V5 is GA (verified in public docs 2026-08-04). The GA contract
1555
+ // is FLAT: one main `email`/`phone` (matching + subscription live on the main entries),
1556
+ // `additionalEmails`/`additionalPhones` arrays, an `addresses` array with the postal
1557
+ // fields NESTED under `address`, and `company: { name, jobTitle }`. There is no `info`
1558
+ // wrapper and no V4-style `emails.items` list wrapper anywhere in V5 requests. Create and
1559
+ // update both take `{ contact, allowDuplicates }`; update requires the current `revision`
1560
+ // and has no fieldMask. Live create/query/update verification is still pending a token
1561
+ // with Contacts permissions (the 2026-07-26 probe got 403), so writers stay UNVERIFIED
1562
+ // until a contract test promotes them — but the target shape is now the documented GA one.
1563
+ //
1564
+ // Custom fields: the GA V5 contact carries `extendedFields.namespaces.<ns>` and the V5
1565
+ // docs route field DEFINITIONS through the Data Extension Schema API with FQDN
1566
+ // `wix.contacts.*.contact` (values under the `_user_fields` namespace). The V4 Contacts
1567
+ // Extended Fields API (`POST /contacts/v4/extended-fields`, values under
1568
+ // `info.extendedFields`) still exists but pairs with the V4 write surface only — do not
1569
+ // mix the two. Labels are likewise a V4 concept; V5 exposes `tags.privateTags.tagIds`
1570
+ // managed through the Tags API (same FQDN). NOTE: the Data Extension Schema intro's
1571
+ // supported-objects table does not list contacts yet — docs inconsistency at GA cutover;
1572
+ // treat the V5 contact-object statement as authoritative but verify live during setup.
1573
+ const V5_CONTACT_PHONE_TAGS = new Set(['OTHER', 'MAIN', 'HOME', 'MOBILE', 'WORK', 'FAX']);
1574
+ const V5_CONTACT_ADDRESS_TAGS = new Set(['OTHER', 'HOME', 'WORK', 'BILLING', 'SHIPPING']);
1575
+ function normalizeV5PhoneTag(tag) {
1576
+ const normalized = String(tag || '').trim().toUpperCase();
1577
+ if (!normalized) return undefined;
1578
+ if (V5_CONTACT_PHONE_TAGS.has(normalized)) return normalized;
1579
+ if (normalized === 'PRIMARY' || normalized === 'SOURCE_PRIMARY' || normalized === 'BILLING') return 'MAIN';
1580
+ if (normalized === 'SHIPPING') return 'HOME';
1581
+ return 'OTHER';
1582
+ }
1583
+ function normalizeV5AddressTag(tag) {
1584
+ const normalized = String(tag || '').trim().toUpperCase();
1585
+ if (!normalized) return undefined;
1586
+ return V5_CONTACT_ADDRESS_TAGS.has(normalized) ? normalized : 'OTHER';
1587
+ }
1588
+ // GA ContactAddress keeps postal fields nested under `address`; anything else found flat
1589
+ // on the item (city, country, streetAddress, …) is moved into `address` so legacy flat
1590
+ // items survive the shape change.
1591
+ const V5_ADDRESS_ITEM_KEYS = new Set(['id', 'tag', 'address', 'defaultAddress', 'recipient']);
1592
+ function normalizeV5AddressItem(item) {
1593
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
1594
+ const out = {};
1595
+ const address = item.address && typeof item.address === 'object' && !Array.isArray(item.address)
1596
+ ? { ...item.address }
1597
+ : {};
1598
+ for (const [key, value] of Object.entries(item)) {
1599
+ if (key === 'address') continue;
1600
+ if (V5_ADDRESS_ITEM_KEYS.has(key)) out[key] = value;
1601
+ else address[key] = value;
1602
+ }
1603
+ const tag = normalizeV5AddressTag(out.tag);
1604
+ if (tag) out.tag = tag;
1605
+ if (Object.keys(address).length) out.address = address;
1606
+ return out;
1607
+ }
1608
+ function normalizeV5Contact(contact = {}) {
1609
+ const normalized = { ...contact };
1610
+ if (normalized.phone && typeof normalized.phone === 'object') {
1611
+ const tag = normalizeV5PhoneTag(normalized.phone.tag);
1612
+ normalized.phone = { ...normalized.phone, ...(tag ? { tag } : {}) };
1613
+ }
1614
+ if (Array.isArray(normalized.additionalPhones)) {
1615
+ normalized.additionalPhones = normalized.additionalPhones.map((item) => {
1616
+ if (!item || typeof item !== 'object') return item;
1617
+ const tag = normalizeV5PhoneTag(item.tag);
1618
+ return { ...item, ...(tag ? { tag } : {}) };
1619
+ });
1620
+ }
1621
+ if (Array.isArray(normalized.addresses)) {
1622
+ normalized.addresses = normalized.addresses.map((item) => normalizeV5AddressItem(item));
1623
+ }
1624
+ return normalized;
1625
+ }
1626
+ // Legacy V4-style `info` payloads (pre-GA generated transforms) convert through this
1627
+ // STRICT whitelist: unknown keys throw instead of silently dropping source data.
1628
+ // `extendedFields` and `labelKeys` throw because they have no mechanical V5 equivalent —
1629
+ // V5 custom fields live under extendedFields.namespaces (Data Extension Schema) and
1630
+ // labels became tags (Tags API); both need a setup-time decision, not a converter guess.
1631
+ const V4_INFO_CONVERTIBLE_KEYS = new Set([
1632
+ 'name', 'emails', 'phones', 'addresses', 'company', 'jobTitle', 'birthdate', 'locale',
1633
+ ]);
1634
+ function contactListItems(value) {
1635
+ if (!value) return [];
1636
+ if (Array.isArray(value)) return value;
1637
+ if (Array.isArray(value.items)) return value.items;
1638
+ return [];
1639
+ }
1640
+ function pickMainListItem(items) {
1641
+ if (!items.length) return { main: undefined, rest: [] };
1642
+ const mainIndex = Math.max(0, items.findIndex((item) => item
1643
+ && typeof item === 'object'
1644
+ && (item.primary === true || String(item.tag || '').trim().toUpperCase() === 'MAIN')));
1645
+ return { main: items[mainIndex], rest: items.filter((_, index) => index !== mainIndex) };
1646
+ }
1647
+ function contactInfoToV5Contact(info = {}) {
1648
+ const unknownKeys = Object.keys(info).filter((key) => !V4_INFO_CONVERTIBLE_KEYS.has(key));
1649
+ if (unknownKeys.length) {
1650
+ throw new Error(
1651
+ `contactInfoToV5Contact: cannot convert V4-style info key(s) ${JSON.stringify(unknownKeys)} to the GA Contacts V5 contact shape. `
1652
+ + 'extendedFields values belong under contact.extendedFields.namespaces (Data Extension Schema, FQDN wix.contacts.*.contact); '
1653
+ + 'labels became tags (Tags API). Regenerate the transform against the flat GA contact shape.',
1654
+ );
1655
+ }
1656
+ const contact = {};
1657
+ if (info.name !== undefined) contact.name = info.name;
1658
+ const emails = pickMainListItem(contactListItems(info.emails));
1659
+ if (emails.main) contact.email = { email: emails.main.email };
1660
+ if (emails.rest.length) contact.additionalEmails = emails.rest.map((item) => ({ email: item.email }));
1661
+ const phones = pickMainListItem(contactListItems(info.phones));
1662
+ if (phones.main) {
1663
+ const tag = normalizeV5PhoneTag(phones.main.tag);
1664
+ contact.phone = { phone: phones.main.phone, ...(tag ? { tag } : {}) };
1665
+ }
1666
+ if (phones.rest.length) {
1667
+ contact.additionalPhones = phones.rest.map((item) => {
1668
+ const tag = normalizeV5PhoneTag(item.tag);
1669
+ return { phone: item.phone, ...(tag ? { tag } : {}) };
1670
+ });
1671
+ }
1672
+ const addresses = contactListItems(info.addresses);
1673
+ if (addresses.length) contact.addresses = addresses.map((item) => normalizeV5AddressItem(item));
1674
+ if (info.company !== undefined || info.jobTitle !== undefined) {
1675
+ contact.company = {
1676
+ ...(info.company !== undefined ? { name: info.company } : {}),
1677
+ ...(info.jobTitle !== undefined ? { jobTitle: info.jobTitle } : {}),
1678
+ };
1679
+ }
1680
+ if (info.birthdate !== undefined) contact.birthdate = info.birthdate;
1681
+ if (info.locale !== undefined) contact.locale = info.locale;
1682
+ return contact;
1683
+ }
1684
+ function toEpochMilliseconds(value) {
1685
+ if (value == null || value === '') return undefined;
1686
+ if (typeof value === 'number' && Number.isFinite(value)) {
1687
+ return value >= 1e12 ? value : value * 1000;
1688
+ }
1689
+ const parsed = Date.parse(String(value));
1690
+ if (!Number.isFinite(parsed)) return value;
1691
+ return parsed;
1692
+ }
1693
+ function normalizeCouponSpecification(specification = {}) {
1694
+ const normalized = { ...specification };
1695
+ normalized.startTime = toEpochMilliseconds(specification.startTime);
1696
+ normalized.expirationTime = toEpochMilliseconds(specification.expirationTime);
1697
+ if (specification.moneyOffRate != null && specification.percentOffRate == null) {
1698
+ normalized.percentOffRate = specification.moneyOffRate;
1699
+ delete normalized.moneyOffRate;
1700
+ }
1701
+ if (normalized.percentOffRate != null) {
1702
+ normalized.percentOffRate = Number(normalized.percentOffRate);
1703
+ }
1704
+ if (normalized.moneyOffAmount != null && typeof normalized.moneyOffAmount === 'object') {
1705
+ normalized.moneyOffAmount = Number(normalized.moneyOffAmount.amount);
1706
+ } else if (normalized.moneyOffAmount != null) {
1707
+ normalized.moneyOffAmount = Number(normalized.moneyOffAmount);
1708
+ }
1709
+ if (normalized.fixedPriceAmount != null && typeof normalized.fixedPriceAmount === 'object') {
1710
+ normalized.fixedPriceAmount = Number(normalized.fixedPriceAmount.amount);
1711
+ } else if (normalized.fixedPriceAmount != null) {
1712
+ normalized.fixedPriceAmount = Number(normalized.fixedPriceAmount);
1713
+ }
1714
+ if (normalized.minimumSubtotal != null) {
1715
+ normalized.minimumSubtotal = Number(normalized.minimumSubtotal);
1716
+ }
1717
+ if (normalized.usageLimit != null) {
1718
+ normalized.usageLimit = Number(normalized.usageLimit);
1719
+ }
1720
+ if (normalized.limitPerCustomer != null) {
1721
+ normalized.limitPerCustomer = Number(normalized.limitPerCustomer);
1722
+ }
1723
+ if (normalized.scope && Object.keys(normalized.scope).length === 0) {
1724
+ delete normalized.scope;
1725
+ }
1726
+ return normalized;
1727
+ }
1728
+ // GA request: POST /contacts/v5/contacts { contact: <flat contact>, allowDuplicates }.
1729
+ // Accepts the flat GA `contact` directly; a legacy V4-style `info` payload is converted
1730
+ // via contactInfoToV5Contact (strict — throws on non-mechanical keys). At least one of
1731
+ // name.first, name.last, email.email, or phone.phone is required by the API.
1732
+ function buildCreateContactRequest({ contact, info, allowDuplicates = false }, safeModeOptions) {
1733
+ if (contact !== undefined && info !== undefined) {
1734
+ throw new Error('buildCreateContactRequest: pass either the flat GA `contact` or a legacy `info`, not both');
1735
+ }
1736
+ const flatContact = info !== undefined ? contactInfoToV5Contact(info) : contact;
1737
+ if (!flatContact || typeof flatContact !== 'object' || Array.isArray(flatContact)) {
1738
+ throw new Error('buildCreateContactRequest: contact must be a flat GA Contacts V5 contact object');
1739
+ }
1740
+ const safeModeEnabled = isSafeModeEnabled(safeModeOptions);
1741
+ const prepared = applySafeModeToRequest({
1742
+ contact: normalizeV5Contact(flatContact),
1743
+ allowDuplicates: safeModeEnabled ? true : allowDuplicates,
1744
+ }, safeModeOptions);
1745
+ return {
1746
+ method: 'POST',
1747
+ url: `${WIXAPIS}/contacts/v5/contacts`,
1748
+ body: prepared.body,
1749
+ ...(prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1750
+ };
1751
+ }
1752
+ async function createContact(wix, payload, safeModeOptions) {
1753
+ return (await wix.send(buildCreateContactRequest(payload, safeModeOptions))).contact;
1754
+ }
1755
+
1756
+ // UNVERIFIED writer (no live run yet). DOCUMENTED endpoint: POST
1757
+ // /contacts/v5/bulk/contacts/upsert — the CONT-01 import path: 1-100 contacts per call,
1758
+ // synchronous, per-item results. Contact matching (main email, or main phone when no
1759
+ // email) decides create vs update, so re-runs upsert instead of duplicating; `externalId`
1760
+ // (set-once, max 100 chars) carries the source-system id for the crosswalk.
1761
+ // `upsertMode`: OVERWRITE (default) | APPEND | OVERWRITE_APPEND_ARRAYS.
1762
+ // Contacts use the same flat GA shape as createContact; each array item wraps as
1763
+ // `{ contact }`.
1764
+ const CONTACTS_BULK_UPSERT_MAX = 100;
1765
+ function buildBulkUpsertContactsRequest(contacts, { upsertMode, returnEntity = false, updateMember } = {}, safeModeOptions) {
1766
+ if (!Array.isArray(contacts) || contacts.length === 0) {
1767
+ throw new Error('buildBulkUpsertContactsRequest: contacts must be a non-empty array');
1768
+ }
1769
+ if (contacts.length > CONTACTS_BULK_UPSERT_MAX) {
1770
+ throw new Error(
1771
+ `buildBulkUpsertContactsRequest: ${contacts.length} contacts exceeds the per-request limit of ${CONTACTS_BULK_UPSERT_MAX} — batch upstream`,
1772
+ );
1773
+ }
1774
+ const prepared = applySafeModeToRequest({
1775
+ contacts: contacts.map((contact) => ({ contact: normalizeV5Contact(contact) })),
1776
+ ...(upsertMode ? { upsertMode } : {}),
1777
+ ...(returnEntity ? { returnEntity: true } : {}),
1778
+ ...(typeof updateMember === 'boolean' ? { updateMember } : {}),
1779
+ }, safeModeOptions);
1780
+ return {
1781
+ method: 'POST',
1782
+ url: `${WIXAPIS}/contacts/v5/bulk/contacts/upsert`,
1783
+ body: prepared.body,
1784
+ ...(prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1785
+ };
1786
+ }
1787
+ // Returns per-item outcomes correlated back to the input index — the same flat shape as
1788
+ // bulkCreateStoresProductsWithInventory, so callers cannot misread partial failure as
1789
+ // all-or-nothing.
1790
+ async function bulkUpsertContacts(wix, contacts, options = {}, safeModeOptions) {
1791
+ const response = await wix.send(buildBulkUpsertContactsRequest(contacts, options, safeModeOptions));
1792
+ const rawResults = (response && response.results) || [];
1793
+ const meta = (response && response.bulkActionMetadata) || {};
1794
+ const results = rawResults.map((r, position) => {
1795
+ const im = (r && r.itemMetadata) || {};
1796
+ // originalIndex is authoritative; position is the documented fallback only.
1797
+ const index = Number.isInteger(im.originalIndex) ? im.originalIndex : position;
1798
+ return {
1799
+ index,
1800
+ inputContact: contacts[index],
1801
+ success: im.success === true,
1802
+ contactId: im.id || (r.item && r.item.id) || null,
1803
+ action: r.action || null, // CREATED | UPDATED
1804
+ contact: r.item || null, // populated only with returnEntity: true
1805
+ errorCode: im.error && im.error.code ? im.error.code : null,
1806
+ errorDescription: im.error && im.error.description ? im.error.description : null,
1807
+ };
1808
+ });
1809
+ const succeeded = results.filter((r) => r.success);
1810
+ const failed = results.filter((r) => !r.success);
1811
+ const undetailedFailures = meta.undetailedFailures || 0;
1812
+ // A result set that does not account for every input is a correlation bug, not a partial
1813
+ // success — surface it rather than silently crosswalking the wrong ids.
1814
+ const unaccounted = contacts.length - results.length - undetailedFailures;
1815
+ return {
1816
+ results,
1817
+ succeeded,
1818
+ failed,
1819
+ totalSuccesses: meta.totalSuccesses !== undefined ? meta.totalSuccesses : succeeded.length,
1820
+ totalFailures: meta.totalFailures !== undefined ? meta.totalFailures : failed.length,
1821
+ undetailedFailures,
1822
+ unaccounted: unaccounted > 0 ? unaccounted : 0,
1823
+ };
1824
+ }
1825
+ function buildQueryContactsRequest(query = { paging: { limit: 100, offset: 0 } }) {
1826
+ return { method: 'POST', url: `${WIXAPIS}/contacts/v5/contacts/query`, body: { query } };
1827
+ }
1828
+ // ONE PAGE, unwrapped to the contacts array — see the READ/RETURN CONTRACT at the top of this
1829
+ // file. Contacts pages by `paging.{limit,offset}`, so a full sweep advances the offset off the
1830
+ // raw response rather than following a cursor; there is no queryAll* helper yet.
1831
+ async function queryContacts(wix, query) {
1832
+ return (await wix.send(buildQueryContactsRequest(query))).contacts || [];
1833
+ }
1834
+ function buildGetContactRequest(contactId) {
1835
+ if (!contactId) throw new Error('buildGetContactRequest: contactId is required');
1836
+ return { method: 'GET', url: `${WIXAPIS}/contacts/v5/contacts/${contactId}` };
1837
+ }
1838
+ async function getContact(wix, contactId) {
1839
+ return (await wix.send(buildGetContactRequest(contactId))).contact;
1840
+ }
1841
+ // GA request: PATCH /contacts/v5/contacts/{id} { contact: { id, revision, <flat fields> },
1842
+ // allowDuplicates? }. The current revision is REQUIRED (optimistic concurrency); there is
1843
+ // no fieldMask in the GA contract — passing one throws so stale pre-GA call sites fail
1844
+ // loudly instead of sending an unrecognized parameter.
1845
+ function buildUpdateContactRequest({ contactId, revision, contact, info, allowDuplicates, fieldMask }) {
1846
+ if (fieldMask !== undefined) {
1847
+ throw new Error('buildUpdateContactRequest: GA Contacts V5 update has no fieldMask; send the flat fields to change on `contact`');
1848
+ }
1849
+ if (contact !== undefined && info !== undefined) {
1850
+ throw new Error('buildUpdateContactRequest: pass either the flat GA `contact` or a legacy `info`, not both');
1851
+ }
1852
+ const id = contactId || contact?.id;
1853
+ if (!id) throw new Error('buildUpdateContactRequest: contactId is required');
1854
+ const rev = revision ?? contact?.revision;
1855
+ if (rev === undefined || rev === null) {
1856
+ throw new Error('buildUpdateContactRequest: revision is required (read the contact first and pass its current revision)');
1857
+ }
1858
+ const flatContact = info !== undefined ? contactInfoToV5Contact(info) : (contact || {});
1859
+ const nextContact = {
1860
+ ...normalizeV5Contact(flatContact),
1861
+ id,
1862
+ revision: rev,
1863
+ };
1864
+ return {
1865
+ method: 'PATCH',
1866
+ url: `${WIXAPIS}/contacts/v5/contacts/${id}`,
1867
+ body: {
1868
+ contact: nextContact,
1869
+ ...(allowDuplicates !== undefined ? { allowDuplicates } : {}),
1870
+ },
1871
+ };
1872
+ }
1873
+ async function updateContact(wix, payload) {
1874
+ return (await wix.send(buildUpdateContactRequest(payload))).contact;
1875
+ }
1876
+ // V4-surface setup helper. Find Or Create Extended Field defines V4 `info.extendedFields`
1877
+ // custom fields and pairs with V4 contact writers only. For the GA V5 surface, custom
1878
+ // field definitions go through the Data Extension Schema API (FQDN wix.contacts.*.contact)
1879
+ // and values are written under `contact.extendedFields.namespaces._user_fields` — see
1880
+ // specs/0012-wix-extended-fields-setup-contract.md.
1881
+ function buildFindOrCreateContactExtendedFieldRequest({ displayName, dataType = 'TEXT' }) {
1882
+ if (!displayName) throw new Error('buildFindOrCreateContactExtendedFieldRequest: displayName is required');
1883
+ return {
1884
+ method: 'POST',
1885
+ url: `${WIXAPIS}/contacts/v4/extended-fields`,
1886
+ body: { displayName, dataType },
1887
+ };
1888
+ }
1889
+ async function findOrCreateContactExtendedField(wix, payload) {
1890
+ return (await wix.send(buildFindOrCreateContactExtendedFieldRequest(payload))).field;
1891
+ }
1892
+
1893
+ // --- Coupons ---------------------------------------------------------------
1894
+ // UNVERIFIED: read-only probe showed /stores/v2/coupons/query reaches the Coupons service
1895
+ // but returned app-not-installed/unauthorized on the target site. The specification must
1896
+ // contain exactly one coupon type; generated code must decide per source coupon whether
1897
+ // native Wix Coupons can represent the source coupon exactly. CMS is not a fallback for a
1898
+ // missing writer; it is only for coupons whose semantics do not fit Wix Coupons.
1899
+ function buildCreateCouponRequest(specification, safeModeOptions) {
1900
+ const prepared = applySafeModeToRequest({ specification: normalizeCouponSpecification(specification) }, safeModeOptions);
1901
+ return {
1902
+ method: 'POST',
1903
+ url: `${WIXAPIS}/stores/v2/coupons`,
1904
+ body: prepared.body,
1905
+ ...(prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1906
+ };
1907
+ }
1908
+ async function createCoupon(wix, specification, safeModeOptions) {
1909
+ const response = await wix.send(buildCreateCouponRequest(specification, safeModeOptions));
1910
+ if (response?.coupon?.id) return response.coupon;
1911
+ const code = String(specification?.code || '').trim();
1912
+ if (code) {
1913
+ for (let attempt = 0; attempt < 4; attempt += 1) {
1914
+ const coupons = await queryCoupons(wix, { paging: { limit: 200, offset: 0 } });
1915
+ const matched = coupons.find((coupon) => String(coupon?.specification?.code || '').trim() === code);
1916
+ if (matched?.id) return matched;
1917
+ if (attempt < 3) {
1918
+ await new Promise((resolve) => setTimeout(resolve, 750));
1919
+ }
1920
+ }
1921
+ }
1922
+ return response.coupon;
1923
+ }
1924
+ function buildQueryCouponsRequest(query = { paging: { limit: 100, offset: 0 } }) {
1925
+ return { method: 'POST', url: `${WIXAPIS}/stores/v2/coupons/query`, body: { query } };
1926
+ }
1927
+ // ONE PAGE, unwrapped to the coupons array — see the READ/RETURN CONTRACT at the top of this file.
1928
+ async function queryCoupons(wix, query) {
1929
+ return (await wix.send(buildQueryCouponsRequest(query))).coupons || [];
1930
+ }
1931
+
1932
+ // --- eCom orders -----------------------------------------------------------
1933
+ // WARNING — createOrder is NOT for import. POST /ecom/v1/orders is the LIVE-commerce
1934
+ // Create Order (ECOM-02 in the owner tracker: Not import-suited): it decrements catalog
1935
+ // inventory, emails the buyer a confirmation, and auto-creates a contact. Historical
1936
+ // orders MUST go through importOrder below. createOrder remains only for creating a
1937
+ // genuine live/test order on purpose.
1938
+ function buildCreateOrderRequest(order, safeModeOptions) {
1939
+ const prepared = applySafeModeToRequest({ order }, safeModeOptions);
1940
+ return {
1941
+ method: 'POST',
1942
+ url: `${WIXAPIS}/ecom/v1/orders`,
1943
+ body: prepared.body,
1944
+ ...(prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1945
+ };
1946
+ }
1947
+ async function createOrder(wix, order, safeModeOptions) {
1948
+ return (await wix.send(buildCreateOrderRequest(order, safeModeOptions))).order;
1949
+ }
1950
+ function buildQueryOrdersRequest(query = { paging: { limit: 100 } }) {
1951
+ return { method: 'POST', url: `${WIXAPIS}/ecom/v1/orders/query`, body: { query } };
1952
+ }
1953
+ // ONE PAGE, unwrapped to the orders array — see the READ/RETURN CONTRACT at the top of this file.
1954
+ async function queryOrders(wix, query) {
1955
+ return (await wix.send(buildQueryOrdersRequest(query))).orders || [];
1956
+ }
1957
+
1958
+ // UNVERIFIED writer (no live run yet). DOCUMENTED endpoint: POST /ecom/v1/orders/import —
1959
+ // the dedicated migration path (Beta, scope SCOPE.ECOM.IMPORT-ORDERS, ECOM-01 in the owner
1960
+ // tracker). Values are stored AS-IS (no total/status recalculation). No side effects: no
1961
+ // buyer notifications, no inventory adjustment, no contact/invoice/receipt/subscription
1962
+ // creation; standard order webhooks don't fire — a single `OrderImported` event is emitted
1963
+ // instead (that event has exactly one consumer, so imported orders stay invisible to
1964
+ // contacts/loyalty and other event-driven views; see ECOM-01).
1965
+ // Required: lineItems (1-300; each needs quantity, productName.original, itemType, price,
1966
+ // and catalogItemId+appId when catalogReference is present), billingInfo.contactDetails,
1967
+ // channelInfo (no SHOPIFY/WOOCOMMERCE enum values — use OTHER_PLATFORM), priceSummary,
1968
+ // status, paymentStatus (full enum, incl. PAID without a real payment).
1969
+ // History: purchasedDate/createdDate/number are settable on import (immutable after).
1970
+ // Re-runs: sending an existing imported order's `id` fully replaces it; overwriting a
1971
+ // non-imported order fails with CANNOT_OVERWRITE_NON_IMPORTED_ORDER. Cleanup exists via
1972
+ // Bulk Delete Imported Orders; live-order numbering continues via Set Order Number Counter.
1973
+ function buildImportOrderRequest(order, safeModeOptions) {
1974
+ const prepared = applySafeModeToRequest({ order }, safeModeOptions);
1975
+ return {
1976
+ method: 'POST',
1977
+ url: `${WIXAPIS}/ecom/v1/orders/import`,
1978
+ body: prepared.body,
1979
+ ...(prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
1980
+ };
1981
+ }
1982
+ async function importOrder(wix, order, safeModeOptions) {
1983
+ return (await wix.send(buildImportOrderRequest(order, safeModeOptions))).order;
1984
+ }
1985
+
1986
+ // --- Stores inventory (Catalog V3 Inventory Items API) ----------------------
1987
+ // UNVERIFIED: POST /stores/v3/inventory-items creates one inventory item per variant.
1988
+ // Inventory items are NOT created automatically when a product is created — a separate
1989
+ // call is required for each variant (per productId + variantId combination).
1990
+ // To mark a variant as in stock without quantity tracking: set `inStock: true`.
1991
+ // Omit `locationId` to target the default location (the one Wix's standard checkout
1992
+ // deducts from). The combination of variantId + locationId must be unique.
1993
+ //
1994
+ // How to determine variantIds: `createStoresProduct` returns the full product object;
1995
+ // the variant IDs are at `product.variantsInfo.variants[].id`.
1996
+ function buildCreateInventoryItemRequest({ variantId, productId, locationId, inStock, quantity, trackQuantity, preorderInfo }) {
1997
+ const item = {
1998
+ variantId,
1999
+ productId,
2000
+ ...(locationId ? { locationId } : {}),
2001
+ ...(typeof inStock === 'boolean' ? { inStock } : {}),
2002
+ ...(quantity != null ? { quantity } : {}),
2003
+ ...(typeof trackQuantity === 'boolean' ? { trackQuantity } : {}),
2004
+ ...(preorderInfo ? { preorderInfo } : {}),
2005
+ };
2006
+ return { method: 'POST', url: `${WIXAPIS}/stores/v3/inventory-items`, body: { inventoryItem: item } };
2007
+ }
2008
+ async function createInventoryItem(wix, payload) {
2009
+ return (await wix.send(buildCreateInventoryItemRequest(payload))).inventoryItem;
2010
+ }
2011
+ // Convenience: mark all variants of a product as in stock (untracked mode) at the
2012
+ // default location. Pass the product object returned by `createStoresProduct`.
2013
+ async function setProductVariantsInStock(wix, { productId, variantIds, locationId } = {}) {
2014
+ const results = [];
2015
+ for (const variantId of (variantIds || [])) {
2016
+ results.push(await createInventoryItem(wix, { variantId, productId, inStock: true, locationId }));
2017
+ }
2018
+ return results;
2019
+ }
2020
+
2021
+ // --- members ---------------------------------------------------------------
2022
+ // VERIFIED: GET /members/v1/members (reconcile), POST /members/v1/members (create).
2023
+ // Dedup by loginEmail — gated PII; null email cannot dedup/create (use a fallback).
2024
+ // DOCUMENTED: no bulk create; >=1s spacing between Create Member calls is the
2025
+ // documented rate-limit floor — space sequential creates and resume via crosswalk.
2026
+ // DOCUMENTED: create sends no email and does not fire the signup automations
2027
+ // trigger — member import is silent by default. Passwords are NEVER imported
2028
+ // (project decision 2026-08-03). Activation (decided): passwordless members
2029
+ // complete the standard forgot-password flow (confirmed 2026-08-03); delivery is
2030
+ // a post-import label-wave automation (owner-created, label-added trigger,
2031
+ // branded email pointing at Log in -> Forgot password; importer labels contacts
2032
+ // in API batches), enabled only after the import window. There is deliberately
2033
+ // no send-set-password-email writer here: its link dies in 3h and mass-sending
2034
+ // it is the exact notification-blast this lib exists to avoid.
2035
+ // VERIFIED-TRAP (2026-07-19): the default (PUBLIC) fieldset OMITS loginEmail, which
2036
+ // silently breaks dedupe-by-loginEmail; request fieldsets=FULL so the field is present.
2037
+ // VERIFIED (2026-08-02, single-site observation): the member list can already contain
2038
+ // AUTO-CREATED user-members for the site owner / contributing Wix users (status
2039
+ // APPROVED) even on an API-provisioned site nobody ever visited — seen on our test
2040
+ // site. Never dedupe or reconcile these against source-site
2041
+ // members. The owner's user-member is a valid blog author memberId — attribute-to-owner
2042
+ // blog imports need no member provisioning. Resolve it from THIS list by loginEmail:
2043
+ // the observed id equality (member id == account GUID) is n=1 on a solo account and
2044
+ // undocumented — never construct a memberId from the account/user id.
2045
+ async function listMembers(wix, { limit = 50 } = {}) {
2046
+ return wix.send({ method: 'GET', url: `${WIXAPIS}/members/v1/members?fieldsets=FULL&paging.limit=${limit}` });
2047
+ }
2048
+ function buildCreateMemberRequest({ email, name, slug }, safeModeOptions) {
2049
+ if (!email) return { skipped: true, reason: 'no email — gated PII; authenticated source re-run required' };
2050
+ const prepared = applySafeModeToRequest({ member: { loginEmail: email, contact: { firstName: name }, profile: { nickname: name, slug } } }, safeModeOptions);
2051
+ return {
2052
+ method: 'POST',
2053
+ url: `${WIXAPIS}/members/v1/members`,
2054
+ body: prepared.body,
2055
+ ...(prepared.safeMode ? { safeMode: prepared.safeMode } : {}),
2056
+ };
2057
+ }
2058
+ async function createMember(wix, payload, safeModeOptions) {
2059
+ const request = buildCreateMemberRequest(payload, safeModeOptions);
2060
+ if (request.skipped) return request;
2061
+ return (await wix.send(request)).member;
2062
+ }
2063
+
2064
+ // --- site notifications mute (Notification Preferences V1) ------------------
2065
+ // VERIFIED (2026-08-04, full cycle live on a test target): mute → state read →
2066
+ // idempotent re-mute → unmute → state restored, all HTTP 200. All three calls
2067
+ // return { siteMuteState: { muted, reason?, mutedBy: { wixUserId } } } — the
2068
+ // executors unwrap to `siteMuteState`.
2069
+ // Scope (proto doc comment, confirmed by Ping): mutes ALL notifications of the site
2070
+ // in context, for all recipients and all channels — sendability is denied regardless
2071
+ // of recipient-level preferences.
2072
+ // Spec 0012 hard invariant: when mute is in effect (always for new sites; explicit
2073
+ // opt-in for existing), a failed mute call means the run NEVER proceeds to import
2074
+ // writes — halt, no degraded mode.
2075
+ // AUTH TRAP (verified 2026-08-04): the permission grant covers USER tokens only.
2076
+ // The CLI-minted OauthNG site token (WIX_AUTH_TOKEN from config/wix.env) works; an
2077
+ // account API key gets a uniform empty-body 403 on all three endpoints.
2078
+ // IDEMPOTENCY TRAP (verified 2026-08-04): re-muting an already-muted site succeeds
2079
+ // but OVERWRITES `reason` (last caller wins) — the import preflight's re-call must
2080
+ // pass the same project-identifying reason as setup, or the audit trail degrades.
2081
+ // `unmuteSiteNotifications` is NEVER called by the flow itself — explicit owner
2082
+ // request only (spec 0012); after an on-request unmute, confirm with
2083
+ // getSiteMuteState (muted: false).
2084
+ const SITE_MUTE_REASON_MAX = 500;
2085
+ function buildMuteSiteNotificationsRequest({ reason } = {}) {
2086
+ const body = reason ? { reason: String(reason).slice(0, SITE_MUTE_REASON_MAX) } : {};
2087
+ return { method: 'POST', url: `${WIXAPIS}/notification-preferences/v1/site-mute/mute`, body };
2088
+ }
2089
+ async function muteSiteNotifications(wix, payload) {
2090
+ return (await wix.send(buildMuteSiteNotificationsRequest(payload))).siteMuteState;
2091
+ }
2092
+ function buildUnmuteSiteNotificationsRequest() {
2093
+ return { method: 'POST', url: `${WIXAPIS}/notification-preferences/v1/site-mute/unmute`, body: {} };
2094
+ }
2095
+ async function unmuteSiteNotifications(wix) {
2096
+ return (await wix.send(buildUnmuteSiteNotificationsRequest())).siteMuteState;
2097
+ }
2098
+ function buildGetSiteMuteStateRequest() {
2099
+ return { method: 'GET', url: `${WIXAPIS}/notification-preferences/v1/site-mute` };
2100
+ }
2101
+ async function getSiteMuteState(wix) {
2102
+ return (await wix.send(buildGetSiteMuteStateRequest())).siteMuteState;
2103
+ }
2104
+
2105
+ module.exports = {
2106
+ WIXAPIS,
2107
+ RICOS_PLUGINS,
2108
+ RICOS_HTML_CAP,
2109
+ DEFAULT_SAFE_MODE_PHONE_NUMBER,
2110
+ SafeModeBlockedError,
2111
+ createSafeModeConfig,
2112
+ createDryRunConfig,
2113
+ normalizeDryRunValue,
2114
+ createWixSetupExecutor,
2115
+ mockEmailForEntity,
2116
+ sanitizeContactFieldsForSafeMode,
2117
+ sanitizeWixRequestBody,
2118
+ createWixClient,
2119
+ buildDirectRestRequest,
2120
+ sendDirectRest,
2121
+ notifyMissingWriter,
2122
+ buildConvertToRicosRequest,
2123
+ splitHtmlIntoChunks,
2124
+ convertHtmlToRichContent,
2125
+ rewriteInlineMedia,
2126
+ buildImportMediaRequest,
2127
+ importMedia,
2128
+ waitUntilFileReady,
2129
+ buildCreateCategoryRequest,
2130
+ createBlogCategory,
2131
+ buildCreateTagRequest,
2132
+ createBlogTag,
2133
+ listBlogTags,
2134
+ buildCreateDraftPostRequest,
2135
+ createDraftPost,
2136
+ publishDraftPost,
2137
+ BLOG_BULK_CREATE_MAX,
2138
+ buildBulkCreateDraftPostsRequest,
2139
+ bulkCreateDraftPosts,
2140
+ buildInsertItemRequest,
2141
+ insertDataItem,
2142
+ queryAllDataItems,
2143
+ WIX_STORES_APP_ID,
2144
+ STORES_TREE_REFERENCE,
2145
+ STORES_SUBSCRIPTION_CONTRACT,
2146
+ STORES_SUBSCRIPTION_DESCRIPTION_MAX,
2147
+ STORES_SUBSCRIPTION_FREQUENCIES,
2148
+ normalizeStoresProductV3,
2149
+ normalizeStoresProductV3ForCreate,
2150
+ normalizeStoresProductMediaItems,
2151
+ normalizeStoresProductSubscriptions,
2152
+ clampStoresSubscriptionDescription,
2153
+ validateStoresProductSubscriptionDetails,
2154
+ buildStoresProductMedia,
2155
+ buildCreateStoresProductRequest,
2156
+ createStoresProduct,
2157
+ BULK_PRODUCT_LIMITS,
2158
+ storesProductBulkCost,
2159
+ buildBulkCreateStoresProductsRequest,
2160
+ bulkCreateStoresProductsWithInventory,
2161
+ buildQueryStoresProductsRequest,
2162
+ queryStoresProducts,
2163
+ queryAllStoresProducts,
2164
+ buildPatchStoresProductMediaRequest,
2165
+ patchStoresProductMedia,
2166
+ buildGetStoresProductRequest,
2167
+ getStoresProduct,
2168
+ buildGetStoresProductBySlugRequest,
2169
+ getStoresProductBySlug,
2170
+ buildDeleteStoresProductRequest,
2171
+ deleteStoresProduct,
2172
+ buildPatchStoresProductRequest,
2173
+ patchStoresProduct,
2174
+ buildQueryStoresCategoriesRequest,
2175
+ queryStoresCategories,
2176
+ queryAllStoresCategories,
2177
+ buildCreateStoresCategoryRequest,
2178
+ createStoresCategory,
2179
+ buildBulkAddItemToCategoriesRequest,
2180
+ bulkAddItemToCategories,
2181
+ buildCreateInventoryItemRequest,
2182
+ createInventoryItem,
2183
+ setProductVariantsInStock,
2184
+ normalizeV5Contact,
2185
+ contactInfoToV5Contact,
2186
+ buildCreateContactRequest,
2187
+ createContact,
2188
+ CONTACTS_BULK_UPSERT_MAX,
2189
+ buildBulkUpsertContactsRequest,
2190
+ bulkUpsertContacts,
2191
+ buildQueryContactsRequest,
2192
+ queryContacts,
2193
+ buildGetContactRequest,
2194
+ getContact,
2195
+ buildUpdateContactRequest,
2196
+ updateContact,
2197
+ buildFindOrCreateContactExtendedFieldRequest,
2198
+ findOrCreateContactExtendedField,
2199
+ buildCreateCouponRequest,
2200
+ createCoupon,
2201
+ buildQueryCouponsRequest,
2202
+ queryCoupons,
2203
+ buildCreateOrderRequest,
2204
+ createOrder,
2205
+ buildImportOrderRequest,
2206
+ importOrder,
2207
+ buildQueryOrdersRequest,
2208
+ queryOrders,
2209
+ listMembers,
2210
+ buildCreateMemberRequest,
2211
+ createMember,
2212
+ SITE_MUTE_REASON_MAX,
2213
+ buildMuteSiteNotificationsRequest,
2214
+ muteSiteNotifications,
2215
+ buildUnmuteSiteNotificationsRequest,
2216
+ unmuteSiteNotifications,
2217
+ buildGetSiteMuteStateRequest,
2218
+ getSiteMuteState,
2219
+ };