create-nextblock 0.2.78 → 0.8.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 (413) hide show
  1. package/bin/create-nextblock.js +740 -459
  2. package/package.json +1 -2
  3. package/scripts/sync-template.js +18 -1
  4. package/templates/nextblock-template/.browserslistrc +11 -0
  5. package/templates/nextblock-template/.swcrc +30 -30
  6. package/templates/nextblock-template/README.md +23 -114
  7. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -28
  8. package/templates/nextblock-template/app/(auth-pages)/sign-in/page.tsx +50 -25
  9. package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +111 -56
  10. package/templates/nextblock-template/app/(auth-pages)/two-factor/actions.ts +91 -0
  11. package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +118 -0
  12. package/templates/nextblock-template/app/(auth-pages)/two-factor/page.tsx +51 -0
  13. package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -0
  14. package/templates/nextblock-template/app/[slug]/PageClientContent.tsx +48 -28
  15. package/templates/nextblock-template/app/[slug]/page.tsx +63 -6
  16. package/templates/nextblock-template/app/[slug]/page.utils.ts +374 -157
  17. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -0
  18. package/templates/nextblock-template/app/actions/consent.ts +57 -0
  19. package/templates/nextblock-template/app/actions/formActions.ts +130 -11
  20. package/templates/nextblock-template/app/actions/languageActions.ts +31 -30
  21. package/templates/nextblock-template/app/actions/package-actions.ts +183 -0
  22. package/templates/nextblock-template/app/actions/postActions.ts +146 -48
  23. package/templates/nextblock-template/app/actions/twoFactorEmail.ts +21 -0
  24. package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +179 -0
  25. package/templates/nextblock-template/app/actions/visualEditingActions.ts +345 -0
  26. package/templates/nextblock-template/app/actions.ts +67 -12
  27. package/templates/nextblock-template/app/api/ai/cortex/build-widget/route.ts +153 -0
  28. package/templates/nextblock-template/app/api/ai/generate-blocks/route.ts +96 -0
  29. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +965 -0
  30. package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -0
  31. package/templates/nextblock-template/app/api/checkout/route.ts +146 -0
  32. package/templates/nextblock-template/app/api/cms/full-backup/export/route.ts +33 -0
  33. package/templates/nextblock-template/app/api/cms/full-backup/restore/route.ts +63 -0
  34. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +3413 -17
  35. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +7830 -0
  36. package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +35 -0
  37. package/templates/nextblock-template/app/api/custom-blocks/db-relations/route.ts +92 -0
  38. package/templates/nextblock-template/app/api/custom-blocks/editor-definitions/route.ts +43 -0
  39. package/templates/nextblock-template/app/api/draft/disable/route.ts +25 -0
  40. package/templates/nextblock-template/app/api/draft/route.ts +93 -0
  41. package/templates/nextblock-template/app/api/draft/start/route.ts +77 -0
  42. package/templates/nextblock-template/app/api/media/library/route.ts +65 -0
  43. package/templates/nextblock-template/app/api/media/r2-presigned/route.ts +53 -0
  44. package/templates/nextblock-template/app/api/media/record/route.ts +160 -0
  45. package/templates/nextblock-template/app/api/search/route.ts +43 -0
  46. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -0
  47. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -0
  48. package/templates/nextblock-template/app/api/webhooks/freemius/route.ts +34 -0
  49. package/templates/nextblock-template/app/api/webhooks/stripe/route.ts +27 -0
  50. package/templates/nextblock-template/app/article/[slug]/PostClientContent.tsx +392 -128
  51. package/templates/nextblock-template/app/article/[slug]/page.tsx +179 -127
  52. package/templates/nextblock-template/app/article/[slug]/page.utils.ts +262 -77
  53. package/templates/nextblock-template/app/auth/callback/route.ts +31 -58
  54. package/templates/nextblock-template/app/cart/page.tsx +7 -0
  55. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -0
  56. package/templates/nextblock-template/app/checkout/page.tsx +52 -0
  57. package/templates/nextblock-template/app/checkout/success/actions.ts +136 -0
  58. package/templates/nextblock-template/app/checkout/success/page.tsx +186 -0
  59. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +163 -33
  60. package/templates/nextblock-template/app/cms/blocks/actions.ts +424 -235
  61. package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +212 -151
  62. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +41 -20
  63. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +152 -19
  64. package/templates/nextblock-template/app/cms/blocks/components/BlockTypeCard.tsx +25 -17
  65. package/templates/nextblock-template/app/cms/blocks/components/BlockTypeSelector.tsx +200 -18
  66. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +33 -16
  67. package/templates/nextblock-template/app/cms/blocks/components/CustomBlockEditorPreview.tsx +160 -0
  68. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +37 -18
  69. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -67
  70. package/templates/nextblock-template/app/cms/blocks/components/SectionConfigPanel.tsx +108 -31
  71. package/templates/nextblock-template/app/cms/blocks/editors/DynamicCustomBlockEditor.tsx +167 -0
  72. package/templates/nextblock-template/app/cms/blocks/editors/FeaturedProductBlockEditor.tsx +31 -0
  73. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +2 -2
  74. package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +1 -1
  75. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +29 -29
  76. package/templates/nextblock-template/app/cms/blocks/editors/PostsGridBlockEditor.tsx +14 -18
  77. package/templates/nextblock-template/app/cms/blocks/editors/ProductGridBlockEditor.tsx +41 -0
  78. package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +318 -118
  79. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +98 -21
  80. package/templates/nextblock-template/app/cms/blocks/editors/VideoEmbedBlockEditor.tsx +1 -1
  81. package/templates/nextblock-template/app/cms/components/ContentLanguageSwitcher.tsx +27 -9
  82. package/templates/nextblock-template/app/cms/components/CopyContentFromLanguage.tsx +1 -1
  83. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -0
  84. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -0
  85. package/templates/nextblock-template/app/cms/components/CortexGlobalAgentChat.tsx +1507 -0
  86. package/templates/nextblock-template/app/cms/components/DraftStatusActions.tsx +145 -0
  87. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +244 -0
  88. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +38 -24
  89. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -0
  90. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -0
  91. package/templates/nextblock-template/app/cms/custom-blocks/[id]/edit/page.tsx +66 -0
  92. package/templates/nextblock-template/app/cms/custom-blocks/actions.ts +519 -0
  93. package/templates/nextblock-template/app/cms/custom-blocks/components/BlockComposer.tsx +1522 -0
  94. package/templates/nextblock-template/app/cms/custom-blocks/components/BlocksLibraryTransferControls.tsx +256 -0
  95. package/templates/nextblock-template/app/cms/custom-blocks/components/DBRelationSelect.tsx +384 -0
  96. package/templates/nextblock-template/app/cms/custom-blocks/components/ImageR2Picker.tsx +221 -0
  97. package/templates/nextblock-template/app/cms/custom-blocks/new/page.tsx +12 -0
  98. package/templates/nextblock-template/app/cms/custom-blocks/page.tsx +438 -0
  99. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -98
  100. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -0
  101. package/templates/nextblock-template/app/cms/dashboard/page.tsx +182 -154
  102. package/templates/nextblock-template/app/cms/import-export/ContentTransferControls.tsx +391 -0
  103. package/templates/nextblock-template/app/cms/import-export/actions.ts +226 -0
  104. package/templates/nextblock-template/app/cms/layout.tsx +29 -10
  105. package/templates/nextblock-template/app/cms/media/UploadFolderContext.tsx +22 -22
  106. package/templates/nextblock-template/app/cms/media/actions.ts +45 -124
  107. package/templates/nextblock-template/app/cms/media/components/DeleteMediaButtonClient.tsx +1 -1
  108. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +26 -26
  109. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -64
  110. package/templates/nextblock-template/app/cms/media/components/MediaPickerDialog.tsx +227 -158
  111. package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +101 -89
  112. package/templates/nextblock-template/app/cms/media/page.tsx +1 -1
  113. package/templates/nextblock-template/app/cms/navigation/components/NavigationItemForm.tsx +2 -2
  114. package/templates/nextblock-template/app/cms/orders/[id]/MarkPaidButton.tsx +44 -0
  115. package/templates/nextblock-template/app/cms/orders/[id]/page.tsx +16 -0
  116. package/templates/nextblock-template/app/cms/orders/actions.ts +201 -0
  117. package/templates/nextblock-template/app/cms/orders/page.tsx +20 -0
  118. package/templates/nextblock-template/app/cms/orders/types.ts +20 -0
  119. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +156 -121
  120. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +79 -26
  121. package/templates/nextblock-template/app/cms/pages/actions.ts +54 -38
  122. package/templates/nextblock-template/app/cms/pages/components/DeletePageButtonClient.tsx +1 -1
  123. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +267 -116
  124. package/templates/nextblock-template/app/cms/pages/page.tsx +25 -18
  125. package/templates/nextblock-template/app/cms/payments/page.tsx +16 -0
  126. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +132 -90
  127. package/templates/nextblock-template/app/cms/posts/actions.ts +71 -72
  128. package/templates/nextblock-template/app/cms/posts/components/DeletePostButtonClient.tsx +1 -1
  129. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +256 -245
  130. package/templates/nextblock-template/app/cms/posts/new/page.tsx +1 -1
  131. package/templates/nextblock-template/app/cms/posts/page.tsx +20 -13
  132. package/templates/nextblock-template/app/cms/products/ClientNotionEditor.tsx +16 -0
  133. package/templates/nextblock-template/app/cms/products/ProductFormClientShell.tsx +56 -0
  134. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +292 -0
  135. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -0
  136. package/templates/nextblock-template/app/cms/products/categories/page.tsx +12 -0
  137. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -0
  138. package/templates/nextblock-template/app/cms/products/new/page.tsx +143 -0
  139. package/templates/nextblock-template/app/cms/products/page.tsx +42 -0
  140. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -0
  141. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -0
  142. package/templates/nextblock-template/app/cms/promotions/PromotionsWorkspace.tsx +456 -0
  143. package/templates/nextblock-template/app/cms/promotions/actions.ts +115 -0
  144. package/templates/nextblock-template/app/cms/promotions/page.tsx +31 -0
  145. package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +2 -2
  146. package/templates/nextblock-template/app/cms/revisions/actions.ts +285 -285
  147. package/templates/nextblock-template/app/cms/revisions/service.ts +19 -16
  148. package/templates/nextblock-template/app/cms/revisions/utils.ts +8 -3
  149. package/templates/nextblock-template/app/cms/settings/backup-restore/BackupRestoreWorkspace.tsx +1004 -0
  150. package/templates/nextblock-template/app/cms/settings/backup-restore/page.tsx +29 -0
  151. package/templates/nextblock-template/app/cms/settings/bot-protection/actions.ts +93 -0
  152. package/templates/nextblock-template/app/cms/settings/bot-protection/components/BotProtectionForm.tsx +129 -0
  153. package/templates/nextblock-template/app/cms/settings/bot-protection/page.tsx +24 -0
  154. package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +1 -1
  155. package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +2 -2
  156. package/templates/nextblock-template/app/cms/settings/copyright/page.tsx +1 -1
  157. package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +496 -0
  158. package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +410 -0
  159. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +248 -0
  160. package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +80 -0
  161. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -0
  162. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -0
  163. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -0
  164. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +203 -44
  165. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -242
  166. package/templates/nextblock-template/app/cms/settings/global-css/actions.ts +65 -0
  167. package/templates/nextblock-template/app/cms/settings/global-css/components/GlobalCssForm.tsx +46 -0
  168. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +24 -0
  169. package/templates/nextblock-template/app/cms/settings/languages/components/DeleteLanguageButton.tsx +1 -1
  170. package/templates/nextblock-template/app/cms/settings/languages/components/LanguageForm.tsx +2 -2
  171. package/templates/nextblock-template/app/cms/settings/languages/page.tsx +1 -1
  172. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  173. package/templates/nextblock-template/app/cms/settings/logos/actions.ts +82 -6
  174. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -0
  175. package/templates/nextblock-template/app/cms/settings/logos/components/DeleteLogoButton.tsx +21 -18
  176. package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +20 -16
  177. package/templates/nextblock-template/app/cms/settings/logos/components/SiteSeoSettingsForm.tsx +133 -0
  178. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  179. package/templates/nextblock-template/app/cms/settings/logos/page.tsx +120 -82
  180. package/templates/nextblock-template/app/cms/settings/logos/types.ts +8 -8
  181. package/templates/nextblock-template/app/cms/settings/packages/activation-form.tsx +84 -0
  182. package/templates/nextblock-template/app/cms/settings/packages/package-card.tsx +122 -0
  183. package/templates/nextblock-template/app/cms/settings/packages/page.tsx +49 -0
  184. package/templates/nextblock-template/app/cms/settings/privacy/actions.ts +53 -0
  185. package/templates/nextblock-template/app/cms/settings/privacy/components/PrivacyForm.tsx +196 -0
  186. package/templates/nextblock-template/app/cms/settings/privacy/page.tsx +26 -0
  187. package/templates/nextblock-template/app/cms/settings/security/actions.ts +251 -0
  188. package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +453 -0
  189. package/templates/nextblock-template/app/cms/settings/security/page.tsx +13 -0
  190. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -0
  191. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -0
  192. package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +28 -23
  193. package/templates/nextblock-template/app/cms/users/actions.ts +105 -40
  194. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +1 -1
  195. package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +65 -152
  196. package/templates/nextblock-template/app/cms/users/page.tsx +15 -10
  197. package/templates/nextblock-template/app/globals.css +9 -0
  198. package/templates/nextblock-template/app/layout.tsx +372 -120
  199. package/templates/nextblock-template/app/lib/seo.test.ts +52 -0
  200. package/templates/nextblock-template/app/lib/seo.ts +279 -0
  201. package/templates/nextblock-template/app/lib/site-settings.ts +87 -0
  202. package/templates/nextblock-template/app/lib/sitemap-utils.ts +224 -39
  203. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -0
  204. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -0
  205. package/templates/nextblock-template/app/lib/ucp/server.ts +1914 -0
  206. package/templates/nextblock-template/app/page.tsx +165 -73
  207. package/templates/nextblock-template/app/product/[slug]/page.tsx +433 -0
  208. package/templates/nextblock-template/app/profile/ProfileAccountSidebar.tsx +73 -0
  209. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -0
  210. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -0
  211. package/templates/nextblock-template/app/profile/account-data.ts +37 -0
  212. package/templates/nextblock-template/app/profile/account-links.ts +22 -0
  213. package/templates/nextblock-template/app/profile/account-types.ts +11 -0
  214. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -0
  215. package/templates/nextblock-template/app/profile/orders/[id]/CustomerOrderDetailPageClient.tsx +79 -0
  216. package/templates/nextblock-template/app/profile/orders/[id]/page.tsx +32 -0
  217. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -0
  218. package/templates/nextblock-template/app/profile/page.tsx +51 -0
  219. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -0
  220. package/templates/nextblock-template/app/profile/password/actions.ts +59 -0
  221. package/templates/nextblock-template/app/profile/password/page.tsx +27 -0
  222. package/templates/nextblock-template/app/providers.tsx +55 -17
  223. package/templates/nextblock-template/app/robots.txt/route.ts +11 -1
  224. package/templates/nextblock-template/app/sitemap.ts +128 -0
  225. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -0
  226. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -0
  227. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -0
  228. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -0
  229. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -0
  230. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -0
  231. package/templates/nextblock-template/components/AppShell.tsx +154 -0
  232. package/templates/nextblock-template/components/BlockRenderer.tsx +210 -64
  233. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -0
  234. package/templates/nextblock-template/components/CartTranslator.tsx +210 -0
  235. package/templates/nextblock-template/components/CurrentContentSetter.tsx +25 -0
  236. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -0
  237. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -0
  238. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -0
  239. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -0
  240. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -0
  241. package/templates/nextblock-template/components/FeatureImageHero.tsx +47 -0
  242. package/templates/nextblock-template/components/GitHubLoginButton.tsx +36 -0
  243. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -0
  244. package/templates/nextblock-template/components/Header.tsx +49 -41
  245. package/templates/nextblock-template/components/LanguageSwitcher.tsx +55 -32
  246. package/templates/nextblock-template/components/ResponsiveNav.tsx +138 -43
  247. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -8
  248. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -55
  249. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +42 -37
  250. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +6 -2
  251. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -0
  252. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -0
  253. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -84
  254. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +17 -0
  255. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +19 -0
  256. package/templates/nextblock-template/components/blocks/renderers/ClientTextBlockRenderer.tsx +262 -8
  257. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +22 -0
  258. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +320 -37
  259. package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +11 -8
  260. package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +12 -3
  261. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +18 -13
  262. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +90 -0
  263. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +31 -0
  264. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +424 -55
  265. package/templates/nextblock-template/components/blocks/renderers/SectionSlider.tsx +137 -0
  266. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -0
  267. package/templates/nextblock-template/components/blocks/renderers/TextBlockRenderer.tsx +37 -22
  268. package/templates/nextblock-template/components/blocks/renderers/VideoEmbedBlockRenderer.tsx +23 -15
  269. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +1 -3
  270. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +1 -3
  271. package/templates/nextblock-template/components/blocks/types.ts +7 -6
  272. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  273. package/templates/nextblock-template/components/form-message.tsx +32 -26
  274. package/templates/nextblock-template/components/header-auth.tsx +69 -17
  275. package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +127 -0
  276. package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +59 -0
  277. package/templates/nextblock-template/components/renderers/CachedDynamicLayoutEngine.tsx +28 -0
  278. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.test.tsx +166 -0
  279. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.tsx +464 -0
  280. package/templates/nextblock-template/components/theme-switcher.tsx +8 -8
  281. package/templates/nextblock-template/components/visual-editing/DeferredVisualEditing.tsx +21 -0
  282. package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +1172 -0
  283. package/templates/nextblock-template/context/AuthContext.tsx +23 -90
  284. package/templates/nextblock-template/context/CurrentContentContext.tsx +10 -4
  285. package/templates/nextblock-template/context/LanguageContext.tsx +16 -16
  286. package/templates/nextblock-template/context/language-rest-client.ts +31 -0
  287. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -0
  288. package/templates/nextblock-template/docs/02-ECOMMERCE-CAPABILITIES.md +364 -0
  289. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +202 -0
  290. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +252 -0
  291. package/templates/nextblock-template/docs/05-DEVELOPER-GUIDE.md +238 -0
  292. package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +125 -0
  293. package/templates/nextblock-template/docs/07-BLOCK-SDK-AND-EXTENSIBILITY.md +146 -0
  294. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +1319 -0
  295. package/templates/nextblock-template/docs/09-LIVE-DRAFT-MODE.md +104 -0
  296. package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +222 -0
  297. package/templates/nextblock-template/docs/README.md +34 -0
  298. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12507 -0
  299. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -14
  300. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -0
  301. package/templates/nextblock-template/index.d.ts +2 -0
  302. package/templates/nextblock-template/lib/ai-block-generation.ts +339 -0
  303. package/templates/nextblock-template/lib/ai-client.ts +247 -0
  304. package/templates/nextblock-template/lib/ai-config.ts +81 -0
  305. package/templates/nextblock-template/lib/ai-cortex-widget-builder.ts +125 -0
  306. package/templates/nextblock-template/lib/ai-global-agent-custom-block-tools.ts +363 -0
  307. package/templates/nextblock-template/lib/ai-global-agent-db-tools.test.ts +405 -0
  308. package/templates/nextblock-template/lib/ai-global-agent-db-tools.ts +1228 -0
  309. package/templates/nextblock-template/lib/ai-global-agent-ecommerce.ts +5 -0
  310. package/templates/nextblock-template/lib/ai-global-agent-tools-stats.test.ts +223 -0
  311. package/templates/nextblock-template/lib/ai-global-agent-tools.test.ts +2183 -0
  312. package/templates/nextblock-template/lib/ai-global-agent-tools.ts +4807 -0
  313. package/templates/nextblock-template/lib/ai-key-crypto.test.ts +70 -0
  314. package/templates/nextblock-template/lib/ai-key-crypto.ts +132 -0
  315. package/templates/nextblock-template/lib/ai-model-catalog.test.ts +49 -0
  316. package/templates/nextblock-template/lib/ai-model-catalog.ts +41 -0
  317. package/templates/nextblock-template/lib/ai-model-registry.test.ts +231 -0
  318. package/templates/nextblock-template/lib/ai-model-registry.ts +522 -0
  319. package/templates/nextblock-template/lib/auth/cookies.ts +47 -0
  320. package/templates/nextblock-template/lib/auth/crypto.ts +42 -0
  321. package/templates/nextblock-template/lib/auth/trustedDevices.ts +92 -0
  322. package/templates/nextblock-template/lib/auth/twoFactor.ts +167 -0
  323. package/templates/nextblock-template/lib/auth-redirects.ts +46 -0
  324. package/templates/nextblock-template/lib/blocks/FeaturedProductBlock.tsx +94 -0
  325. package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +137 -0
  326. package/templates/nextblock-template/lib/blocks/README.md +13 -670
  327. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +138 -56
  328. package/templates/nextblock-template/lib/blocks/blockTypes.ts +18 -0
  329. package/templates/nextblock-template/lib/blocks/ecommerce-block-schemas.ts +31 -0
  330. package/templates/nextblock-template/lib/cms-transfer/csv.test.ts +77 -0
  331. package/templates/nextblock-template/lib/cms-transfer/csv.ts +399 -0
  332. package/templates/nextblock-template/lib/cms-transfer/server.ts +2243 -0
  333. package/templates/nextblock-template/lib/cms-transfer/types.ts +145 -0
  334. package/templates/nextblock-template/lib/cortex-widget-registry.test.ts +199 -0
  335. package/templates/nextblock-template/lib/cortex-widget-registry.ts +88 -0
  336. package/templates/nextblock-template/lib/cortex-widget-schema.test.tsx +237 -0
  337. package/templates/nextblock-template/lib/cortex-widget-schema.ts +393 -0
  338. package/templates/nextblock-template/lib/custom-block-definitions.ts +87 -0
  339. package/templates/nextblock-template/lib/custom-block-r2-upload-shared.ts +178 -0
  340. package/templates/nextblock-template/lib/custom-block-r2-upload.test.ts +140 -0
  341. package/templates/nextblock-template/lib/custom-block-r2-upload.ts +68 -0
  342. package/templates/nextblock-template/lib/custom-block-relation-registry.ts +256 -0
  343. package/templates/nextblock-template/lib/custom-block-relations.test.ts +227 -0
  344. package/templates/nextblock-template/lib/custom-block-relations.ts +279 -0
  345. package/templates/nextblock-template/lib/custom-block-safelist.ts +14 -0
  346. package/templates/nextblock-template/lib/editor/dynamic-extension-core.test.ts +172 -0
  347. package/templates/nextblock-template/lib/editor/dynamic-extension-core.ts +213 -0
  348. package/templates/nextblock-template/lib/editor/dynamic-extension-loader.ts +22 -0
  349. package/templates/nextblock-template/lib/editor/dynamic-extensions.tsx +193 -0
  350. package/templates/nextblock-template/lib/full-backup/manifest.test.ts +121 -0
  351. package/templates/nextblock-template/lib/full-backup/manifest.ts +206 -0
  352. package/templates/nextblock-template/lib/full-backup/server.ts +743 -0
  353. package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +45 -0
  354. package/templates/nextblock-template/lib/posts/readTime.ts +60 -0
  355. package/templates/nextblock-template/lib/privacy/consent-client.ts +57 -0
  356. package/templates/nextblock-template/lib/privacy/settings.ts +103 -0
  357. package/templates/nextblock-template/lib/privacy/types.ts +67 -0
  358. package/templates/nextblock-template/lib/promotions/server.test.ts +74 -0
  359. package/templates/nextblock-template/lib/promotions/server.ts +741 -0
  360. package/templates/nextblock-template/lib/resolve-block-relations.test.ts +142 -0
  361. package/templates/nextblock-template/lib/resolve-block-relations.ts +255 -0
  362. package/templates/nextblock-template/lib/search/server.ts +585 -0
  363. package/templates/nextblock-template/lib/search/types.ts +27 -0
  364. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -0
  365. package/templates/nextblock-template/lib/visual-editing/draft-content.ts +380 -0
  366. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -0
  367. package/templates/nextblock-template/lib/visual-editing/draft-route.ts +82 -0
  368. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -0
  369. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -0
  370. package/templates/nextblock-template/lib/visual-editing/mutations.ts +190 -0
  371. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -0
  372. package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +511 -0
  373. package/templates/nextblock-template/lib/visual-editing/types.ts +122 -0
  374. package/templates/nextblock-template/lib/zod-config.ts +5 -0
  375. package/templates/nextblock-template/next.config.js +190 -66
  376. package/templates/nextblock-template/package.json +34 -30
  377. package/templates/nextblock-template/proxy.ts +435 -253
  378. package/templates/nextblock-template/public/images/NBcover.webp +0 -0
  379. package/templates/nextblock-template/public/images/cap.webp +0 -0
  380. package/templates/nextblock-template/public/images/commerce-plan.webp +0 -0
  381. package/templates/nextblock-template/public/images/commerce-square.webp +0 -0
  382. package/templates/nextblock-template/public/images/commerce-wide.webp +0 -0
  383. package/templates/nextblock-template/public/images/cortex-ai-square.webp +0 -0
  384. package/templates/nextblock-template/public/images/cortex-ai.webp +0 -0
  385. package/templates/nextblock-template/public/images/extensibility.webp +0 -0
  386. package/templates/nextblock-template/public/images/goals.webp +0 -0
  387. package/templates/nextblock-template/public/images/included.webp +0 -0
  388. package/templates/nextblock-template/public/images/nx-graph.webp +0 -0
  389. package/templates/nextblock-template/public/images/pants.webp +0 -0
  390. package/templates/nextblock-template/public/images/t-shirt.webp +0 -0
  391. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -0
  392. package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +100 -0
  393. package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +62 -0
  394. package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +537 -0
  395. package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +58 -0
  396. package/templates/nextblock-template/scripts/verify-custom-block-definitions.ts +188 -0
  397. package/templates/nextblock-template/scripts/verify-dynamic-custom-block-extensions.ts +123 -0
  398. package/templates/nextblock-template/scripts/verify-dynamic-layout-engine.tsx +133 -0
  399. package/templates/nextblock-template/scripts/verify-milestone-2-custom-blocks.ts +65 -0
  400. package/templates/nextblock-template/tailwind.config.js +1 -0
  401. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -0
  402. package/templates/nextblock-template/tools/deploy-supabase.js +69 -71
  403. package/templates/nextblock-template/tsconfig.json +52 -66
  404. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
  405. package/templates/nextblock-template/types/jsdom.d.ts +6 -0
  406. package/templates/nextblock-template/app/force-styles.tsx +0 -31
  407. package/templates/nextblock-template/app/sitemap.xml/route.ts +0 -63
  408. package/templates/nextblock-template/components/blocks/renderers/HeroBlockRenderer.tsx +0 -273
  409. package/templates/nextblock-template/docs/How to Create a Custom Block.md +0 -149
  410. package/templates/nextblock-template/docs/cms-application-overview.md +0 -56
  411. package/templates/nextblock-template/docs/cms-architecture-overview.md +0 -73
  412. package/templates/nextblock-template/docs/files-structure.md +0 -426
  413. package/templates/nextblock-template/docs/tiptap-bundle-optimization-summary.md +0 -174
@@ -0,0 +1,2183 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('@nextblock-cms/utils', async () => {
4
+ const { z } = await import('zod');
5
+
6
+ return {
7
+ editorBlockDocumentSchema: z.object({
8
+ content: z.array(z.any()).optional(),
9
+ type: z.literal('doc'),
10
+ }),
11
+ minorUnitAmountToMajor: (amount: number) => amount / 100,
12
+ };
13
+ });
14
+
15
+ vi.mock('@nextblock-cms/ecommerce', async () => {
16
+ const { z } = await import('zod');
17
+ const currencyPriceMapSchema = z.record(z.string(), z.coerce.number().min(0)).default({});
18
+ const currencySalePriceMapSchema = z
19
+ .record(z.string(), z.coerce.number().min(0).nullable())
20
+ .default({});
21
+
22
+ return {
23
+ productSchema: z
24
+ .object({
25
+ description_json: z.any().optional(),
26
+ freemius_plan_id: z.string().optional(),
27
+ freemius_product_id: z.string().optional(),
28
+ is_taxable: z.boolean(),
29
+ language_id: z.coerce.number().int().min(1),
30
+ meta_description: z.string().optional().nullable(),
31
+ meta_title: z.string().optional().nullable(),
32
+ payment_provider: z.enum(['stripe', 'freemius']),
33
+ price: z.coerce.number().min(0),
34
+ prices: currencyPriceMapSchema,
35
+ product_media: z.array(z.object({ media_id: z.string() })).optional(),
36
+ product_type: z.enum(['physical', 'digital']),
37
+ sale_price: z.coerce.number().min(0).optional().nullable(),
38
+ sale_prices: currencySalePriceMapSchema,
39
+ short_description: z.string().optional(),
40
+ sku: z.string().min(1),
41
+ slug: z.string().min(1),
42
+ status: z.enum(['draft', 'active', 'archived']),
43
+ stock: z.coerce.number().int().min(0),
44
+ title: z.string().min(1),
45
+ upc: z.string().optional().nullable(),
46
+ variation_attributes: z.array(z.any()).optional(),
47
+ variants: z.array(z.any()).optional(),
48
+ })
49
+ .refine(
50
+ (product) =>
51
+ product.sale_price === null ||
52
+ product.sale_price === undefined ||
53
+ product.sale_price <= product.price,
54
+ { path: ['sale_price'] }
55
+ ),
56
+ };
57
+ });
58
+
59
+ vi.mock('@nextblock-cms/ecommerce/server', async () => {
60
+ const { z } = await import('zod');
61
+ const currencyPriceMapSchema = z.record(z.string(), z.coerce.number().min(0)).default({});
62
+ const currencySalePriceMapSchema = z
63
+ .record(z.string(), z.coerce.number().min(0).nullable())
64
+ .default({});
65
+ const productSchema = z
66
+ .object({
67
+ description_json: z.any().optional(),
68
+ freemius_plan_id: z.string().optional(),
69
+ freemius_product_id: z.string().optional(),
70
+ is_taxable: z.boolean(),
71
+ language_id: z.coerce.number().int().min(1),
72
+ meta_description: z.string().optional().nullable(),
73
+ meta_title: z.string().optional().nullable(),
74
+ payment_provider: z.enum(['stripe', 'freemius']),
75
+ price: z.coerce.number().min(0),
76
+ prices: currencyPriceMapSchema,
77
+ product_media: z.array(z.object({ media_id: z.string() })).optional(),
78
+ product_type: z.enum(['physical', 'digital']),
79
+ sale_price: z.coerce.number().min(0).optional().nullable(),
80
+ sale_prices: currencySalePriceMapSchema,
81
+ short_description: z.string().optional(),
82
+ sku: z.string().min(1),
83
+ slug: z.string().min(1),
84
+ status: z.enum(['draft', 'active', 'archived']),
85
+ stock: z.coerce.number().int().min(0),
86
+ title: z.string().min(1),
87
+ upc: z.string().optional().nullable(),
88
+ variation_attributes: z.array(z.any()).optional(),
89
+ variants: z.array(z.any()).optional(),
90
+ })
91
+ .refine(
92
+ (product) =>
93
+ product.sale_price === null ||
94
+ product.sale_price === undefined ||
95
+ product.sale_price <= product.price,
96
+ { path: ['sale_price'] }
97
+ );
98
+
99
+ return {
100
+ createProduct: async (supabase: any, input: Record<string, any>) => {
101
+ const { data } = await supabase
102
+ .from('products')
103
+ .insert({
104
+ ...input,
105
+ price: Math.round(Number(input.price || 0) * 100),
106
+ sale_price:
107
+ input.sale_price === null || input.sale_price === undefined
108
+ ? null
109
+ : Math.round(Number(input.sale_price) * 100),
110
+ })
111
+ .select('*');
112
+
113
+ return data?.[0] ?? null;
114
+ },
115
+ productSchema,
116
+ updateProduct: async (supabase: any, id: string, input: Record<string, any>) => {
117
+ const { data } = await supabase
118
+ .from('products')
119
+ .update({
120
+ ...input,
121
+ price: Math.round(Number(input.price || 0) * 100),
122
+ sale_price:
123
+ input.sale_price === null || input.sale_price === undefined
124
+ ? null
125
+ : Math.round(Number(input.sale_price) * 100),
126
+ })
127
+ .eq('id', id)
128
+ .select('*')
129
+ .single();
130
+
131
+ return data;
132
+ },
133
+ };
134
+ });
135
+
136
+ import {
137
+ buildVisibleContactIntroActionPlan,
138
+ executeCmsActionPlan,
139
+ executeCreateCmsPage,
140
+ executeCreateCmsPost,
141
+ executeCreateCmsProduct,
142
+ executeDeleteCmsItem,
143
+ executeInsertContentBlock,
144
+ executePrepareDeleteCmsItem,
145
+ executeReadCurrentCmsItem,
146
+ executeSearchDocumentation,
147
+ executeSearchDocumentationWithTimeout,
148
+ executeUpdateContentBlock,
149
+ executeUpdateCmsItemField,
150
+ executeUpdateCurrentCmsFields,
151
+ executeUpdateFooter,
152
+ executeUpdateNavigationBar,
153
+ executeUpdateSectionColumnBlock,
154
+ } from './ai-global-agent-tools';
155
+
156
+ type MockRow = Record<string, any>;
157
+
158
+ type MockDatabase = {
159
+ blocks: MockRow[];
160
+ currencies: MockRow[];
161
+ languages: MockRow[];
162
+ navigation_items: MockRow[];
163
+ pages: MockRow[];
164
+ posts: MockRow[];
165
+ products: MockRow[];
166
+ site_settings: MockRow[];
167
+ };
168
+
169
+ class MockQuery {
170
+ private filters: Array<{ column: string; value: unknown }> = [];
171
+ private limitCount: number | null = null;
172
+ private operation: 'delete' | 'insert' | 'select' | 'update' | 'upsert' = 'select';
173
+ private payload: MockRow | MockRow[] | null = null;
174
+
175
+ constructor(
176
+ private readonly database: MockDatabase,
177
+ private readonly calls: MockRow[],
178
+ private readonly table: keyof MockDatabase
179
+ ) {}
180
+
181
+ select(columns?: string) {
182
+ this.calls.push({ columns, operation: 'select', table: this.table });
183
+ return this;
184
+ }
185
+
186
+ eq(column: string, value: unknown) {
187
+ this.filters.push({ column, value });
188
+ return this;
189
+ }
190
+
191
+ limit(count: number) {
192
+ this.limitCount = count;
193
+ return this;
194
+ }
195
+
196
+ delete() {
197
+ this.operation = 'delete';
198
+ this.calls.push({ operation: 'delete', table: this.table });
199
+ return this;
200
+ }
201
+
202
+ insert(payload: MockRow | MockRow[]) {
203
+ this.operation = 'insert';
204
+ this.payload = payload;
205
+ this.calls.push({ operation: 'insert', payload, table: this.table });
206
+ return this;
207
+ }
208
+
209
+ update(payload: MockRow) {
210
+ this.operation = 'update';
211
+ this.payload = payload;
212
+ this.calls.push({ operation: 'update', payload, table: this.table });
213
+ return this;
214
+ }
215
+
216
+ upsert(payload: MockRow | MockRow[]) {
217
+ this.operation = 'upsert';
218
+ this.payload = payload;
219
+ this.calls.push({ operation: 'upsert', payload, table: this.table });
220
+ return this;
221
+ }
222
+
223
+ order() {
224
+ return this;
225
+ }
226
+
227
+ maybeSingle() {
228
+ return this.execute().then((result) => ({
229
+ data: result.data?.[0] ?? null,
230
+ error: result.error,
231
+ }));
232
+ }
233
+
234
+ single() {
235
+ return this.execute().then((result) => ({
236
+ data: Array.isArray(result.data) ? result.data[0] : result.data,
237
+ error: result.error,
238
+ }));
239
+ }
240
+
241
+ then<TResult1 = any, TResult2 = never>(
242
+ onfulfilled?: ((value: any) => TResult1 | PromiseLike<TResult1>) | null,
243
+ onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
244
+ ) {
245
+ return this.execute().then(onfulfilled, onrejected);
246
+ }
247
+
248
+ private matchesFilters(row: MockRow) {
249
+ return this.filters.every((filter) => row[filter.column] === filter.value);
250
+ }
251
+
252
+ private async execute() {
253
+ if (this.operation === 'delete') {
254
+ const beforeCount = this.database[this.table].length;
255
+ this.database[this.table] = this.database[this.table].filter(
256
+ (row) => !this.matchesFilters(row)
257
+ );
258
+
259
+ return {
260
+ data: null,
261
+ error: null,
262
+ removed: beforeCount - this.database[this.table].length,
263
+ };
264
+ }
265
+
266
+ if (this.operation === 'insert') {
267
+ const rows = Array.isArray(this.payload) ? this.payload : [this.payload];
268
+ const inserted = rows.filter(Boolean).map((row) => {
269
+ const nextId =
270
+ this.database[this.table].reduce((max, current) => Math.max(max, Number(current.id) || 0), 0) +
271
+ 1;
272
+ return { id: nextId, ...row };
273
+ });
274
+
275
+ this.database[this.table].push(...inserted);
276
+
277
+ return {
278
+ data: inserted,
279
+ error: null,
280
+ };
281
+ }
282
+
283
+ if (this.operation === 'update') {
284
+ const payload = Array.isArray(this.payload) ? this.payload[0] : this.payload;
285
+ const updated: MockRow[] = [];
286
+
287
+ this.database[this.table] = this.database[this.table].map((row) => {
288
+ if (!this.matchesFilters(row)) {
289
+ return row;
290
+ }
291
+
292
+ const nextRow = { ...row, ...payload };
293
+ updated.push(nextRow);
294
+ return nextRow;
295
+ });
296
+
297
+ return {
298
+ data: updated,
299
+ error: null,
300
+ };
301
+ }
302
+
303
+ if (this.operation === 'upsert') {
304
+ const rows = Array.isArray(this.payload) ? this.payload : [this.payload];
305
+
306
+ for (const row of rows.filter(Boolean)) {
307
+ const existingIndex = this.database[this.table].findIndex(
308
+ (current) => current.key && current.key === row.key
309
+ );
310
+
311
+ if (existingIndex >= 0) {
312
+ this.database[this.table][existingIndex] = {
313
+ ...this.database[this.table][existingIndex],
314
+ ...row,
315
+ };
316
+ } else {
317
+ this.database[this.table].push(row);
318
+ }
319
+ }
320
+
321
+ return {
322
+ data: rows,
323
+ error: null,
324
+ };
325
+ }
326
+
327
+ let data = this.database[this.table].filter((row) => this.matchesFilters(row));
328
+
329
+ if (this.limitCount !== null) {
330
+ data = data.slice(0, this.limitCount);
331
+ }
332
+
333
+ return {
334
+ data,
335
+ error: null,
336
+ };
337
+ }
338
+ }
339
+
340
+ function createMockSupabase(overrides?: Partial<MockDatabase>) {
341
+ const calls: MockRow[] = [];
342
+ const database: MockDatabase = {
343
+ blocks: [],
344
+ currencies: [{ code: 'USD', id: 1, is_active: true, is_default: true }],
345
+ languages: [{ code: 'en', id: 1 }],
346
+ navigation_items: [
347
+ { id: 1, label: 'Old', language_id: 1, menu_key: 'HEADER', order: 0, url: '/old' },
348
+ ],
349
+ pages: [],
350
+ posts: [],
351
+ products: [],
352
+ site_settings: [],
353
+ ...overrides,
354
+ };
355
+
356
+ return {
357
+ calls,
358
+ database,
359
+ supabase: {
360
+ from: (table: string) => {
361
+ if (!(table in database)) {
362
+ throw new Error(`Unexpected mock table: ${table}`);
363
+ }
364
+
365
+ return new MockQuery(database, calls, table as keyof MockDatabase);
366
+ },
367
+ },
368
+ };
369
+ }
370
+
371
+ function expectConfirmation(result: any) {
372
+ expect(result).toMatchObject({
373
+ mutationExecuted: false,
374
+ requiresConfirmation: true,
375
+ success: true,
376
+ });
377
+ expect(result.confirmationPhrase).toEqual(expect.stringMatching(/^CONFIRM .+ #[a-f0-9]{8}$/));
378
+ }
379
+
380
+ async function executeConfirmed(
381
+ executor: (input: any, context?: any) => Promise<any>,
382
+ input: any,
383
+ context: any = {}
384
+ ) {
385
+ const preview = await executor(input, context);
386
+ expectConfirmation(preview);
387
+
388
+ return executor(input, {
389
+ ...context,
390
+ latestUserMessage: preview.confirmationPhrase,
391
+ });
392
+ }
393
+
394
+ describe('Cortex AI global agent tool executors', () => {
395
+ it('replaces the header navigation menu for the selected locale', async () => {
396
+ const revalidated: string[] = [];
397
+ const { database, supabase } = createMockSupabase();
398
+
399
+ const result = await executeConfirmed(
400
+ executeUpdateNavigationBar,
401
+ {
402
+ items: [
403
+ {
404
+ children: [{ label: 'Team', url: '/about/team' }],
405
+ label: 'About',
406
+ url: '/about',
407
+ },
408
+ { label: 'Contact', target: '_self', url: '/contact' },
409
+ ],
410
+ languageCode: 'en',
411
+ mode: 'replace',
412
+ },
413
+ {
414
+ revalidatePath: (path) => revalidated.push(path),
415
+ supabase,
416
+ }
417
+ );
418
+
419
+ expect(result).toEqual({
420
+ insertedCount: 3,
421
+ languageCode: 'en',
422
+ menuKey: 'HEADER',
423
+ mode: 'replace',
424
+ skippedCount: 0,
425
+ mutationExecuted: true,
426
+ success: true,
427
+ updatedCount: 0,
428
+ });
429
+ expect(database.navigation_items).toEqual([
430
+ {
431
+ id: 1,
432
+ label: 'About',
433
+ language_id: 1,
434
+ menu_key: 'HEADER',
435
+ order: 0,
436
+ page_id: null,
437
+ parent_id: null,
438
+ url: '/about',
439
+ },
440
+ {
441
+ id: 2,
442
+ label: 'Team',
443
+ language_id: 1,
444
+ menu_key: 'HEADER',
445
+ order: 0,
446
+ page_id: null,
447
+ parent_id: 1,
448
+ url: '/about/team',
449
+ },
450
+ {
451
+ id: 3,
452
+ label: 'Contact',
453
+ language_id: 1,
454
+ menu_key: 'HEADER',
455
+ order: 1,
456
+ page_id: null,
457
+ parent_id: null,
458
+ url: '/contact',
459
+ },
460
+ ]);
461
+ expect(revalidated).toEqual(['/', '/cms/navigation']);
462
+ });
463
+
464
+ it('appends header navigation items without clearing existing links', async () => {
465
+ const { database, supabase } = createMockSupabase({
466
+ navigation_items: [
467
+ { id: 1, label: 'Home', language_id: 1, menu_key: 'HEADER', order: 0, url: '/' },
468
+ { id: 2, label: 'Articles', language_id: 1, menu_key: 'HEADER', order: 1, url: '/articles' },
469
+ ],
470
+ });
471
+
472
+ const result = await executeConfirmed(
473
+ executeUpdateNavigationBar,
474
+ {
475
+ items: [{ label: 'Contact', url: '/contact' }],
476
+ languageCode: 'en',
477
+ mode: 'append',
478
+ },
479
+ { revalidatePath: () => undefined, supabase }
480
+ );
481
+
482
+ expect(result).toEqual({
483
+ insertedCount: 1,
484
+ languageCode: 'en',
485
+ menuKey: 'HEADER',
486
+ mode: 'append',
487
+ mutationExecuted: true,
488
+ skippedCount: 0,
489
+ success: true,
490
+ updatedCount: 0,
491
+ });
492
+ expect(database.navigation_items).toEqual([
493
+ { id: 1, label: 'Home', language_id: 1, menu_key: 'HEADER', order: 0, url: '/' },
494
+ { id: 2, label: 'Articles', language_id: 1, menu_key: 'HEADER', order: 1, url: '/articles' },
495
+ {
496
+ id: 3,
497
+ label: 'Contact',
498
+ language_id: 1,
499
+ menu_key: 'HEADER',
500
+ order: 2,
501
+ page_id: null,
502
+ parent_id: null,
503
+ url: '/contact',
504
+ },
505
+ ]);
506
+ });
507
+
508
+ it('resolves language names when appending header navigation items', async () => {
509
+ const { database, supabase } = createMockSupabase({
510
+ languages: [
511
+ { code: 'en', id: 1, is_active: true, name: 'English' },
512
+ { code: 'fr', id: 2, is_active: true, name: 'French' },
513
+ ],
514
+ navigation_items: [
515
+ { id: 1, label: 'Home', language_id: 1, menu_key: 'HEADER', order: 0, url: '/' },
516
+ { id: 2, label: 'Accueil', language_id: 2, menu_key: 'HEADER', order: 0, url: '/' },
517
+ ],
518
+ });
519
+
520
+ const result = await executeConfirmed(
521
+ executeUpdateNavigationBar,
522
+ {
523
+ items: [{ label: 'Contact', target: '_self', url: 'mailto:info@nextblock.dev' }],
524
+ languageCode: 'French',
525
+ mode: 'append',
526
+ },
527
+ { revalidatePath: () => undefined, supabase }
528
+ );
529
+
530
+ expect(result).toEqual({
531
+ insertedCount: 1,
532
+ languageCode: 'fr',
533
+ menuKey: 'HEADER',
534
+ mode: 'append',
535
+ mutationExecuted: true,
536
+ skippedCount: 0,
537
+ success: true,
538
+ updatedCount: 0,
539
+ });
540
+ expect(database.navigation_items).toContainEqual({
541
+ id: 3,
542
+ label: 'Contact',
543
+ language_id: 2,
544
+ menu_key: 'HEADER',
545
+ order: 1,
546
+ page_id: null,
547
+ parent_id: null,
548
+ url: 'mailto:info@nextblock.dev',
549
+ });
550
+ });
551
+
552
+ it('links AI-created translated navigation items to page and nav translation groups', async () => {
553
+ const { database, supabase } = createMockSupabase({
554
+ languages: [
555
+ { code: 'en', id: 1, is_active: true, name: 'English' },
556
+ { code: 'fr', id: 2, is_active: true, name: 'French' },
557
+ ],
558
+ navigation_items: [
559
+ {
560
+ id: 1,
561
+ label: 'Contact Us',
562
+ language_id: 1,
563
+ menu_key: 'HEADER',
564
+ order: 0,
565
+ page_id: 1,
566
+ translation_group_id: 'group-nav-contact',
567
+ url: '/contact-us',
568
+ },
569
+ ],
570
+ pages: [
571
+ {
572
+ id: 1,
573
+ language_id: 1,
574
+ slug: 'contact-us',
575
+ title: 'Contact Us',
576
+ translation_group_id: 'group-page-contact',
577
+ },
578
+ {
579
+ id: 2,
580
+ language_id: 2,
581
+ slug: 'contactez-nous',
582
+ title: 'Contactez-nous',
583
+ translation_group_id: 'group-page-contact',
584
+ },
585
+ ],
586
+ });
587
+
588
+ const result = await executeConfirmed(
589
+ executeUpdateNavigationBar,
590
+ {
591
+ items: [{ label: 'Contactez-nous', url: '/contactez-nous' }],
592
+ languageCode: 'French',
593
+ mode: 'append',
594
+ },
595
+ { revalidatePath: () => undefined, supabase }
596
+ );
597
+
598
+ expect(result).toMatchObject({
599
+ insertedCount: 1,
600
+ languageCode: 'fr',
601
+ mutationExecuted: true,
602
+ success: true,
603
+ });
604
+ expect(database.navigation_items[1]).toMatchObject({
605
+ label: 'Contactez-nous',
606
+ language_id: 2,
607
+ menu_key: 'HEADER',
608
+ page_id: 2,
609
+ translation_group_id: 'group-nav-contact',
610
+ url: '/contactez-nous',
611
+ });
612
+ });
613
+
614
+ it('updates a single existing header navigation item without replacing the menu', async () => {
615
+ const { database, supabase } = createMockSupabase({
616
+ languages: [
617
+ { code: 'en', id: 1, is_active: true, name: 'English' },
618
+ { code: 'fr', id: 2, is_active: true, name: 'French' },
619
+ ],
620
+ navigation_items: [
621
+ { id: 1, label: 'Accueil', language_id: 2, menu_key: 'HEADER', order: 0, url: '/' },
622
+ {
623
+ id: 2,
624
+ label: 'Contact',
625
+ language_id: 2,
626
+ menu_key: 'HEADER',
627
+ order: 1,
628
+ url: 'mailto:info@nextblock.dev',
629
+ },
630
+ { id: 3, label: 'Articles', language_id: 2, menu_key: 'HEADER', order: 2, url: '/articles' },
631
+ ],
632
+ });
633
+
634
+ const result = await executeConfirmed(
635
+ executeUpdateNavigationBar,
636
+ {
637
+ items: [
638
+ {
639
+ label: 'Nous Contacter',
640
+ target: '_self',
641
+ url: 'mailto:info@nextblock.dev',
642
+ },
643
+ ],
644
+ languageCode: 'French',
645
+ match: { label: 'Contact' },
646
+ mode: 'update',
647
+ },
648
+ { revalidatePath: () => undefined, supabase }
649
+ );
650
+
651
+ expect(result).toEqual({
652
+ insertedCount: 0,
653
+ languageCode: 'fr',
654
+ menuKey: 'HEADER',
655
+ mode: 'update',
656
+ mutationExecuted: true,
657
+ skippedCount: 0,
658
+ success: true,
659
+ updatedCount: 1,
660
+ });
661
+ expect(database.navigation_items).toEqual([
662
+ { id: 1, label: 'Accueil', language_id: 2, menu_key: 'HEADER', order: 0, url: '/' },
663
+ {
664
+ id: 2,
665
+ label: 'Nous Contacter',
666
+ language_id: 2,
667
+ menu_key: 'HEADER',
668
+ order: 1,
669
+ url: 'mailto:info@nextblock.dev',
670
+ },
671
+ { id: 3, label: 'Articles', language_id: 2, menu_key: 'HEADER', order: 2, url: '/articles' },
672
+ ]);
673
+ });
674
+
675
+ it('refuses destructive partial header navigation replacements', async () => {
676
+ const { database, supabase } = createMockSupabase({
677
+ navigation_items: [
678
+ { id: 1, label: 'Home', language_id: 1, menu_key: 'HEADER', order: 0, url: '/' },
679
+ { id: 2, label: 'Articles', language_id: 1, menu_key: 'HEADER', order: 1, url: '/articles' },
680
+ {
681
+ id: 3,
682
+ label: 'Contact',
683
+ language_id: 1,
684
+ menu_key: 'HEADER',
685
+ order: 2,
686
+ url: 'mailto:info@nextblock.dev',
687
+ },
688
+ ],
689
+ });
690
+
691
+ await expect(
692
+ executeUpdateNavigationBar(
693
+ {
694
+ items: [{ label: 'Nous Contacter', url: 'mailto:info@nextblock.dev' }],
695
+ languageCode: 'en',
696
+ mode: 'replace',
697
+ },
698
+ { revalidatePath: () => undefined, supabase }
699
+ )
700
+ ).rejects.toThrow('Refusing destructive HEADER navigation replacement');
701
+
702
+ expect(database.navigation_items).toEqual([
703
+ { id: 1, label: 'Home', language_id: 1, menu_key: 'HEADER', order: 0, url: '/' },
704
+ { id: 2, label: 'Articles', language_id: 1, menu_key: 'HEADER', order: 1, url: '/articles' },
705
+ {
706
+ id: 3,
707
+ label: 'Contact',
708
+ language_id: 1,
709
+ menu_key: 'HEADER',
710
+ order: 2,
711
+ url: 'mailto:info@nextblock.dev',
712
+ },
713
+ ]);
714
+ });
715
+
716
+ it('skips duplicate header navigation append requests by URL', async () => {
717
+ const { database, supabase } = createMockSupabase({
718
+ navigation_items: [
719
+ { id: 1, label: 'Home', language_id: 1, menu_key: 'HEADER', order: 0, url: '/' },
720
+ {
721
+ id: 2,
722
+ label: 'Contact',
723
+ language_id: 1,
724
+ menu_key: 'HEADER',
725
+ order: 1,
726
+ url: 'mailto:info@nextblock.dev',
727
+ },
728
+ ],
729
+ });
730
+
731
+ const result = await executeConfirmed(
732
+ executeUpdateNavigationBar,
733
+ {
734
+ items: [{ label: 'Contact', target: '_self', url: 'mailto:info@nextblock.dev' }],
735
+ languageCode: 'en',
736
+ mode: 'append',
737
+ },
738
+ { revalidatePath: () => undefined, supabase }
739
+ );
740
+
741
+ expect(result).toEqual({
742
+ insertedCount: 0,
743
+ languageCode: 'en',
744
+ menuKey: 'HEADER',
745
+ mode: 'append',
746
+ mutationExecuted: true,
747
+ skippedCount: 1,
748
+ success: true,
749
+ updatedCount: 0,
750
+ });
751
+ expect(database.navigation_items).toEqual([
752
+ { id: 1, label: 'Home', language_id: 1, menu_key: 'HEADER', order: 0, url: '/' },
753
+ {
754
+ id: 2,
755
+ label: 'Contact',
756
+ language_id: 1,
757
+ menu_key: 'HEADER',
758
+ order: 1,
759
+ url: 'mailto:info@nextblock.dev',
760
+ },
761
+ ]);
762
+ });
763
+
764
+ it('updates footer links and copyright settings', async () => {
765
+ const { database, supabase } = createMockSupabase({
766
+ navigation_items: [
767
+ { id: 10, label: 'Old Footer', language_id: 1, menu_key: 'FOOTER', order: 0, url: '/old' },
768
+ ],
769
+ site_settings: [{ key: 'footer_copyright', value: { en: 'Old' } }],
770
+ });
771
+
772
+ const result = await executeConfirmed(
773
+ executeUpdateFooter,
774
+ {
775
+ copyright: { en: '(c) {year} NextBlock. All rights reserved.' },
776
+ languageCode: 'en',
777
+ links: [{ label: 'Privacy', url: '/privacy' }],
778
+ },
779
+ { revalidatePath: () => undefined, supabase }
780
+ );
781
+
782
+ expect(result).toMatchObject({
783
+ copyrightUpdated: true,
784
+ footerNavigation: {
785
+ insertedCount: 1,
786
+ languageCode: 'en',
787
+ menuKey: 'FOOTER',
788
+ },
789
+ mutationExecuted: true,
790
+ success: true,
791
+ });
792
+ expect(database.navigation_items).toEqual([
793
+ {
794
+ id: 1,
795
+ label: 'Privacy',
796
+ language_id: 1,
797
+ menu_key: 'FOOTER',
798
+ order: 0,
799
+ page_id: null,
800
+ parent_id: null,
801
+ url: '/privacy',
802
+ },
803
+ ]);
804
+ expect(database.site_settings).toEqual([
805
+ {
806
+ key: 'footer_copyright',
807
+ value: { en: '(c) {year} NextBlock. All rights reserved.' },
808
+ },
809
+ ]);
810
+ });
811
+
812
+ it('searches published documentation-like pages and posts', async () => {
813
+ const { supabase } = createMockSupabase({
814
+ pages: [
815
+ {
816
+ id: 1,
817
+ meta_description: 'CMS setup, editor blocks, and Supabase auth.',
818
+ slug: 'docs/setup',
819
+ status: 'published',
820
+ title: 'Setup Guide',
821
+ },
822
+ ],
823
+ posts: [
824
+ {
825
+ excerpt: 'Use Supabase auth with profiles and roles in NextBlock.',
826
+ id: 1,
827
+ meta_description: null,
828
+ slug: 'supabase-auth-guide',
829
+ status: 'published',
830
+ subtitle: null,
831
+ title: 'Supabase Auth Guide',
832
+ },
833
+ {
834
+ excerpt: 'Draft content should not be returned.',
835
+ id: 2,
836
+ slug: 'draft',
837
+ status: 'draft',
838
+ title: 'Draft',
839
+ },
840
+ ],
841
+ });
842
+
843
+ const result = await executeSearchDocumentation(
844
+ { limit: 2, query: 'Supabase auth' },
845
+ { supabase }
846
+ );
847
+
848
+ expect(result).toEqual({
849
+ query: 'Supabase auth',
850
+ results: [
851
+ {
852
+ excerpt: 'Use Supabase auth with profiles and roles in NextBlock.',
853
+ source: 'post',
854
+ title: 'Supabase Auth Guide',
855
+ url: '/article/supabase-auth-guide',
856
+ },
857
+ {
858
+ excerpt: 'CMS setup, editor blocks, and Supabase auth.',
859
+ source: 'page',
860
+ title: 'Setup Guide',
861
+ url: '/docs/setup',
862
+ },
863
+ ],
864
+ success: true,
865
+ });
866
+ });
867
+
868
+ it('returns a fallback instead of hanging when documentation search is slow', async () => {
869
+ const createHangingQuery = () =>
870
+ ({
871
+ eq() {
872
+ return this;
873
+ },
874
+ limit() {
875
+ return new Promise(() => undefined);
876
+ },
877
+ select() {
878
+ return this;
879
+ },
880
+ }) as any;
881
+
882
+ const result = await executeSearchDocumentationWithTimeout(
883
+ { limit: 2, query: 'NextBlock project' },
884
+ {
885
+ supabase: {
886
+ from: () => createHangingQuery(),
887
+ },
888
+ },
889
+ 5
890
+ );
891
+
892
+ expect(result).toMatchObject({
893
+ query: 'NextBlock project',
894
+ results: [],
895
+ success: false,
896
+ timedOut: true,
897
+ });
898
+ });
899
+
900
+ it('reads the current page context with block summaries', async () => {
901
+ const { supabase } = createMockSupabase({
902
+ blocks: [
903
+ {
904
+ block_type: 'text',
905
+ content: { html_content: '<p>Hello</p>' },
906
+ id: 11,
907
+ language_id: 1,
908
+ order: 2,
909
+ page_id: 7,
910
+ post_id: null,
911
+ },
912
+ {
913
+ block_type: 'heading',
914
+ content: { level: 2, text_content: 'Intro' },
915
+ id: 10,
916
+ language_id: 1,
917
+ order: 1,
918
+ page_id: 7,
919
+ post_id: null,
920
+ },
921
+ ],
922
+ pages: [
923
+ {
924
+ id: 7,
925
+ language_id: 1,
926
+ meta_description: null,
927
+ slug: 'home',
928
+ status: 'published',
929
+ title: 'Home',
930
+ },
931
+ ],
932
+ });
933
+
934
+ const result = await executeReadCurrentCmsItem(
935
+ { includeBlockContent: false, includeBlocks: true },
936
+ {
937
+ pageContext: { contentType: 'page', entityId: 7, slug: 'home', title: 'Home' },
938
+ supabase,
939
+ }
940
+ );
941
+
942
+ expect(result.success).toBe(true);
943
+ expect(result.item.title).toBe('Home');
944
+ expect(result.blocks).toEqual([
945
+ {
946
+ blockType: 'heading',
947
+ content: undefined,
948
+ id: 10,
949
+ languageId: 1,
950
+ order: 1,
951
+ pageId: 7,
952
+ postId: null,
953
+ },
954
+ {
955
+ blockType: 'text',
956
+ content: undefined,
957
+ id: 11,
958
+ languageId: 1,
959
+ order: 2,
960
+ pageId: 7,
961
+ postId: null,
962
+ },
963
+ ]);
964
+ });
965
+
966
+ it('updates validated product fields including description_json', async () => {
967
+ const revalidated: string[] = [];
968
+ const { database, supabase } = createMockSupabase({
969
+ products: [
970
+ {
971
+ description_json: null,
972
+ id: 'prod_1',
973
+ language_id: 1,
974
+ meta_description: null,
975
+ meta_title: null,
976
+ short_description: 'Old short copy',
977
+ slug: 'studio-tee',
978
+ status: 'draft',
979
+ title: 'Studio Tee',
980
+ },
981
+ ],
982
+ });
983
+ const descriptionJson = {
984
+ content: [
985
+ {
986
+ content: [{ text: 'NextBlock tee description.', type: 'text' }],
987
+ type: 'paragraph',
988
+ },
989
+ ],
990
+ type: 'doc',
991
+ };
992
+
993
+ const result = await executeConfirmed(
994
+ executeUpdateCurrentCmsFields,
995
+ {
996
+ fields: {
997
+ description_json: descriptionJson,
998
+ short_description: 'Soft cotton tee for builders.',
999
+ status: 'active',
1000
+ },
1001
+ },
1002
+ {
1003
+ pageContext: {
1004
+ contentType: 'product',
1005
+ entityId: 'prod_1',
1006
+ slug: 'studio-tee',
1007
+ title: 'Studio Tee',
1008
+ },
1009
+ revalidatePath: (path) => revalidated.push(path),
1010
+ supabase,
1011
+ }
1012
+ );
1013
+
1014
+ expect(result).toMatchObject({
1015
+ contentType: 'product',
1016
+ entityId: 'prod_1',
1017
+ mutationExecuted: true,
1018
+ slug: 'studio-tee',
1019
+ success: true,
1020
+ updatedFields: ['description_json', 'short_description', 'status'],
1021
+ });
1022
+ expect(database.products[0]).toMatchObject({
1023
+ description_json: descriptionJson,
1024
+ short_description: 'Soft cotton tee for builders.',
1025
+ status: 'active',
1026
+ });
1027
+ expect(revalidated).toEqual([
1028
+ '/cms/products/prod_1/edit',
1029
+ '/product/studio-tee',
1030
+ '/cms/products',
1031
+ ]);
1032
+ });
1033
+
1034
+ it('updates only blocks that belong to the current page context', async () => {
1035
+ const { database, supabase } = createMockSupabase({
1036
+ blocks: [
1037
+ {
1038
+ block_type: 'text',
1039
+ content: { html_content: '<p>Old</p>' },
1040
+ id: 12,
1041
+ language_id: 1,
1042
+ order: 0,
1043
+ page_id: 7,
1044
+ post_id: null,
1045
+ },
1046
+ ],
1047
+ });
1048
+
1049
+ await expect(
1050
+ executeUpdateContentBlock(
1051
+ {
1052
+ blockId: 12,
1053
+ blockType: 'text',
1054
+ content: { html_content: '<p>Wrong page</p>' },
1055
+ },
1056
+ {
1057
+ pageContext: { contentType: 'page', entityId: 8 },
1058
+ supabase,
1059
+ }
1060
+ )
1061
+ ).rejects.toThrow('does not belong to the current page');
1062
+
1063
+ const result = await executeConfirmed(
1064
+ executeUpdateContentBlock,
1065
+ {
1066
+ blockId: 12,
1067
+ blockType: 'text',
1068
+ content: { html_content: '<p>Updated</p>' },
1069
+ },
1070
+ {
1071
+ pageContext: { contentType: 'page', entityId: 7, slug: 'docs/setup' },
1072
+ revalidatePath: () => undefined,
1073
+ supabase,
1074
+ }
1075
+ );
1076
+
1077
+ expect(result).toMatchObject({
1078
+ blockId: 12,
1079
+ blockType: 'text',
1080
+ contentUpdated: true,
1081
+ mutationExecuted: true,
1082
+ success: true,
1083
+ });
1084
+ expect(database.blocks[0].content).toEqual({ html_content: '<p>Updated</p>' });
1085
+ });
1086
+
1087
+ it('inserts a visible rich text block before the form on a page', async () => {
1088
+ const { database, supabase } = createMockSupabase({
1089
+ blocks: [
1090
+ {
1091
+ block_type: 'form',
1092
+ content: {
1093
+ fields: [],
1094
+ recipient_email: 'info@nextblock.dev',
1095
+ submit_button_text: 'Send Message',
1096
+ success_message: 'Thanks',
1097
+ },
1098
+ id: 12,
1099
+ language_id: 1,
1100
+ order: 0,
1101
+ page_id: 7,
1102
+ post_id: null,
1103
+ },
1104
+ ],
1105
+ pages: [
1106
+ {
1107
+ id: 7,
1108
+ language_id: 1,
1109
+ slug: 'contact-us',
1110
+ title: 'Contact Us',
1111
+ translation_group_id: 'group-contact',
1112
+ },
1113
+ ],
1114
+ });
1115
+
1116
+ const result = await executeConfirmed(
1117
+ executeInsertContentBlock,
1118
+ {
1119
+ anchorBlockType: 'form',
1120
+ block: {
1121
+ blockType: 'text',
1122
+ content: {
1123
+ html_content:
1124
+ '<h2>Let us help you move faster</h2><p>Tell us what you are building and the NextBlock team will get back to you.</p>',
1125
+ },
1126
+ },
1127
+ contentType: 'page',
1128
+ slug: 'contact-us',
1129
+ position: 'before',
1130
+ },
1131
+ { revalidatePath: () => undefined, supabase }
1132
+ );
1133
+
1134
+ expect(result).toMatchObject({
1135
+ blockType: 'text',
1136
+ contentType: 'page',
1137
+ entityId: 7,
1138
+ mutationExecuted: true,
1139
+ order: 0,
1140
+ success: true,
1141
+ });
1142
+ expect(database.blocks).toHaveLength(2);
1143
+ expect(database.blocks[0]).toMatchObject({
1144
+ block_type: 'form',
1145
+ order: 1,
1146
+ });
1147
+ expect(database.blocks[1]).toMatchObject({
1148
+ block_type: 'text',
1149
+ order: 0,
1150
+ page_id: 7,
1151
+ });
1152
+ expect(database.blocks[1].content.html_content).toContain('Let us help you move faster');
1153
+ });
1154
+
1155
+ it('builds a deterministic action plan for visible English and French contact intro copy', () => {
1156
+ const plan = buildVisibleContactIntroActionPlan(
1157
+ 'can you add a title and description above the form on both contact pages english and french'
1158
+ );
1159
+
1160
+ expect(plan).toMatchObject({
1161
+ actions: [
1162
+ {
1163
+ input: {
1164
+ anchorBlockType: 'form',
1165
+ contentType: 'page',
1166
+ position: 'before',
1167
+ slug: 'contact-us',
1168
+ },
1169
+ tool: 'insert_content_block',
1170
+ },
1171
+ {
1172
+ input: {
1173
+ anchorBlockType: 'form',
1174
+ contentType: 'page',
1175
+ position: 'before',
1176
+ slug: 'contactez-nous',
1177
+ },
1178
+ tool: 'insert_content_block',
1179
+ },
1180
+ ],
1181
+ summary:
1182
+ 'Add visible title and description copy above the forms on the English and French Contact pages.',
1183
+ });
1184
+ expect(plan?.actions[0].input.block.content.html_content).toContain('<h2>');
1185
+ expect(plan?.actions[1].input.block.content.html_content).toContain('<h2>');
1186
+ });
1187
+
1188
+ it('uses an action plan to add localized intro copy above forms on both translated pages', async () => {
1189
+ const { database, supabase } = createMockSupabase({
1190
+ blocks: [
1191
+ {
1192
+ block_type: 'form',
1193
+ content: {
1194
+ fields: [],
1195
+ recipient_email: 'info@nextblock.dev',
1196
+ submit_button_text: 'Send Message',
1197
+ success_message: 'Thanks',
1198
+ },
1199
+ id: 12,
1200
+ language_id: 1,
1201
+ order: 0,
1202
+ page_id: 7,
1203
+ post_id: null,
1204
+ },
1205
+ {
1206
+ block_type: 'form',
1207
+ content: {
1208
+ fields: [],
1209
+ recipient_email: 'info@nextblock.dev',
1210
+ submit_button_text: 'Envoyer',
1211
+ success_message: 'Merci',
1212
+ },
1213
+ id: 13,
1214
+ language_id: 2,
1215
+ order: 0,
1216
+ page_id: 8,
1217
+ post_id: null,
1218
+ },
1219
+ ],
1220
+ pages: [
1221
+ {
1222
+ id: 7,
1223
+ language_id: 1,
1224
+ slug: 'contact-us',
1225
+ title: 'Contact Us',
1226
+ translation_group_id: 'group-contact',
1227
+ },
1228
+ {
1229
+ id: 8,
1230
+ language_id: 2,
1231
+ slug: 'contactez-nous',
1232
+ title: 'Contactez-nous',
1233
+ translation_group_id: 'group-contact',
1234
+ },
1235
+ ],
1236
+ });
1237
+
1238
+ const result = await executeConfirmed(
1239
+ executeCmsActionPlan,
1240
+ {
1241
+ actions: [
1242
+ {
1243
+ input: {
1244
+ anchorBlockType: 'form',
1245
+ block: {
1246
+ blockType: 'text',
1247
+ content: {
1248
+ html_content:
1249
+ '<h2>Ready to talk?</h2><p>Share your goals and we will help you choose the right next step.</p>',
1250
+ },
1251
+ },
1252
+ contentType: 'page',
1253
+ position: 'before',
1254
+ slug: 'contact-us',
1255
+ },
1256
+ tool: 'insert_content_block',
1257
+ },
1258
+ {
1259
+ input: {
1260
+ anchorBlockType: 'form',
1261
+ block: {
1262
+ blockType: 'text',
1263
+ content: {
1264
+ html_content:
1265
+ '<h2>Prêt à discuter?</h2><p>Parlez-nous de vos objectifs et nous vous aiderons à choisir la prochaine étape.</p>',
1266
+ },
1267
+ },
1268
+ contentType: 'page',
1269
+ position: 'before',
1270
+ slug: 'contactez-nous',
1271
+ },
1272
+ tool: 'insert_content_block',
1273
+ },
1274
+ ],
1275
+ },
1276
+ {
1277
+ pageContext: {
1278
+ contentType: 'page',
1279
+ entityId: 7,
1280
+ slug: 'contact-us',
1281
+ title: 'Contact Us',
1282
+ },
1283
+ revalidatePath: () => undefined,
1284
+ supabase,
1285
+ }
1286
+ );
1287
+
1288
+ expect(result).toMatchObject({
1289
+ actionCount: 2,
1290
+ mutationExecuted: true,
1291
+ success: true,
1292
+ });
1293
+ const englishTextBlocks = database.blocks.filter(
1294
+ (block) => block.block_type === 'text' && block.page_id === 7
1295
+ );
1296
+ const frenchTextBlocks = database.blocks.filter(
1297
+ (block) => block.block_type === 'text' && block.page_id === 8
1298
+ );
1299
+
1300
+ expect(englishTextBlocks).toHaveLength(1);
1301
+ expect(frenchTextBlocks).toHaveLength(1);
1302
+ expect(englishTextBlocks[0]).toMatchObject({ order: 0 });
1303
+ expect(frenchTextBlocks[0]).toMatchObject({ order: 0 });
1304
+ expect(database.blocks).toEqual(
1305
+ expect.arrayContaining([
1306
+ expect.objectContaining({ block_type: 'form', page_id: 7, order: 1 }),
1307
+ expect.objectContaining({ block_type: 'form', page_id: 8, order: 1 }),
1308
+ ])
1309
+ );
1310
+ });
1311
+
1312
+ it('merges partial top-level block content with existing content before validation', async () => {
1313
+ const { database, supabase } = createMockSupabase({
1314
+ blocks: [
1315
+ {
1316
+ block_type: 'button',
1317
+ content: { text: 'Old label', url: '/contact', variant: 'default' },
1318
+ id: 14,
1319
+ language_id: 1,
1320
+ order: 0,
1321
+ page_id: 7,
1322
+ post_id: null,
1323
+ },
1324
+ ],
1325
+ });
1326
+
1327
+ const result = await executeConfirmed(
1328
+ executeUpdateContentBlock,
1329
+ {
1330
+ blockId: 14,
1331
+ blockType: 'button',
1332
+ content: { text: 'Contact Us' },
1333
+ },
1334
+ {
1335
+ pageContext: { contentType: 'page', entityId: 7, slug: 'home' },
1336
+ revalidatePath: () => undefined,
1337
+ supabase,
1338
+ }
1339
+ );
1340
+
1341
+ expect(result).toMatchObject({
1342
+ blockId: 14,
1343
+ blockType: 'button',
1344
+ contentUpdated: true,
1345
+ mutationExecuted: true,
1346
+ success: true,
1347
+ });
1348
+ expect(database.blocks[0].content).toMatchObject({
1349
+ text: 'Contact Us',
1350
+ url: '/contact',
1351
+ variant: 'default',
1352
+ });
1353
+ });
1354
+
1355
+ it('appends button-shaped content to a section block while preserving required layout fields', async () => {
1356
+ const sectionContent = {
1357
+ background: { type: 'none' },
1358
+ column_blocks: [
1359
+ [
1360
+ {
1361
+ block_type: 'text',
1362
+ content: { html_content: '<p>Hero intro</p>' },
1363
+ },
1364
+ ],
1365
+ ],
1366
+ column_gap: 'md',
1367
+ container_type: 'container',
1368
+ padding: { bottom: 'lg', top: 'lg' },
1369
+ responsive_columns: { desktop: 1, mobile: 1, tablet: 1 },
1370
+ };
1371
+ const { database, supabase } = createMockSupabase({
1372
+ blocks: [
1373
+ {
1374
+ block_type: 'section',
1375
+ content: sectionContent,
1376
+ id: 8,
1377
+ language_id: 1,
1378
+ order: 0,
1379
+ page_id: 7,
1380
+ post_id: null,
1381
+ },
1382
+ ],
1383
+ });
1384
+
1385
+ const result = await executeConfirmed(
1386
+ executeUpdateContentBlock,
1387
+ {
1388
+ blockId: 8,
1389
+ blockType: 'section',
1390
+ content: { text: 'Contact Us', url: '/contact', variant: 'default' },
1391
+ },
1392
+ {
1393
+ pageContext: { contentType: 'page', entityId: 7, slug: 'articles' },
1394
+ revalidatePath: () => undefined,
1395
+ supabase,
1396
+ }
1397
+ );
1398
+
1399
+ expect(result).toMatchObject({
1400
+ blockId: 8,
1401
+ blockType: 'section',
1402
+ contentUpdated: true,
1403
+ mutationExecuted: true,
1404
+ success: true,
1405
+ });
1406
+ expect(database.blocks[0].content).toMatchObject({
1407
+ background: { type: 'none' },
1408
+ column_gap: 'md',
1409
+ container_type: 'container',
1410
+ padding: { bottom: 'lg', top: 'lg' },
1411
+ responsive_columns: { desktop: 1, mobile: 1, tablet: 1 },
1412
+ });
1413
+ expect(database.blocks[0].content.column_blocks[0]).toHaveLength(2);
1414
+ expect(database.blocks[0].content.column_blocks[0][1]).toMatchObject({
1415
+ block_type: 'button',
1416
+ content: { text: 'Contact Us', url: '/contact', variant: 'default' },
1417
+ });
1418
+ expect(database.blocks[0].content.column_blocks[0][1].temp_id).toEqual(expect.any(String));
1419
+ });
1420
+
1421
+ it('updates a validated nested section column block', async () => {
1422
+ const sectionContent = {
1423
+ background: { type: 'none' },
1424
+ column_blocks: [
1425
+ [
1426
+ {
1427
+ block_type: 'text',
1428
+ content: { html_content: '<p>Old nested copy</p>' },
1429
+ },
1430
+ ],
1431
+ ],
1432
+ column_gap: 'md',
1433
+ container_type: 'container',
1434
+ padding: { bottom: 'md', top: 'md' },
1435
+ responsive_columns: { desktop: 1, mobile: 1, tablet: 1 },
1436
+ };
1437
+ const { database, supabase } = createMockSupabase({
1438
+ blocks: [
1439
+ {
1440
+ block_type: 'section',
1441
+ content: sectionContent,
1442
+ id: 20,
1443
+ language_id: 1,
1444
+ order: 0,
1445
+ page_id: 7,
1446
+ post_id: null,
1447
+ },
1448
+ ],
1449
+ });
1450
+
1451
+ const result = await executeConfirmed(
1452
+ executeUpdateSectionColumnBlock,
1453
+ {
1454
+ blockIndex: 0,
1455
+ blockType: 'text',
1456
+ columnIndex: 0,
1457
+ content: { html_content: '<p>New nested copy</p>' },
1458
+ parentBlockId: 20,
1459
+ },
1460
+ {
1461
+ pageContext: { contentType: 'page', entityId: 7, slug: 'home' },
1462
+ revalidatePath: () => undefined,
1463
+ supabase,
1464
+ }
1465
+ );
1466
+
1467
+ expect(result).toMatchObject({
1468
+ blockIndex: 0,
1469
+ columnIndex: 0,
1470
+ nestedBlockType: 'text',
1471
+ parentBlockId: 20,
1472
+ parentBlockType: 'section',
1473
+ mutationExecuted: true,
1474
+ success: true,
1475
+ });
1476
+ expect(database.blocks[0].content.column_blocks[0][0].content).toEqual({
1477
+ html_content: '<p>New nested copy</p>',
1478
+ });
1479
+ });
1480
+
1481
+ it('returns confirmation for existing mutating tools before changing data', async () => {
1482
+ const { database, supabase } = createMockSupabase({
1483
+ navigation_items: [
1484
+ { id: 1, label: 'Home', language_id: 1, menu_key: 'HEADER', order: 0, url: '/' },
1485
+ ],
1486
+ });
1487
+
1488
+ const result = await executeUpdateNavigationBar(
1489
+ {
1490
+ items: [{ label: 'Contact', url: '/contact' }],
1491
+ languageCode: 'en',
1492
+ mode: 'append',
1493
+ },
1494
+ { supabase }
1495
+ );
1496
+
1497
+ expectConfirmation(result);
1498
+ expect(database.navigation_items).toEqual([
1499
+ { id: 1, label: 'Home', language_id: 1, menu_key: 'HEADER', order: 0, url: '/' },
1500
+ ]);
1501
+ });
1502
+
1503
+ it('prepares a multi-action CMS plan without mutating', async () => {
1504
+ const { database, supabase } = createMockSupabase({
1505
+ navigation_items: [
1506
+ { id: 1, label: 'Home', language_id: 1, menu_key: 'HEADER', order: 0, url: '/' },
1507
+ ],
1508
+ });
1509
+
1510
+ const preview = await executeCmsActionPlan(
1511
+ {
1512
+ actions: [
1513
+ {
1514
+ input: {
1515
+ contactEmail: 'info@nextblock.dev',
1516
+ title: 'Contact Us',
1517
+ },
1518
+ tool: 'create_cms_page',
1519
+ },
1520
+ {
1521
+ input: {
1522
+ items: [{ label: 'Contact Us', url: '/contact-us' }],
1523
+ languageCode: 'en',
1524
+ mode: 'append',
1525
+ },
1526
+ tool: 'update_navigation_bar',
1527
+ },
1528
+ ],
1529
+ },
1530
+ { actorUserId: 'admin_1', supabase }
1531
+ );
1532
+
1533
+ expectConfirmation(preview);
1534
+ expect(preview.preview).toMatchObject({
1535
+ actionCount: 2,
1536
+ summary: 'Complete 2 CMS actions.',
1537
+ });
1538
+ expect(preview.preview.actionSummaries).toHaveLength(2);
1539
+ expect(database.pages).toEqual([]);
1540
+ expect(database.navigation_items).toEqual([
1541
+ { id: 1, label: 'Home', language_id: 1, menu_key: 'HEADER', order: 0, url: '/' },
1542
+ ]);
1543
+ });
1544
+
1545
+ it('confirms a create-page-plus-navigation action plan in order', async () => {
1546
+ const { database, supabase } = createMockSupabase({
1547
+ navigation_items: [
1548
+ { id: 1, label: 'Home', language_id: 1, menu_key: 'HEADER', order: 0, url: '/' },
1549
+ ],
1550
+ });
1551
+
1552
+ const result = await executeConfirmed(
1553
+ executeCmsActionPlan,
1554
+ {
1555
+ actions: [
1556
+ {
1557
+ input: {
1558
+ contactEmail: 'info@nextblock.dev',
1559
+ title: 'Contact Us',
1560
+ },
1561
+ tool: 'create_cms_page',
1562
+ },
1563
+ {
1564
+ input: {
1565
+ items: [{ label: 'Contact Us', url: '/contact-us' }],
1566
+ languageCode: 'en',
1567
+ mode: 'append',
1568
+ },
1569
+ tool: 'update_navigation_bar',
1570
+ },
1571
+ ],
1572
+ },
1573
+ { actorUserId: 'admin_1', revalidatePath: () => undefined, supabase }
1574
+ );
1575
+
1576
+ expect(result).toMatchObject({
1577
+ actionCount: 2,
1578
+ editPath: '/cms/pages/1/edit',
1579
+ mutationExecuted: true,
1580
+ success: true,
1581
+ });
1582
+ expect(database.pages[0]).toMatchObject({
1583
+ slug: 'contact-us',
1584
+ title: 'Contact Us',
1585
+ });
1586
+ expect(database.navigation_items[1]).toMatchObject({
1587
+ label: 'Contact Us',
1588
+ language_id: 1,
1589
+ menu_key: 'HEADER',
1590
+ page_id: 1,
1591
+ parent_id: null,
1592
+ url: '/contact-us',
1593
+ });
1594
+ expect(database.navigation_items[1].translation_group_id).toEqual(expect.any(String));
1595
+ });
1596
+
1597
+ it('normalizes command-string action plans and links created language versions', async () => {
1598
+ const { database, supabase } = createMockSupabase({
1599
+ languages: [
1600
+ { code: 'en', id: 1, is_active: true, name: 'English' },
1601
+ { code: 'fr', id: 2, is_active: true, name: 'French' },
1602
+ ],
1603
+ navigation_items: [
1604
+ { id: 1, label: 'Home', language_id: 1, menu_key: 'HEADER', order: 0, url: '/' },
1605
+ { id: 2, label: 'Accueil', language_id: 2, menu_key: 'HEADER', order: 0, url: '/' },
1606
+ ],
1607
+ });
1608
+
1609
+ const result = await executeConfirmed(
1610
+ executeCmsActionPlan,
1611
+ {
1612
+ actions: [
1613
+ "create_cms_page(title='Contact Us', slug='contact-us', contactEmail='info@nextblock.dev', blocks=[{'blockType': 'form', 'content': {'recipient_email': 'info@nextblock.dev', 'fields': [{'label': 'Name', 'type': 'text'}, {'label': 'Email', 'type': 'email'}, {'label': 'Message', 'type': 'textarea'}]}}])",
1614
+ "update_navigation_bar(items=[{'label': 'Contact Us', 'url': '/contact-us'}], languageCode='en', mode='append')",
1615
+ "create_cms_page(title='Contactez-nous', slug='contactez-nous', languageCode='fr', contactEmail='info@nextblock.dev', blocks=[{'blockType': 'form', 'content': {'recipient_email': 'info@nextblock.dev', 'fields': [{'label': 'Nom', 'type': 'text'}, {'label': 'Email', 'type': 'email'}, {'label': 'Message', 'type': 'textarea'}]}}])",
1616
+ "update_navigation_bar(items=[{'label': 'Contactez-nous', 'url': '/contactez-nous'}], languageCode='fr', mode='append')",
1617
+ ],
1618
+ },
1619
+ { actorUserId: 'admin_1', revalidatePath: () => undefined, supabase }
1620
+ );
1621
+
1622
+ expect(result).toMatchObject({
1623
+ actionCount: 4,
1624
+ editPath: '/cms/pages/1/edit',
1625
+ mutationExecuted: true,
1626
+ success: true,
1627
+ });
1628
+ expect(database.pages).toHaveLength(2);
1629
+ expect(database.pages[0]).toMatchObject({
1630
+ language_id: 1,
1631
+ slug: 'contact-us',
1632
+ title: 'Contact Us',
1633
+ });
1634
+ expect(database.pages[1]).toMatchObject({
1635
+ language_id: 2,
1636
+ slug: 'contactez-nous',
1637
+ title: 'Contactez-nous',
1638
+ translation_group_id: database.pages[0].translation_group_id,
1639
+ });
1640
+ expect(database.navigation_items[2]).toMatchObject({
1641
+ label: 'Contact Us',
1642
+ language_id: 1,
1643
+ page_id: 1,
1644
+ url: '/contact-us',
1645
+ });
1646
+ expect(database.navigation_items[3]).toMatchObject({
1647
+ label: 'Contactez-nous',
1648
+ language_id: 2,
1649
+ page_id: 2,
1650
+ translation_group_id: database.navigation_items[2].translation_group_id,
1651
+ url: '/contactez-nous',
1652
+ });
1653
+ });
1654
+
1655
+ it('confirms a navigation-only action plan without returning a navigation path', async () => {
1656
+ const { supabase } = createMockSupabase({
1657
+ navigation_items: [
1658
+ { id: 1, label: 'Home', language_id: 1, menu_key: 'HEADER', order: 0, url: '/' },
1659
+ ],
1660
+ });
1661
+
1662
+ const result = await executeConfirmed(
1663
+ executeCmsActionPlan,
1664
+ {
1665
+ actions: [
1666
+ {
1667
+ input: {
1668
+ items: [{ label: 'Contact', url: '/contact' }],
1669
+ languageCode: 'en',
1670
+ mode: 'append',
1671
+ },
1672
+ tool: 'update_navigation_bar',
1673
+ },
1674
+ ],
1675
+ },
1676
+ { actorUserId: 'admin_1', revalidatePath: () => undefined, supabase }
1677
+ );
1678
+
1679
+ expect(result).toMatchObject({
1680
+ actionCount: 1,
1681
+ mutationExecuted: true,
1682
+ success: true,
1683
+ });
1684
+ expect(result.editPath).toBeUndefined();
1685
+ expect(result.redirectPath).toBeUndefined();
1686
+ });
1687
+
1688
+ it('creates a confirmed Contact Us page with hero and form blocks', async () => {
1689
+ const revalidated: string[] = [];
1690
+ const { database, supabase } = createMockSupabase();
1691
+ const input = {
1692
+ contactEmail: 'info@nextblock.dev',
1693
+ title: 'Contact Us',
1694
+ };
1695
+
1696
+ const preview = await executeCreateCmsPage(input, {
1697
+ actorUserId: 'admin_1',
1698
+ revalidatePath: (path) => revalidated.push(path),
1699
+ supabase,
1700
+ });
1701
+
1702
+ expectConfirmation(preview);
1703
+ expect(database.pages).toHaveLength(0);
1704
+ expect(database.blocks).toHaveLength(0);
1705
+
1706
+ const result = await executeCreateCmsPage(input, {
1707
+ actorUserId: 'admin_1',
1708
+ latestUserMessage: preview.confirmationPhrase,
1709
+ revalidatePath: (path) => revalidated.push(path),
1710
+ supabase,
1711
+ });
1712
+
1713
+ expect(result).toMatchObject({
1714
+ blockCount: 2,
1715
+ contentType: 'page',
1716
+ editPath: '/cms/pages/1/edit',
1717
+ entityId: 1,
1718
+ mutationExecuted: true,
1719
+ slug: 'contact-us',
1720
+ success: true,
1721
+ title: 'Contact Us',
1722
+ });
1723
+ expect(database.pages[0]).toMatchObject({
1724
+ author_id: 'admin_1',
1725
+ language_id: 1,
1726
+ slug: 'contact-us',
1727
+ status: 'draft',
1728
+ title: 'Contact Us',
1729
+ });
1730
+ expect(database.blocks.map((block) => block.block_type)).toEqual(['section', 'form']);
1731
+ expect(database.blocks[1].content).toMatchObject({
1732
+ recipient_email: 'info@nextblock.dev',
1733
+ submit_button_text: 'Send Message',
1734
+ });
1735
+ expect(revalidated).toEqual(['/cms/pages/1/edit', '/contact-us', '/cms/pages']);
1736
+ });
1737
+
1738
+ it('creates a translated page in the supplied translation group', async () => {
1739
+ const { database, supabase } = createMockSupabase({
1740
+ languages: [
1741
+ { code: 'en', id: 1, is_active: true, name: 'English' },
1742
+ { code: 'fr', id: 2, is_active: true, name: 'French' },
1743
+ ],
1744
+ pages: [
1745
+ {
1746
+ id: 1,
1747
+ language_id: 1,
1748
+ slug: 'contact-us',
1749
+ title: 'Contact Us',
1750
+ translation_group_id: 'group-contact',
1751
+ },
1752
+ ],
1753
+ });
1754
+
1755
+ const result = await executeConfirmed(
1756
+ executeCreateCmsPage,
1757
+ {
1758
+ contactEmail: 'info@nextblock.dev',
1759
+ languageCode: 'French',
1760
+ slug: 'contactez-nous',
1761
+ title: 'Contactez-nous',
1762
+ translationGroupId: 'group-contact',
1763
+ },
1764
+ { actorUserId: 'admin_1', revalidatePath: () => undefined, supabase }
1765
+ );
1766
+
1767
+ expect(result).toMatchObject({
1768
+ contentType: 'page',
1769
+ entityId: 2,
1770
+ mutationExecuted: true,
1771
+ slug: 'contactez-nous',
1772
+ success: true,
1773
+ });
1774
+ expect(database.pages[1]).toMatchObject({
1775
+ language_id: 2,
1776
+ slug: 'contactez-nous',
1777
+ title: 'Contactez-nous',
1778
+ translation_group_id: 'group-contact',
1779
+ });
1780
+ });
1781
+
1782
+ it('refuses to create a second page translation for an existing group language', async () => {
1783
+ const { database, supabase } = createMockSupabase({
1784
+ languages: [
1785
+ { code: 'en', id: 1, is_active: true, name: 'English' },
1786
+ { code: 'fr', id: 2, is_active: true, name: 'French' },
1787
+ ],
1788
+ pages: [
1789
+ {
1790
+ id: 1,
1791
+ language_id: 1,
1792
+ slug: 'contact-us',
1793
+ title: 'Contact Us',
1794
+ translation_group_id: 'group-contact',
1795
+ },
1796
+ {
1797
+ id: 2,
1798
+ language_id: 2,
1799
+ slug: 'contactez-nous',
1800
+ title: 'Contactez-nous',
1801
+ translation_group_id: 'group-contact',
1802
+ },
1803
+ ],
1804
+ });
1805
+
1806
+ const result = await executeCreateCmsPage(
1807
+ {
1808
+ languageCode: 'fr',
1809
+ slug: 'contact-fr-copy',
1810
+ title: 'Contact FR Copy',
1811
+ translationGroupId: 'group-contact',
1812
+ },
1813
+ { actorUserId: 'admin_1', supabase }
1814
+ );
1815
+
1816
+ expect(result).toMatchObject({
1817
+ duplicateTranslation: true,
1818
+ mutationExecuted: false,
1819
+ success: false,
1820
+ });
1821
+ expect(database.pages).toHaveLength(2);
1822
+ });
1823
+
1824
+ it('normalizes common AI-created heading and form block shapes before confirmation', async () => {
1825
+ const { database, supabase } = createMockSupabase();
1826
+ const input = {
1827
+ blocks: [
1828
+ {
1829
+ blockType: 'heading' as const,
1830
+ content: {
1831
+ text: 'Contact Us',
1832
+ },
1833
+ },
1834
+ {
1835
+ blockType: 'form' as const,
1836
+ content: {
1837
+ fields: [
1838
+ { label: 'Name', type: 'text' },
1839
+ { label: 'Email', type: 'email' },
1840
+ { label: 'Message', type: 'textarea' },
1841
+ ],
1842
+ recipient_email: 'info@nextblock.dev',
1843
+ },
1844
+ },
1845
+ ],
1846
+ title: 'Contact Us',
1847
+ };
1848
+
1849
+ const result = await executeConfirmed(
1850
+ executeCreateCmsPage,
1851
+ input,
1852
+ { actorUserId: 'admin_1', revalidatePath: () => undefined, supabase }
1853
+ );
1854
+
1855
+ expect(result).toMatchObject({
1856
+ blockCount: 2,
1857
+ mutationExecuted: true,
1858
+ slug: 'contact-us',
1859
+ success: true,
1860
+ });
1861
+ expect(database.blocks[0].content).toMatchObject({
1862
+ level: 1,
1863
+ text_content: 'Contact Us',
1864
+ });
1865
+ expect(database.blocks[1].content).toMatchObject({
1866
+ recipient_email: 'info@nextblock.dev',
1867
+ submit_button_text: 'Send Message',
1868
+ success_message: 'Thanks for reaching out. We will reply as soon as possible.',
1869
+ });
1870
+ expect(database.blocks[1].content.fields).toEqual([
1871
+ expect.objectContaining({ field_type: 'text', is_required: true, label: 'Name', temp_id: 'field-1' }),
1872
+ expect.objectContaining({ field_type: 'email', is_required: true, label: 'Email', temp_id: 'field-2' }),
1873
+ expect.objectContaining({ field_type: 'textarea', is_required: true, label: 'Message', temp_id: 'field-3' }),
1874
+ ]);
1875
+ });
1876
+
1877
+ it('creates confirmed posts and products with safe defaults', async () => {
1878
+ const { database, supabase } = createMockSupabase();
1879
+
1880
+ const postResult = await executeConfirmed(
1881
+ executeCreateCmsPost,
1882
+ {
1883
+ excerpt: 'Latest launch details.',
1884
+ title: 'Launch Notes',
1885
+ },
1886
+ { actorUserId: 'admin_1', revalidatePath: () => undefined, supabase }
1887
+ );
1888
+
1889
+ expect(postResult).toMatchObject({
1890
+ contentType: 'post',
1891
+ editPath: '/cms/posts/1/edit',
1892
+ mutationExecuted: true,
1893
+ slug: 'launch-notes',
1894
+ success: true,
1895
+ });
1896
+ expect(database.posts[0]).toMatchObject({
1897
+ author_id: 'admin_1',
1898
+ slug: 'launch-notes',
1899
+ status: 'draft',
1900
+ title: 'Launch Notes',
1901
+ });
1902
+
1903
+ const productResult = await executeConfirmed(
1904
+ executeCreateCmsProduct,
1905
+ {
1906
+ title: 'Studio Tee',
1907
+ },
1908
+ { revalidatePath: () => undefined, supabase }
1909
+ );
1910
+
1911
+ expect(productResult).toMatchObject({
1912
+ contentType: 'product',
1913
+ editPath: '/cms/products/1/edit',
1914
+ mutationExecuted: true,
1915
+ slug: 'studio-tee',
1916
+ success: true,
1917
+ });
1918
+ expect(database.products[0]).toMatchObject({
1919
+ is_taxable: true,
1920
+ payment_provider: 'stripe',
1921
+ price: 0,
1922
+ product_type: 'physical',
1923
+ sku: 'STUDIOTEE',
1924
+ status: 'draft',
1925
+ stock: 0,
1926
+ });
1927
+ });
1928
+
1929
+ it('returns duplicate slug failures without mutating', async () => {
1930
+ const { database, supabase } = createMockSupabase({
1931
+ pages: [
1932
+ {
1933
+ id: 7,
1934
+ language_id: 1,
1935
+ slug: 'contact-us',
1936
+ title: 'Contact Us',
1937
+ },
1938
+ ],
1939
+ });
1940
+
1941
+ const result = await executeCreateCmsPage(
1942
+ {
1943
+ contactEmail: 'info@nextblock.dev',
1944
+ title: 'Contact Us',
1945
+ },
1946
+ { actorUserId: 'admin_1', supabase }
1947
+ );
1948
+
1949
+ expect(result).toMatchObject({
1950
+ duplicate: true,
1951
+ mutationExecuted: false,
1952
+ success: false,
1953
+ });
1954
+ expect(database.pages).toHaveLength(1);
1955
+ expect(database.blocks).toHaveLength(0);
1956
+ });
1957
+
1958
+ it('updates single fields with confirmation and status aliases', async () => {
1959
+ const { database, supabase } = createMockSupabase({
1960
+ languages: [
1961
+ { code: 'en', id: 1, is_active: true, name: 'English' },
1962
+ { code: 'fr', id: 2, is_active: true, name: 'French' },
1963
+ ],
1964
+ pages: [
1965
+ {
1966
+ id: 3,
1967
+ language_id: 1,
1968
+ slug: 'about',
1969
+ status: 'draft',
1970
+ title: 'About',
1971
+ },
1972
+ ],
1973
+ });
1974
+
1975
+ const result = await executeConfirmed(
1976
+ executeUpdateCmsItemField,
1977
+ {
1978
+ contentType: 'page',
1979
+ entityId: 3,
1980
+ field: 'status',
1981
+ value: 'public',
1982
+ },
1983
+ { revalidatePath: () => undefined, supabase }
1984
+ );
1985
+
1986
+ expect(result).toMatchObject({
1987
+ contentType: 'page',
1988
+ entityId: 3,
1989
+ field: 'status',
1990
+ mutationExecuted: true,
1991
+ success: true,
1992
+ });
1993
+ expect(database.pages[0].status).toBe('published');
1994
+
1995
+ const languageResult = await executeConfirmed(
1996
+ executeUpdateCmsItemField,
1997
+ {
1998
+ contentType: 'page',
1999
+ entityId: 3,
2000
+ field: 'language',
2001
+ value: 'French',
2002
+ },
2003
+ { revalidatePath: () => undefined, supabase }
2004
+ );
2005
+
2006
+ expect(languageResult).toMatchObject({
2007
+ contentType: 'page',
2008
+ field: 'language_id',
2009
+ mutationExecuted: true,
2010
+ success: true,
2011
+ });
2012
+ expect(database.pages[0].language_id).toBe(2);
2013
+ });
2014
+
2015
+ it('updates product prices through ecommerce helpers and refuses scheduled specials', async () => {
2016
+ const product = {
2017
+ id: 'prod_1',
2018
+ is_taxable: true,
2019
+ language_id: 1,
2020
+ payment_provider: 'stripe',
2021
+ price: 1000,
2022
+ product_type: 'physical',
2023
+ sale_price: null,
2024
+ short_description: '',
2025
+ sku: 'STUDIO-TEE',
2026
+ slug: 'studio-tee',
2027
+ status: 'draft',
2028
+ stock: 0,
2029
+ title: 'Studio Tee',
2030
+ upc: '',
2031
+ };
2032
+ const { database, supabase } = createMockSupabase({
2033
+ products: [product],
2034
+ });
2035
+
2036
+ const priceResult = await executeConfirmed(
2037
+ executeUpdateCmsItemField,
2038
+ {
2039
+ contentType: 'product',
2040
+ entityId: 'prod_1',
2041
+ field: 'price',
2042
+ value: 19.99,
2043
+ },
2044
+ { revalidatePath: () => undefined, supabase }
2045
+ );
2046
+
2047
+ expect(priceResult).toMatchObject({
2048
+ contentType: 'product',
2049
+ field: 'price',
2050
+ mutationExecuted: true,
2051
+ success: true,
2052
+ });
2053
+ expect(database.products[0].price).toBe(1999);
2054
+
2055
+ const saleResult = await executeConfirmed(
2056
+ executeUpdateCmsItemField,
2057
+ {
2058
+ contentType: 'product',
2059
+ entityId: 'prod_1',
2060
+ field: 'sale_price',
2061
+ value: 9.99,
2062
+ },
2063
+ { revalidatePath: () => undefined, supabase }
2064
+ );
2065
+
2066
+ expect(saleResult).toMatchObject({
2067
+ contentType: 'product',
2068
+ field: 'sale_price',
2069
+ mutationExecuted: true,
2070
+ success: true,
2071
+ });
2072
+ expect(database.products[0].sale_price).toBe(999);
2073
+
2074
+ const scheduledResult = await executeUpdateCmsItemField(
2075
+ {
2076
+ contentType: 'product',
2077
+ endsAt: '2026-06-01',
2078
+ entityId: 'prod_1',
2079
+ field: 'sale_price',
2080
+ startsAt: '2026-05-01',
2081
+ value: 8.99,
2082
+ },
2083
+ { supabase }
2084
+ );
2085
+
2086
+ expect(scheduledResult).toMatchObject({
2087
+ mutationExecuted: false,
2088
+ success: false,
2089
+ unsupported: true,
2090
+ });
2091
+ expect(database.products[0].sale_price).toBe(999);
2092
+ });
2093
+
2094
+ it('prepares and confirms deleting page translation groups and nav links', async () => {
2095
+ const { database, supabase } = createMockSupabase({
2096
+ navigation_items: [
2097
+ { id: 1, label: 'Contact', language_id: 1, menu_key: 'HEADER', order: 0, url: '/contact' },
2098
+ { id: 2, label: 'Contact FR', language_id: 2, menu_key: 'HEADER', order: 0, url: '/contactez-nous' },
2099
+ ],
2100
+ pages: [
2101
+ {
2102
+ id: 1,
2103
+ language_id: 1,
2104
+ slug: 'contact',
2105
+ title: 'Contact',
2106
+ translation_group_id: 'group-contact',
2107
+ },
2108
+ {
2109
+ id: 2,
2110
+ language_id: 2,
2111
+ slug: 'contactez-nous',
2112
+ title: 'Contactez-nous',
2113
+ translation_group_id: 'group-contact',
2114
+ },
2115
+ ],
2116
+ });
2117
+
2118
+ const prepared = await executePrepareDeleteCmsItem(
2119
+ { contentType: 'page', entityId: 1 },
2120
+ { supabase }
2121
+ );
2122
+
2123
+ expectConfirmation(prepared);
2124
+ expect(prepared).toMatchObject({
2125
+ preparedDelete: true,
2126
+ preview: {
2127
+ affectedCount: 2,
2128
+ collectionPath: '/cms/pages',
2129
+ contentType: 'page',
2130
+ navigationLinkCount: 2,
2131
+ summary:
2132
+ 'Delete page "Contact" (contact), including 2 language versions and 2 navigation links.',
2133
+ },
2134
+ });
2135
+
2136
+ const result = await executeDeleteCmsItem(
2137
+ { contentType: 'page', entityId: 1 },
2138
+ {
2139
+ latestUserMessage: prepared.confirmationPhrase,
2140
+ revalidatePath: () => undefined,
2141
+ supabase,
2142
+ }
2143
+ );
2144
+
2145
+ expect(result).toMatchObject({
2146
+ affectedCount: 2,
2147
+ collectionPath: '/cms/pages',
2148
+ contentType: 'page',
2149
+ mutationExecuted: true,
2150
+ redirectPath: '/cms/pages',
2151
+ success: true,
2152
+ });
2153
+ expect(database.pages).toEqual([]);
2154
+ expect(database.navigation_items).toEqual([]);
2155
+ });
2156
+
2157
+ it('deletes a confirmed product without deleting other products', async () => {
2158
+ const { database, supabase } = createMockSupabase({
2159
+ products: [
2160
+ { id: 'prod_1', slug: 'studio-tee', title: 'Studio Tee' },
2161
+ { id: 'prod_2', slug: 'studio-hat', title: 'Studio Hat' },
2162
+ ],
2163
+ });
2164
+
2165
+ const result = await executeConfirmed(
2166
+ executeDeleteCmsItem,
2167
+ { contentType: 'product', entityId: 'prod_1' },
2168
+ { revalidatePath: () => undefined, supabase }
2169
+ );
2170
+
2171
+ expect(result).toMatchObject({
2172
+ affectedCount: 1,
2173
+ collectionPath: '/cms/products',
2174
+ contentType: 'product',
2175
+ mutationExecuted: true,
2176
+ redirectPath: '/cms/products',
2177
+ success: true,
2178
+ });
2179
+ expect(database.products).toEqual([
2180
+ { id: 'prod_2', slug: 'studio-hat', title: 'Studio Hat' },
2181
+ ]);
2182
+ });
2183
+ });