create-nextblock 0.2.78 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (413) hide show
  1. package/bin/create-nextblock.js +793 -472
  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,2243 @@
1
+ import "server-only";
2
+
3
+ import { revalidatePath, revalidateTag } from "next/cache";
4
+ import { v4 as uuidv4 } from "uuid";
5
+ import type { Json } from "@nextblock-cms/db";
6
+ import {
7
+ createClient,
8
+ getServiceRoleSupabaseClient,
9
+ verifyPackageOnline,
10
+ } from "@nextblock-cms/db/server";
11
+ import { syncCategoriesForTranslationGroup } from "@nextblock-cms/ecommerce/server";
12
+ import {
13
+ buildCustomBlockCopySlug,
14
+ customBlockDefinitionCreateSchema,
15
+ } from "@nextblock-cms/utils";
16
+
17
+ import {
18
+ CUSTOM_BLOCK_DEFINITIONS_CACHE_TAG,
19
+ getCustomBlockDefinitionCacheTag,
20
+ } from "../custom-block-definitions";
21
+
22
+ import {
23
+ createPageRevision,
24
+ createPostRevision,
25
+ } from "../../app/cms/revisions/service";
26
+ import {
27
+ getFullPageContent,
28
+ getFullPostContent,
29
+ type FullPageContent,
30
+ type FullPostContent,
31
+ } from "../../app/cms/revisions/utils";
32
+ import {
33
+ getCsvHeaders,
34
+ getTemplateCsv,
35
+ majorToMinor,
36
+ makeUniqueSku,
37
+ makeUniqueSlug,
38
+ minorToMajor,
39
+ normalizeBlocksFromFields,
40
+ parseBoolean,
41
+ parseCsv,
42
+ parseJsonField,
43
+ parseNullableNumber,
44
+ parseNumber,
45
+ priceMapMajorToMinor,
46
+ priceMapMinorToMajor,
47
+ salePriceMapMajorToMinor,
48
+ salePriceMapMinorToMajor,
49
+ splitList,
50
+ stringifyCsv,
51
+ toJsonCell,
52
+ } from "./csv";
53
+ import type {
54
+ BackupBlockRecord,
55
+ BackupCustomBlockRecord,
56
+ BackupPageRecord,
57
+ BackupPostRecord,
58
+ BackupProductRecord,
59
+ BackupProductVariantRecord,
60
+ CmsBackupBundleV1,
61
+ CmsContentType,
62
+ CmsImportConflictMode,
63
+ CmsImportMessage,
64
+ CmsImportOptions,
65
+ CmsImportPreviewItem,
66
+ CmsImportSummary,
67
+ } from "./types";
68
+
69
+ type SupabaseAny = ReturnType<typeof getServiceRoleSupabaseClient>;
70
+ type CsvLikeRow = Record<string, string>;
71
+ type ImportSourceRow = CsvLikeRow | BackupPageRecord | BackupPostRecord | BackupProductRecord;
72
+
73
+ const PAGE_STATUSES = new Set(["draft", "published", "archived"]);
74
+ const PRODUCT_STATUSES = new Set(["draft", "active", "archived"]);
75
+ const PRODUCT_TYPES = new Set(["physical", "digital"]);
76
+ const PAYMENT_PROVIDERS = new Set(["stripe", "freemius"]);
77
+
78
+ interface TransferAuth {
79
+ userId: string;
80
+ role: string;
81
+ supabase: SupabaseAny;
82
+ }
83
+
84
+ interface LanguageRecord {
85
+ id: number;
86
+ code: string;
87
+ name: string | null;
88
+ }
89
+
90
+ interface ExistingContentRecord {
91
+ id: number;
92
+ language_id: number;
93
+ slug: string;
94
+ title: string;
95
+ status: string;
96
+ version?: number | null;
97
+ translation_group_id?: string | null;
98
+ }
99
+
100
+ interface ExistingProductRecord {
101
+ id: string;
102
+ language_id: number;
103
+ slug: string;
104
+ sku: string;
105
+ title: string;
106
+ status: string;
107
+ translation_group_id?: string | null;
108
+ }
109
+
110
+ interface PreparedContentImport {
111
+ contentType: "pages" | "posts";
112
+ rowNumber: number;
113
+ action: "create" | "update";
114
+ targetId: number | null;
115
+ meta: Record<string, unknown>;
116
+ blocks: BackupBlockRecord[];
117
+ replaceBlocks: boolean;
118
+ oldSlug?: string | null;
119
+ }
120
+
121
+ interface PreparedProductImport {
122
+ contentType: "products";
123
+ rowNumber: number;
124
+ action: "create" | "update";
125
+ targetId: string | null;
126
+ meta: Record<string, unknown>;
127
+ blocks: BackupBlockRecord[];
128
+ categoryIds: string[];
129
+ mediaIds: string[];
130
+ variants: BackupProductVariantRecord[];
131
+ replaceBlocks: boolean;
132
+ replaceMedia: boolean;
133
+ replaceVariants: boolean;
134
+ syncCategories: boolean;
135
+ oldSlug?: string | null;
136
+ }
137
+
138
+ type PreparedImport = PreparedContentImport | PreparedProductImport;
139
+
140
+ function emptySummary(): CmsImportSummary {
141
+ return {
142
+ success: true,
143
+ totalRows: 0,
144
+ created: 0,
145
+ updated: 0,
146
+ skipped: 0,
147
+ errors: [],
148
+ warnings: [],
149
+ preview: [],
150
+ };
151
+ }
152
+
153
+ function addMessage(
154
+ messages: CmsImportMessage[],
155
+ row: number,
156
+ message: string,
157
+ type: "error" | "warning" = "error"
158
+ ) {
159
+ messages.push({ row, type, message });
160
+ }
161
+
162
+ function normalizeNullableString(value: unknown) {
163
+ const text = typeof value === "string" ? value.trim() : "";
164
+ return text ? text : null;
165
+ }
166
+
167
+ function normalizeRequiredString(value: unknown) {
168
+ return typeof value === "string" ? value.trim() : "";
169
+ }
170
+
171
+ function normalizeId(value: unknown) {
172
+ const text = normalizeRequiredString(value);
173
+ return text || null;
174
+ }
175
+
176
+ function rowHasOwnField(row: ImportSourceRow, fieldName: string) {
177
+ return Object.prototype.hasOwnProperty.call(row, fieldName);
178
+ }
179
+
180
+ function rowHasImportValue(row: ImportSourceRow, fieldName: string) {
181
+ if (!rowHasOwnField(row, fieldName)) return false;
182
+
183
+ const value = (row as Record<string, unknown>)[fieldName];
184
+ if (value === null || value === undefined) return false;
185
+ if (Array.isArray(value)) return value.length > 0;
186
+ if (typeof value === "object") return true;
187
+ return normalizeRequiredString(value) !== "";
188
+ }
189
+
190
+ function canPreserveBlankFields(options: CmsImportOptions, target: unknown) {
191
+ return Boolean(
192
+ options.ignoreBlankFields &&
193
+ options.conflictMode === "overwrite_existing" &&
194
+ options.applyMode === "live" &&
195
+ target
196
+ );
197
+ }
198
+
199
+ function shouldPreserveBlankField(
200
+ row: ImportSourceRow,
201
+ fieldName: string,
202
+ options: CmsImportOptions,
203
+ target: unknown
204
+ ) {
205
+ return canPreserveBlankFields(options, target) && !rowHasImportValue(row, fieldName);
206
+ }
207
+
208
+ function setMetaValue(
209
+ meta: Record<string, unknown>,
210
+ key: string,
211
+ value: unknown,
212
+ shouldSet = true
213
+ ) {
214
+ if (shouldSet) {
215
+ meta[key] = value;
216
+ }
217
+ }
218
+
219
+ function readRequiredNumber(
220
+ value: unknown,
221
+ fieldName: string,
222
+ rowNumber: number,
223
+ errors: CmsImportMessage[]
224
+ ) {
225
+ const raw = typeof value === "number" ? String(value) : normalizeRequiredString(value);
226
+ if (!raw) {
227
+ addMessage(errors, rowNumber, `${fieldName} is required.`);
228
+ return null;
229
+ }
230
+
231
+ const parsed = Number(raw);
232
+ if (!Number.isFinite(parsed)) {
233
+ addMessage(errors, rowNumber, `${fieldName} must be a valid number.`);
234
+ return null;
235
+ }
236
+ if (parsed < 0) {
237
+ addMessage(errors, rowNumber, `${fieldName} must be zero or greater.`);
238
+ return null;
239
+ }
240
+
241
+ return parsed;
242
+ }
243
+
244
+ function readOptionalDate(
245
+ value: unknown,
246
+ fieldName: string,
247
+ rowNumber: number,
248
+ errors: CmsImportMessage[]
249
+ ) {
250
+ const raw = normalizeNullableString(value);
251
+ if (!raw) return null;
252
+
253
+ const date = new Date(raw);
254
+ if (Number.isNaN(date.getTime())) {
255
+ addMessage(errors, rowNumber, `${fieldName} must be a valid date.`);
256
+ return null;
257
+ }
258
+
259
+ return date.toISOString();
260
+ }
261
+
262
+ function isRecord(value: unknown): value is Record<string, unknown> {
263
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
264
+ }
265
+
266
+ async function requireTransferUser(adminOnly = false): Promise<TransferAuth> {
267
+ const authClient = createClient();
268
+ const {
269
+ data: { user },
270
+ } = await authClient.auth.getUser();
271
+
272
+ if (!user) {
273
+ throw new Error("User not authenticated.");
274
+ }
275
+
276
+ const { data: profile, error } = await authClient
277
+ .from("profiles")
278
+ .select("role")
279
+ .eq("id", user.id)
280
+ .single();
281
+
282
+ if (error || !profile) {
283
+ throw new Error("Could not verify CMS permissions.");
284
+ }
285
+
286
+ const role = profile.role;
287
+ const allowed = adminOnly ? role === "ADMIN" : role === "ADMIN" || role === "WRITER";
288
+ if (!allowed) {
289
+ throw new Error(adminOnly ? "Admin role required." : "Writer or admin role required.");
290
+ }
291
+
292
+ return {
293
+ userId: user.id,
294
+ role,
295
+ supabase: getServiceRoleSupabaseClient(),
296
+ };
297
+ }
298
+
299
+ async function loadLanguages(supabase: SupabaseAny) {
300
+ const { data, error } = await supabase
301
+ .from("languages")
302
+ .select("id, code, name")
303
+ .order("id", { ascending: true });
304
+
305
+ if (error) {
306
+ throw new Error(`Failed to load languages: ${error.message}`);
307
+ }
308
+
309
+ const languages = (data || []) as LanguageRecord[];
310
+ const byCode = new Map(languages.map((language) => [language.code.toLowerCase(), language]));
311
+ const byId = new Map(languages.map((language) => [language.id, language]));
312
+
313
+ return { languages, byCode, byId };
314
+ }
315
+
316
+ async function isEcommerceContentAvailable(supabase: SupabaseAny) {
317
+ return verifyPackageOnline("ecommerce", supabase).catch(() => false);
318
+ }
319
+
320
+ async function loadMediaMaps(supabase: SupabaseAny) {
321
+ const { data, error } = await supabase
322
+ .from("media")
323
+ .select("id, object_key");
324
+
325
+ if (error) {
326
+ throw new Error(`Failed to load media references: ${error.message}`);
327
+ }
328
+
329
+ const byId = new Map<string, { id: string; object_key: string | null }>();
330
+ const byObjectKey = new Map<string, { id: string; object_key: string | null }>();
331
+
332
+ for (const media of data || []) {
333
+ const id = String((media as any).id);
334
+ const objectKey = typeof (media as any).object_key === "string" ? (media as any).object_key : null;
335
+ byId.set(id, { id, object_key: objectKey });
336
+ if (objectKey) {
337
+ byObjectKey.set(objectKey, { id, object_key: objectKey });
338
+ }
339
+ }
340
+
341
+ return { byId, byObjectKey };
342
+ }
343
+
344
+ function resolveMediaId(params: {
345
+ id?: string | null;
346
+ objectKey?: string | null;
347
+ rowNumber: number;
348
+ fieldLabel: string;
349
+ mediaById: Map<string, { id: string; object_key: string | null }>;
350
+ mediaByObjectKey: Map<string, { id: string; object_key: string | null }>;
351
+ warnings: CmsImportMessage[];
352
+ }) {
353
+ const id = normalizeId(params.id);
354
+ const objectKey = normalizeId(params.objectKey);
355
+
356
+ if (id) {
357
+ if (params.mediaById.has(id)) {
358
+ return id;
359
+ }
360
+
361
+ addMessage(params.warnings, params.rowNumber, `${params.fieldLabel} "${id}" was not found and will be skipped.`, "warning");
362
+ return null;
363
+ }
364
+
365
+ if (objectKey) {
366
+ const media = params.mediaByObjectKey.get(objectKey);
367
+ if (media) {
368
+ return media.id;
369
+ }
370
+
371
+ addMessage(params.warnings, params.rowNumber, `${params.fieldLabel} object key "${objectKey}" was not found and will be skipped.`, "warning");
372
+ }
373
+
374
+ return null;
375
+ }
376
+
377
+ function getOptionalLanguageId(
378
+ row: ImportSourceRow,
379
+ rowNumber: number,
380
+ languagesByCode: Map<string, LanguageRecord>,
381
+ errors: CmsImportMessage[]
382
+ ) {
383
+ const languageCode = normalizeRequiredString((row as any).language_code).toLowerCase();
384
+ if (!languageCode) return null;
385
+
386
+ const language = languagesByCode.get(languageCode);
387
+ if (!language) {
388
+ addMessage(errors, rowNumber, `Language "${languageCode}" does not exist.`);
389
+ return null;
390
+ }
391
+
392
+ return language.id;
393
+ }
394
+
395
+ function parseBlocksForContent(
396
+ row: ImportSourceRow,
397
+ rowNumber: number,
398
+ languageId: number,
399
+ errors: CmsImportMessage[]
400
+ ) {
401
+ const localErrors: Array<{ row: number; message: string }> = [];
402
+ const backupBlocks = Array.isArray((row as BackupPageRecord).blocks)
403
+ ? (row as BackupPageRecord).blocks
404
+ : undefined;
405
+ const parsedBlocks =
406
+ backupBlocks ??
407
+ parseJsonField<unknown>(
408
+ (row as CsvLikeRow).blocks_json,
409
+ "blocks_json",
410
+ rowNumber,
411
+ localErrors
412
+ );
413
+
414
+ const blocks = normalizeBlocksFromFields({
415
+ blocksJson: parsedBlocks,
416
+ html: (row as CsvLikeRow).content_html,
417
+ languageId,
418
+ rowNumber,
419
+ errors: localErrors,
420
+ });
421
+
422
+ for (const error of localErrors) {
423
+ addMessage(errors, error.row, error.message);
424
+ }
425
+
426
+ return blocks;
427
+ }
428
+
429
+ function parseBlocksForProduct(
430
+ row: ImportSourceRow,
431
+ rowNumber: number,
432
+ languageId: number,
433
+ errors: CmsImportMessage[]
434
+ ) {
435
+ const localErrors: Array<{ row: number; message: string }> = [];
436
+ const backupBlocks = Array.isArray((row as BackupProductRecord).description_blocks)
437
+ ? (row as BackupProductRecord).description_blocks
438
+ : undefined;
439
+ const parsedBlocks =
440
+ backupBlocks ??
441
+ parseJsonField<unknown>(
442
+ (row as CsvLikeRow).description_blocks_json,
443
+ "description_blocks_json",
444
+ rowNumber,
445
+ localErrors
446
+ );
447
+
448
+ const blocks = normalizeBlocksFromFields({
449
+ blocksJson: parsedBlocks,
450
+ html: (row as CsvLikeRow).description_html,
451
+ languageId,
452
+ rowNumber,
453
+ errors: localErrors,
454
+ });
455
+
456
+ for (const error of localErrors) {
457
+ addMessage(errors, error.row, error.message);
458
+ }
459
+
460
+ return blocks;
461
+ }
462
+
463
+ async function loadExistingContent(supabase: SupabaseAny, contentType: "pages" | "posts") {
464
+ const table = contentType === "pages" ? "pages" : "posts";
465
+ const { data, error } = await supabase
466
+ .from(table)
467
+ .select("id, language_id, slug, title, status, version, translation_group_id");
468
+
469
+ if (error) {
470
+ throw new Error(`Failed to load existing ${contentType}: ${error.message}`);
471
+ }
472
+
473
+ const byId = new Map<number, ExistingContentRecord>();
474
+ const bySlug = new Map<string, ExistingContentRecord>();
475
+
476
+ for (const item of (data || []) as ExistingContentRecord[]) {
477
+ byId.set(Number(item.id), item);
478
+ bySlug.set(`${item.language_id}:${item.slug}`, item);
479
+ }
480
+
481
+ return { byId, bySlug };
482
+ }
483
+
484
+ async function loadExistingProducts(supabase: SupabaseAny) {
485
+ const { data, error } = await supabase
486
+ .from("products")
487
+ .select("id, language_id, slug, sku, title, status, translation_group_id");
488
+
489
+ if (error) {
490
+ throw new Error(`Failed to load existing products: ${error.message}`);
491
+ }
492
+
493
+ const byId = new Map<string, ExistingProductRecord>();
494
+ const bySlug = new Map<string, ExistingProductRecord>();
495
+ const bySku = new Map<string, ExistingProductRecord>();
496
+
497
+ for (const item of (data || []) as ExistingProductRecord[]) {
498
+ byId.set(item.id, item);
499
+ bySlug.set(`${item.language_id}:${item.slug}`, item);
500
+ bySku.set(`${item.language_id}:${item.sku}`, item);
501
+ }
502
+
503
+ return { byId, bySlug, bySku };
504
+ }
505
+
506
+ function createPreview(
507
+ prepared: PreparedImport[],
508
+ summary: CmsImportSummary
509
+ ) {
510
+ summary.preview = prepared.map((item) => ({
511
+ row: item.rowNumber,
512
+ action: item.action,
513
+ identifier:
514
+ item.contentType === "products"
515
+ ? String(item.meta.sku || item.meta.slug || item.meta.title || item.targetId || "Product")
516
+ : String(item.meta.slug || item.meta.title || item.targetId || "Content"),
517
+ contentType: item.contentType,
518
+ })) satisfies CmsImportPreviewItem[];
519
+ summary.created = prepared.filter((item) => item.action === "create").length;
520
+ summary.updated = prepared.filter((item) => item.action === "update").length;
521
+ summary.skipped = 0;
522
+ }
523
+
524
+ async function prepareContentImports(params: {
525
+ supabase: SupabaseAny;
526
+ contentType: "pages" | "posts";
527
+ rows: ImportSourceRow[];
528
+ options: CmsImportOptions;
529
+ }) {
530
+ const summary = emptySummary();
531
+ summary.totalRows = params.rows.length;
532
+ const prepared: PreparedContentImport[] = [];
533
+ const { byCode: languagesByCode } = await loadLanguages(params.supabase);
534
+ const mediaMaps = await loadMediaMaps(params.supabase);
535
+ const existing = await loadExistingContent(params.supabase, params.contentType);
536
+ const slugSets = new Map<number, Set<string>>();
537
+ const translationGroupMap = new Map<string, string>();
538
+
539
+ for (const item of existing.byId.values()) {
540
+ const set = slugSets.get(item.language_id) ?? new Set<string>();
541
+ set.add(item.slug);
542
+ slugSets.set(item.language_id, set);
543
+ }
544
+
545
+ for (const [index, row] of params.rows.entries()) {
546
+ const rowNumber = index + 2;
547
+ const numericId = Number((row as any).id);
548
+ const matchedById =
549
+ params.options.conflictMode === "overwrite_existing" && Number.isFinite(numericId)
550
+ ? existing.byId.get(numericId)
551
+ : undefined;
552
+ let languageId = getOptionalLanguageId(row, rowNumber, languagesByCode, summary.errors);
553
+ if (!languageId && matchedById) {
554
+ languageId = matchedById.language_id;
555
+ }
556
+
557
+ const incomingSlug = normalizeRequiredString((row as any).slug);
558
+ const matchedBySlug =
559
+ params.options.conflictMode === "overwrite_existing" && languageId && incomingSlug
560
+ ? existing.bySlug.get(`${languageId}:${incomingSlug}`)
561
+ : undefined;
562
+ const target = matchedById ?? matchedBySlug ?? null;
563
+ const action = target ? "update" : "create";
564
+ const preserveBlanks = canPreserveBlankFields(params.options, target);
565
+ const preserveField = (fieldName: string) =>
566
+ shouldPreserveBlankField(row, fieldName, params.options, target);
567
+ const title = normalizeRequiredString((row as any).title);
568
+ const status = preserveField("status")
569
+ ? ""
570
+ : normalizeRequiredString((row as any).status || "draft");
571
+
572
+ if (!languageId && !rowHasImportValue(row, "language_code")) {
573
+ addMessage(summary.errors, rowNumber, "language_code is required.");
574
+ }
575
+ if (!title && !preserveField("title")) addMessage(summary.errors, rowNumber, "title is required.");
576
+ if (!incomingSlug && !preserveField("slug")) addMessage(summary.errors, rowNumber, "slug is required.");
577
+ if (!preserveField("status") && !PAGE_STATUSES.has(status)) {
578
+ addMessage(summary.errors, rowNumber, `status must be one of: ${Array.from(PAGE_STATUSES).join(", ")}.`);
579
+ }
580
+ if (
581
+ !languageId ||
582
+ (!title && !preserveField("title")) ||
583
+ (!incomingSlug && !preserveField("slug")) ||
584
+ (!preserveField("status") && !PAGE_STATUSES.has(status))
585
+ ) {
586
+ continue;
587
+ }
588
+
589
+ const slugSet = slugSets.get(languageId) ?? new Set<string>();
590
+ const shouldUpdateSlug = !preserveField("slug");
591
+ const slug = shouldUpdateSlug
592
+ ? params.options.conflictMode === "create_new" || action === "create"
593
+ ? makeUniqueSlug(incomingSlug, slugSet)
594
+ : incomingSlug
595
+ : target?.slug ?? incomingSlug;
596
+ slugSets.set(languageId, slugSet);
597
+
598
+ const slugOwner = existing.bySlug.get(`${languageId}:${slug}`);
599
+ if (action === "update" && shouldUpdateSlug && slugOwner && slugOwner.id !== target?.id) {
600
+ addMessage(summary.errors, rowNumber, `Slug "${slug}" already belongs to another ${params.contentType.slice(0, -1)} in this language.`);
601
+ continue;
602
+ }
603
+
604
+ const importedGroupId = normalizeId((row as any).translation_group_id);
605
+ const translationGroupId =
606
+ action === "update"
607
+ ? importedGroupId || target?.translation_group_id || uuidv4()
608
+ : importedGroupId
609
+ ? translationGroupMap.get(importedGroupId) ?? translationGroupMap.set(importedGroupId, uuidv4()).get(importedGroupId)
610
+ : uuidv4();
611
+
612
+ const shouldResolveFeatureImage =
613
+ !preserveBlanks ||
614
+ rowHasImportValue(row, "feature_image_id") ||
615
+ rowHasImportValue(row, "feature_image_object_key");
616
+ const featureImageId = shouldResolveFeatureImage
617
+ ? resolveMediaId({
618
+ id: normalizeId((row as any).feature_image_id),
619
+ objectKey: normalizeId((row as any).feature_image_object_key),
620
+ rowNumber,
621
+ fieldLabel: "Feature image",
622
+ mediaById: mediaMaps.byId,
623
+ mediaByObjectKey: mediaMaps.byObjectKey,
624
+ warnings: summary.warnings,
625
+ })
626
+ : undefined;
627
+
628
+ const replaceBlocks =
629
+ !preserveBlanks ||
630
+ rowHasImportValue(row, "blocks_json") ||
631
+ rowHasImportValue(row, "content_html") ||
632
+ Array.isArray((row as BackupPageRecord).blocks);
633
+ const blocks = replaceBlocks
634
+ ? parseBlocksForContent(row, rowNumber, languageId, summary.errors)
635
+ : [];
636
+ const meta: Record<string, unknown> = {};
637
+ setMetaValue(meta, "language_id", languageId, !preserveField("language_code"));
638
+ setMetaValue(meta, "title", title, !preserveField("title"));
639
+ setMetaValue(meta, "slug", slug, shouldUpdateSlug);
640
+ setMetaValue(meta, "status", status, !preserveField("status"));
641
+ setMetaValue(
642
+ meta,
643
+ "meta_title",
644
+ normalizeNullableString((row as any).meta_title),
645
+ !preserveField("meta_title")
646
+ );
647
+ setMetaValue(
648
+ meta,
649
+ "meta_description",
650
+ normalizeNullableString((row as any).meta_description),
651
+ !preserveField("meta_description")
652
+ );
653
+ setMetaValue(meta, "feature_image_id", featureImageId, shouldResolveFeatureImage);
654
+ setMetaValue(
655
+ meta,
656
+ "translation_group_id",
657
+ translationGroupId,
658
+ !preserveField("translation_group_id")
659
+ );
660
+
661
+ if (params.contentType === "posts") {
662
+ setMetaValue(meta, "label", normalizeNullableString((row as any).label), !preserveField("label"));
663
+ setMetaValue(meta, "excerpt", normalizeNullableString((row as any).excerpt), !preserveField("excerpt"));
664
+ setMetaValue(meta, "subtitle", normalizeNullableString((row as any).subtitle), !preserveField("subtitle"));
665
+ setMetaValue(
666
+ meta,
667
+ "published_at",
668
+ readOptionalDate((row as any).published_at, "published_at", rowNumber, summary.errors),
669
+ !preserveField("published_at")
670
+ );
671
+ }
672
+
673
+ prepared.push({
674
+ contentType: params.contentType,
675
+ rowNumber,
676
+ action,
677
+ targetId: target?.id ?? null,
678
+ meta,
679
+ blocks,
680
+ replaceBlocks,
681
+ oldSlug: target?.slug ?? null,
682
+ });
683
+ }
684
+
685
+ createPreview(prepared, summary);
686
+ summary.success = summary.errors.length === 0;
687
+ return { summary, prepared };
688
+ }
689
+
690
+ async function loadCategories(supabase: SupabaseAny) {
691
+ const { data, error } = await supabase
692
+ .from("categories" as any)
693
+ .select("id, slug");
694
+
695
+ if (error) {
696
+ throw new Error(`Failed to load product categories: ${error.message}`);
697
+ }
698
+
699
+ return new Map((data || []).map((category: any) => [String(category.slug), String(category.id)]));
700
+ }
701
+
702
+ function normalizePriceMapValue(value: unknown) {
703
+ return isRecord(value) ? (value as Record<string, number>) : {};
704
+ }
705
+
706
+ function normalizeSalePriceMapValue(value: unknown) {
707
+ return isRecord(value) ? (value as Record<string, number | null>) : {};
708
+ }
709
+
710
+ async function prepareProductImports(params: {
711
+ supabase: SupabaseAny;
712
+ rows: ImportSourceRow[];
713
+ options: CmsImportOptions;
714
+ }) {
715
+ const summary = emptySummary();
716
+ summary.totalRows = params.rows.length;
717
+ const prepared: PreparedProductImport[] = [];
718
+ const { byCode: languagesByCode } = await loadLanguages(params.supabase);
719
+ const mediaMaps = await loadMediaMaps(params.supabase);
720
+ const categoryMap = await loadCategories(params.supabase);
721
+ const existing = await loadExistingProducts(params.supabase);
722
+ const slugSets = new Map<number, Set<string>>();
723
+ const skuSets = new Map<number, Set<string>>();
724
+ const translationGroupMap = new Map<string, string>();
725
+
726
+ for (const item of existing.byId.values()) {
727
+ const slugSet = slugSets.get(item.language_id) ?? new Set<string>();
728
+ const skuSet = skuSets.get(item.language_id) ?? new Set<string>();
729
+ slugSet.add(item.slug);
730
+ skuSet.add(item.sku);
731
+ slugSets.set(item.language_id, slugSet);
732
+ skuSets.set(item.language_id, skuSet);
733
+ }
734
+
735
+ for (const [index, row] of params.rows.entries()) {
736
+ const rowNumber = index + 2;
737
+ const rowId = normalizeId((row as any).id);
738
+ const matchedById =
739
+ params.options.conflictMode === "overwrite_existing" && rowId
740
+ ? existing.byId.get(rowId)
741
+ : undefined;
742
+ let languageId = getOptionalLanguageId(row, rowNumber, languagesByCode, summary.errors);
743
+ if (!languageId && matchedById) {
744
+ languageId = matchedById.language_id;
745
+ }
746
+
747
+ const incomingSlug = normalizeRequiredString((row as any).slug);
748
+ const incomingSku = normalizeRequiredString((row as any).sku);
749
+ const matchedBySku =
750
+ params.options.conflictMode === "overwrite_existing" && languageId && incomingSku
751
+ ? existing.bySku.get(`${languageId}:${incomingSku}`)
752
+ : undefined;
753
+ const matchedBySlug =
754
+ params.options.conflictMode === "overwrite_existing" && languageId && incomingSlug
755
+ ? existing.bySlug.get(`${languageId}:${incomingSlug}`)
756
+ : undefined;
757
+
758
+ if (matchedBySku && matchedBySlug && matchedBySku.id !== matchedBySlug.id) {
759
+ addMessage(summary.errors, rowNumber, `SKU "${incomingSku}" and slug "${incomingSlug}" match different products.`);
760
+ continue;
761
+ }
762
+
763
+ const target = matchedById ?? matchedBySku ?? matchedBySlug ?? null;
764
+ const action = target ? "update" : "create";
765
+ const preserveBlanks = canPreserveBlankFields(params.options, target);
766
+ const preserveField = (fieldName: string) =>
767
+ shouldPreserveBlankField(row, fieldName, params.options, target);
768
+ const title = normalizeRequiredString((row as any).title);
769
+ const productType = preserveField("product_type")
770
+ ? ""
771
+ : normalizeRequiredString((row as any).product_type || "physical");
772
+ const paymentProvider = preserveField("payment_provider")
773
+ ? ""
774
+ : normalizeRequiredString(
775
+ (row as any).payment_provider || (productType === "digital" ? "freemius" : "stripe")
776
+ );
777
+ const status = preserveField("status")
778
+ ? ""
779
+ : normalizeRequiredString((row as any).status || "draft");
780
+ const price = preserveField("price")
781
+ ? undefined
782
+ : readRequiredNumber((row as any).price, "price", rowNumber, summary.errors);
783
+ const stock = preserveField("stock")
784
+ ? undefined
785
+ : readRequiredNumber((row as any).stock, "stock", rowNumber, summary.errors);
786
+ const updatesProductType = !preserveField("product_type");
787
+ const updatesPaymentProvider = !preserveField("payment_provider");
788
+
789
+ if (!languageId && !rowHasImportValue(row, "language_code")) {
790
+ addMessage(summary.errors, rowNumber, "language_code is required.");
791
+ }
792
+ if (!title && !preserveField("title")) addMessage(summary.errors, rowNumber, "title is required.");
793
+ if (!incomingSlug && !preserveField("slug")) addMessage(summary.errors, rowNumber, "slug is required.");
794
+ if (!incomingSku && !preserveField("sku")) addMessage(summary.errors, rowNumber, "sku is required.");
795
+ if (updatesProductType && !PRODUCT_TYPES.has(productType)) {
796
+ addMessage(summary.errors, rowNumber, "product_type must be physical or digital.");
797
+ }
798
+ if (updatesPaymentProvider && !PAYMENT_PROVIDERS.has(paymentProvider)) {
799
+ addMessage(summary.errors, rowNumber, "payment_provider must be stripe or freemius.");
800
+ }
801
+ if (preserveBlanks && updatesProductType !== updatesPaymentProvider) {
802
+ addMessage(summary.errors, rowNumber, "product_type and payment_provider must be imported together when ignoring blank fields.");
803
+ }
804
+ if (updatesProductType && updatesPaymentProvider && productType === "physical" && paymentProvider !== "stripe") {
805
+ addMessage(summary.errors, rowNumber, "physical products must use stripe.");
806
+ }
807
+ if (updatesProductType && updatesPaymentProvider && productType === "digital" && paymentProvider !== "freemius") {
808
+ addMessage(summary.errors, rowNumber, "digital products must use freemius.");
809
+ }
810
+ if (!preserveField("status") && !PRODUCT_STATUSES.has(status)) {
811
+ addMessage(summary.errors, rowNumber, `status must be one of: ${Array.from(PRODUCT_STATUSES).join(", ")}.`);
812
+ }
813
+ if (
814
+ !languageId ||
815
+ (!title && !preserveField("title")) ||
816
+ (!incomingSlug && !preserveField("slug")) ||
817
+ (!incomingSku && !preserveField("sku")) ||
818
+ (updatesProductType && !PRODUCT_TYPES.has(productType)) ||
819
+ (updatesPaymentProvider && !PAYMENT_PROVIDERS.has(paymentProvider)) ||
820
+ (preserveBlanks && updatesProductType !== updatesPaymentProvider) ||
821
+ (updatesProductType && updatesPaymentProvider && productType === "physical" && paymentProvider !== "stripe") ||
822
+ (updatesProductType && updatesPaymentProvider && productType === "digital" && paymentProvider !== "freemius") ||
823
+ (!preserveField("status") && !PRODUCT_STATUSES.has(status)) ||
824
+ price === null ||
825
+ stock === null
826
+ ) {
827
+ continue;
828
+ }
829
+
830
+ const slugSet = slugSets.get(languageId) ?? new Set<string>();
831
+ const skuSet = skuSets.get(languageId) ?? new Set<string>();
832
+ const shouldUpdateSlug = !preserveField("slug");
833
+ const shouldUpdateSku = !preserveField("sku");
834
+ const slug = shouldUpdateSlug
835
+ ? params.options.conflictMode === "create_new" || action === "create"
836
+ ? makeUniqueSlug(incomingSlug, slugSet)
837
+ : incomingSlug
838
+ : target?.slug ?? incomingSlug;
839
+ const sku = shouldUpdateSku
840
+ ? params.options.conflictMode === "create_new" || action === "create"
841
+ ? makeUniqueSku(incomingSku, skuSet)
842
+ : incomingSku
843
+ : target?.sku ?? incomingSku;
844
+ slugSets.set(languageId, slugSet);
845
+ skuSets.set(languageId, skuSet);
846
+
847
+ const slugOwner = existing.bySlug.get(`${languageId}:${slug}`);
848
+ const skuOwner = existing.bySku.get(`${languageId}:${sku}`);
849
+ if (action === "update" && shouldUpdateSlug && slugOwner && slugOwner.id !== target?.id) {
850
+ addMessage(summary.errors, rowNumber, `Slug "${slug}" already belongs to another product in this language.`);
851
+ continue;
852
+ }
853
+ if (action === "update" && shouldUpdateSku && skuOwner && skuOwner.id !== target?.id) {
854
+ addMessage(summary.errors, rowNumber, `SKU "${sku}" already belongs to another product in this language.`);
855
+ continue;
856
+ }
857
+
858
+ const localErrors: Array<{ row: number; message: string }> = [];
859
+ const prices =
860
+ preserveField("prices_json")
861
+ ? undefined
862
+ : (row as BackupProductRecord).prices ??
863
+ parseJsonField<Record<string, number>>((row as CsvLikeRow).prices_json, "prices_json", rowNumber, localErrors) ??
864
+ {};
865
+ const salePrices =
866
+ preserveField("sale_prices_json")
867
+ ? undefined
868
+ : (row as BackupProductRecord).sale_prices ??
869
+ parseJsonField<Record<string, number | null>>((row as CsvLikeRow).sale_prices_json, "sale_prices_json", rowNumber, localErrors) ??
870
+ {};
871
+ const descriptionJson =
872
+ preserveField("description_json")
873
+ ? undefined
874
+ : (row as BackupProductRecord).description_json ??
875
+ parseJsonField<Json>((row as CsvLikeRow).description_json, "description_json", rowNumber, localErrors);
876
+ const replaceVariants =
877
+ !preserveBlanks ||
878
+ rowHasImportValue(row, "variants_json") ||
879
+ Array.isArray((row as BackupProductRecord).variants);
880
+ const rawVariants =
881
+ replaceVariants
882
+ ? (row as BackupProductRecord).variants ??
883
+ parseJsonField<BackupProductVariantRecord[]>((row as CsvLikeRow).variants_json, "variants_json", rowNumber, localErrors) ??
884
+ []
885
+ : [];
886
+
887
+ for (const error of localErrors) {
888
+ addMessage(summary.errors, error.row, error.message);
889
+ }
890
+ if (localErrors.length > 0) continue;
891
+ if (replaceVariants && !Array.isArray(rawVariants)) {
892
+ addMessage(summary.errors, rowNumber, "variants_json must be an array.");
893
+ continue;
894
+ }
895
+
896
+ const syncCategories =
897
+ !preserveBlanks ||
898
+ rowHasImportValue(row, "category_slugs") ||
899
+ Array.isArray((row as BackupProductRecord).category_slugs);
900
+ const categorySlugs = syncCategories
901
+ ? Array.isArray((row as BackupProductRecord).category_slugs)
902
+ ? ((row as BackupProductRecord).category_slugs || [])
903
+ : splitList((row as CsvLikeRow).category_slugs)
904
+ : [];
905
+ const categoryIds: string[] = [];
906
+ for (const categorySlug of categorySlugs) {
907
+ const categoryId = categoryMap.get(categorySlug);
908
+ if (!categoryId) {
909
+ addMessage(summary.errors, rowNumber, `Category slug "${categorySlug}" does not exist.`);
910
+ } else {
911
+ categoryIds.push(categoryId);
912
+ }
913
+ }
914
+ if (summary.errors.some((error) => error.row === rowNumber)) continue;
915
+
916
+ const replaceMedia =
917
+ !preserveBlanks ||
918
+ rowHasImportValue(row, "media_ids") ||
919
+ rowHasImportValue(row, "media_object_keys") ||
920
+ Array.isArray((row as BackupProductRecord).media_ids) ||
921
+ Array.isArray((row as BackupProductRecord).media_object_keys);
922
+ const explicitMediaIds = replaceMedia
923
+ ? Array.isArray((row as BackupProductRecord).media_ids)
924
+ ? ((row as BackupProductRecord).media_ids || [])
925
+ : splitList((row as CsvLikeRow).media_ids)
926
+ : [];
927
+ const mediaObjectKeys = replaceMedia
928
+ ? Array.isArray((row as BackupProductRecord).media_object_keys)
929
+ ? ((row as BackupProductRecord).media_object_keys || [])
930
+ : splitList((row as CsvLikeRow).media_object_keys)
931
+ : [];
932
+ const mediaIds = new Set<string>();
933
+ for (const mediaId of explicitMediaIds) {
934
+ const resolved = resolveMediaId({
935
+ id: mediaId,
936
+ rowNumber,
937
+ fieldLabel: "Product media",
938
+ mediaById: mediaMaps.byId,
939
+ mediaByObjectKey: mediaMaps.byObjectKey,
940
+ warnings: summary.warnings,
941
+ });
942
+ if (resolved) mediaIds.add(resolved);
943
+ }
944
+ for (const objectKey of mediaObjectKeys) {
945
+ const resolved = resolveMediaId({
946
+ objectKey,
947
+ rowNumber,
948
+ fieldLabel: "Product media",
949
+ mediaById: mediaMaps.byId,
950
+ mediaByObjectKey: mediaMaps.byObjectKey,
951
+ warnings: summary.warnings,
952
+ });
953
+ if (resolved) mediaIds.add(resolved);
954
+ }
955
+
956
+ const variants = rawVariants.map((variant) => {
957
+ const mainMediaId = resolveMediaId({
958
+ id: variant.main_media_id,
959
+ objectKey: variant.main_media_object_key,
960
+ rowNumber,
961
+ fieldLabel: `Variant media for ${variant.sku}`,
962
+ mediaById: mediaMaps.byId,
963
+ mediaByObjectKey: mediaMaps.byObjectKey,
964
+ warnings: summary.warnings,
965
+ });
966
+
967
+ return {
968
+ ...variant,
969
+ main_media_id: mainMediaId,
970
+ prices: normalizePriceMapValue(variant.prices),
971
+ sale_prices: normalizeSalePriceMapValue(variant.sale_prices),
972
+ attribute_term_ids: Array.isArray(variant.attribute_term_ids) ? variant.attribute_term_ids : [],
973
+ };
974
+ });
975
+
976
+ const importedGroupId = normalizeId((row as any).translation_group_id);
977
+ const translationGroupId =
978
+ action === "update"
979
+ ? importedGroupId || target?.translation_group_id || uuidv4()
980
+ : importedGroupId
981
+ ? translationGroupMap.get(importedGroupId) ?? translationGroupMap.set(importedGroupId, uuidv4()).get(importedGroupId)
982
+ : uuidv4();
983
+
984
+ const replaceBlocks =
985
+ !preserveBlanks ||
986
+ rowHasImportValue(row, "description_blocks_json") ||
987
+ rowHasImportValue(row, "description_html") ||
988
+ Array.isArray((row as BackupProductRecord).description_blocks);
989
+ const blocks = replaceBlocks
990
+ ? parseBlocksForProduct(row, rowNumber, languageId, summary.errors)
991
+ : [];
992
+ const meta: Record<string, unknown> = {};
993
+ setMetaValue(meta, "language_id", languageId, !preserveField("language_code"));
994
+ setMetaValue(
995
+ meta,
996
+ "translation_group_id",
997
+ translationGroupId,
998
+ !preserveField("translation_group_id")
999
+ );
1000
+ setMetaValue(meta, "product_type", productType, updatesProductType);
1001
+ setMetaValue(meta, "payment_provider", paymentProvider, updatesPaymentProvider);
1002
+ setMetaValue(meta, "title", title, !preserveField("title"));
1003
+ setMetaValue(meta, "slug", slug, shouldUpdateSlug);
1004
+ setMetaValue(meta, "sku", sku, shouldUpdateSku);
1005
+ setMetaValue(meta, "upc", normalizeNullableString((row as any).upc), !preserveField("upc"));
1006
+ setMetaValue(meta, "status", status, !preserveField("status"));
1007
+ setMetaValue(meta, "price", price, !preserveField("price"));
1008
+ setMetaValue(meta, "prices", prices, !preserveField("prices_json"));
1009
+ setMetaValue(
1010
+ meta,
1011
+ "sale_price",
1012
+ parseNullableNumber(String((row as any).sale_price ?? "")),
1013
+ !preserveField("sale_price")
1014
+ );
1015
+ setMetaValue(meta, "sale_prices", salePrices, !preserveField("sale_prices_json"));
1016
+ setMetaValue(meta, "stock", stock, !preserveField("stock"));
1017
+ setMetaValue(
1018
+ meta,
1019
+ "is_taxable",
1020
+ parseBoolean(String((row as any).is_taxable ?? ""), true),
1021
+ !preserveField("is_taxable")
1022
+ );
1023
+ setMetaValue(meta, "meta_title", normalizeNullableString((row as any).meta_title), !preserveField("meta_title"));
1024
+ setMetaValue(
1025
+ meta,
1026
+ "meta_description",
1027
+ normalizeNullableString((row as any).meta_description),
1028
+ !preserveField("meta_description")
1029
+ );
1030
+ setMetaValue(
1031
+ meta,
1032
+ "short_description",
1033
+ normalizeNullableString((row as any).short_description),
1034
+ !preserveField("short_description")
1035
+ );
1036
+ setMetaValue(
1037
+ meta,
1038
+ "description_json",
1039
+ descriptionJson ?? null,
1040
+ !preserveField("description_json")
1041
+ );
1042
+ setMetaValue(
1043
+ meta,
1044
+ "freemius_product_id",
1045
+ normalizeNullableString((row as any).freemius_product_id),
1046
+ !preserveField("freemius_product_id")
1047
+ );
1048
+ setMetaValue(
1049
+ meta,
1050
+ "freemius_plan_id",
1051
+ normalizeNullableString((row as any).freemius_plan_id),
1052
+ !preserveField("freemius_plan_id")
1053
+ );
1054
+ setMetaValue(
1055
+ meta,
1056
+ "trial_period_days",
1057
+ parseNumber(String((row as any).trial_period_days ?? ""), 0),
1058
+ !preserveField("trial_period_days")
1059
+ );
1060
+ setMetaValue(
1061
+ meta,
1062
+ "trial_requires_payment_method",
1063
+ parseBoolean(String((row as any).trial_requires_payment_method ?? ""), false),
1064
+ !preserveField("trial_requires_payment_method")
1065
+ );
1066
+ setMetaValue(meta, "product_media", Array.from(mediaIds).map((media_id) => ({ media_id })), replaceMedia);
1067
+ setMetaValue(meta, "category_ids", categoryIds, syncCategories);
1068
+ setMetaValue(meta, "variants", variants, replaceVariants);
1069
+
1070
+ prepared.push({
1071
+ contentType: "products",
1072
+ rowNumber,
1073
+ action,
1074
+ targetId: target?.id ?? null,
1075
+ meta,
1076
+ blocks,
1077
+ categoryIds,
1078
+ mediaIds: Array.from(mediaIds),
1079
+ variants,
1080
+ replaceBlocks,
1081
+ replaceMedia,
1082
+ replaceVariants,
1083
+ syncCategories,
1084
+ oldSlug: target?.slug ?? null,
1085
+ });
1086
+ }
1087
+
1088
+ createPreview(prepared, summary);
1089
+ summary.success = summary.errors.length === 0;
1090
+ return { summary, prepared };
1091
+ }
1092
+
1093
+ function buildBlockPayload(block: BackupBlockRecord, parent: { type: CmsContentType; id: number | string }, index: number) {
1094
+ return {
1095
+ page_id: parent.type === "pages" ? parent.id : null,
1096
+ post_id: parent.type === "posts" ? parent.id : null,
1097
+ product_id: parent.type === "products" ? parent.id : null,
1098
+ language_id: block.language_id,
1099
+ block_type: block.block_type,
1100
+ content: block.content,
1101
+ order: typeof block.order === "number" && Number.isFinite(block.order) ? block.order : index,
1102
+ };
1103
+ }
1104
+
1105
+ async function replaceBlocks(
1106
+ supabase: SupabaseAny,
1107
+ contentType: CmsContentType,
1108
+ parentId: number | string,
1109
+ blocks: BackupBlockRecord[]
1110
+ ) {
1111
+ const column = contentType === "pages" ? "page_id" : contentType === "posts" ? "post_id" : "product_id";
1112
+ const { error: deleteError } = await supabase.from("blocks").delete().eq(column, parentId as any);
1113
+ if (deleteError) {
1114
+ throw new Error(`Failed to clear ${contentType} blocks: ${deleteError.message}`);
1115
+ }
1116
+
1117
+ if (blocks.length === 0) return;
1118
+
1119
+ const payload = blocks.map((block, index) => buildBlockPayload(block, { type: contentType, id: parentId }, index));
1120
+ const { error: insertError } = await supabase.from("blocks").insert(payload as any);
1121
+ if (insertError) {
1122
+ throw new Error(`Failed to insert ${contentType} blocks: ${insertError.message}`);
1123
+ }
1124
+ }
1125
+
1126
+ async function applyContentImport(params: {
1127
+ auth: TransferAuth;
1128
+ item: PreparedContentImport;
1129
+ options: CmsImportOptions;
1130
+ }) {
1131
+ const { auth, item, options } = params;
1132
+ const table = item.contentType === "pages" ? "pages" : "posts";
1133
+ const parentType = item.contentType === "pages" ? "page" : "post";
1134
+ let parentId = item.targetId;
1135
+ let previousContent: FullPageContent | FullPostContent | null = null;
1136
+
1137
+ if (options.applyMode === "live" && parentId) {
1138
+ previousContent =
1139
+ item.contentType === "pages"
1140
+ ? await getFullPageContent(parentId)
1141
+ : await getFullPostContent(parentId);
1142
+ }
1143
+
1144
+ if (!parentId) {
1145
+ const insertMeta = {
1146
+ ...item.meta,
1147
+ status: options.applyMode === "draft" ? "draft" : item.meta.status,
1148
+ author_id: auth.userId,
1149
+ };
1150
+ const { data, error } = await auth.supabase
1151
+ .from(table)
1152
+ .insert(insertMeta as any)
1153
+ .select("id")
1154
+ .single();
1155
+
1156
+ if (error || !data) {
1157
+ throw new Error(`Failed to create ${item.contentType.slice(0, -1)} from row ${item.rowNumber}: ${error?.message ?? "unknown error"}`);
1158
+ }
1159
+
1160
+ parentId = Number((data as any).id);
1161
+ }
1162
+
1163
+ if (options.applyMode === "draft") {
1164
+ const { error } = await (auth.supabase as any)
1165
+ .from("content_drafts")
1166
+ .upsert(
1167
+ {
1168
+ parent_type: parentType,
1169
+ parent_id: parentId,
1170
+ author_id: auth.userId,
1171
+ meta: item.meta,
1172
+ blocks: item.blocks,
1173
+ updated_at: new Date().toISOString(),
1174
+ },
1175
+ { onConflict: "parent_type,parent_id" }
1176
+ );
1177
+
1178
+ if (error) {
1179
+ throw new Error(`Failed to save draft from row ${item.rowNumber}: ${error.message}`);
1180
+ }
1181
+ } else {
1182
+ if (Object.keys(item.meta).length > 0) {
1183
+ const { error } = await auth.supabase
1184
+ .from(table)
1185
+ .update(item.meta as any)
1186
+ .eq("id", parentId);
1187
+
1188
+ if (error) {
1189
+ throw new Error(`Failed to update ${item.contentType.slice(0, -1)} from row ${item.rowNumber}: ${error.message}`);
1190
+ }
1191
+ }
1192
+
1193
+ if (item.replaceBlocks) {
1194
+ await replaceBlocks(auth.supabase, item.contentType, parentId, item.blocks);
1195
+ }
1196
+ await (auth.supabase as any)
1197
+ .from("content_drafts")
1198
+ .delete()
1199
+ .eq("parent_type", parentType)
1200
+ .eq("parent_id", parentId);
1201
+
1202
+ if (previousContent) {
1203
+ const nextContent =
1204
+ item.contentType === "pages"
1205
+ ? await getFullPageContent(parentId)
1206
+ : await getFullPostContent(parentId);
1207
+ if (nextContent) {
1208
+ if (item.contentType === "pages") {
1209
+ await createPageRevision(parentId, auth.userId, previousContent as FullPageContent, nextContent as FullPageContent);
1210
+ } else {
1211
+ await createPostRevision(parentId, auth.userId, previousContent as FullPostContent, nextContent as FullPostContent);
1212
+ }
1213
+ }
1214
+ }
1215
+ }
1216
+
1217
+ revalidatePath(item.contentType === "pages" ? "/cms/pages" : "/cms/posts");
1218
+ const slug = typeof item.meta.slug === "string" ? item.meta.slug : null;
1219
+ if (item.contentType === "pages") {
1220
+ if (slug) revalidatePath(`/${slug}`);
1221
+ if (item.oldSlug && item.oldSlug !== slug) revalidatePath(`/${item.oldSlug}`);
1222
+ } else {
1223
+ revalidatePath("/articles");
1224
+ if (slug) revalidatePath(`/article/${slug}`);
1225
+ if (item.oldSlug && item.oldSlug !== slug) revalidatePath(`/article/${item.oldSlug}`);
1226
+ }
1227
+ }
1228
+
1229
+ function toLiveProductPayload(meta: Record<string, unknown>) {
1230
+ const payload: Record<string, unknown> = {
1231
+ updated_at: new Date().toISOString(),
1232
+ };
1233
+ const hasKey = (key: string) => Object.prototype.hasOwnProperty.call(meta, key);
1234
+ const copy = (key: string) => {
1235
+ if (hasKey(key)) payload[key] = meta[key];
1236
+ };
1237
+
1238
+ copy("language_id");
1239
+ copy("translation_group_id");
1240
+ copy("product_type");
1241
+ copy("payment_provider");
1242
+ copy("title");
1243
+ copy("slug");
1244
+ copy("sku");
1245
+ copy("upc");
1246
+ copy("status");
1247
+ if (hasKey("price")) payload.price = majorToMinor(meta.price as number) ?? 0;
1248
+ if (hasKey("prices")) payload.prices = priceMapMajorToMinor(meta.prices as Record<string, number>);
1249
+ if (hasKey("sale_price")) payload.sale_price = majorToMinor(meta.sale_price as number | null);
1250
+ if (hasKey("sale_prices")) {
1251
+ payload.sale_prices = salePriceMapMajorToMinor(meta.sale_prices as Record<string, number | null>);
1252
+ }
1253
+ copy("stock");
1254
+ copy("is_taxable");
1255
+ copy("meta_title");
1256
+ copy("meta_description");
1257
+ copy("short_description");
1258
+ copy("description_json");
1259
+ copy("freemius_product_id");
1260
+ copy("freemius_plan_id");
1261
+ copy("trial_period_days");
1262
+ copy("trial_requires_payment_method");
1263
+
1264
+ return payload;
1265
+ }
1266
+
1267
+ async function replaceProductMedia(supabase: SupabaseAny, productId: string, mediaIds: string[]) {
1268
+ const { error: deleteError } = await supabase
1269
+ .from("product_media")
1270
+ .delete()
1271
+ .eq("product_id", productId);
1272
+ if (deleteError) {
1273
+ throw new Error(`Failed to clear product media: ${deleteError.message}`);
1274
+ }
1275
+
1276
+ if (mediaIds.length === 0) return;
1277
+
1278
+ const { error } = await supabase.from("product_media").insert(
1279
+ mediaIds.map((mediaId, index) => ({
1280
+ product_id: productId,
1281
+ media_id: mediaId,
1282
+ sort_order: index,
1283
+ })) as any
1284
+ );
1285
+ if (error) {
1286
+ throw new Error(`Failed to insert product media: ${error.message}`);
1287
+ }
1288
+ }
1289
+
1290
+ async function replaceProductVariants(
1291
+ supabase: SupabaseAny,
1292
+ productId: string,
1293
+ variants: BackupProductVariantRecord[]
1294
+ ) {
1295
+ const { error: deleteError } = await supabase
1296
+ .from("product_variants")
1297
+ .delete()
1298
+ .eq("product_id", productId);
1299
+ if (deleteError) {
1300
+ throw new Error(`Failed to clear product variants: ${deleteError.message}`);
1301
+ }
1302
+
1303
+ for (const variant of variants) {
1304
+ const { data, error } = await supabase
1305
+ .from("product_variants")
1306
+ .insert({
1307
+ product_id: productId,
1308
+ sku: variant.sku,
1309
+ upc: variant.upc ?? null,
1310
+ price: majorToMinor(variant.price) ?? 0,
1311
+ prices: priceMapMajorToMinor(variant.prices),
1312
+ sale_price: majorToMinor(variant.sale_price ?? null),
1313
+ sale_prices: salePriceMapMajorToMinor(variant.sale_prices),
1314
+ stock_quantity: variant.stock_quantity ?? 0,
1315
+ main_media_id: variant.main_media_id ?? null,
1316
+ } as any)
1317
+ .select("id")
1318
+ .single();
1319
+
1320
+ if (error || !data) {
1321
+ throw new Error(`Failed to insert product variant "${variant.sku}": ${error?.message ?? "unknown error"}`);
1322
+ }
1323
+
1324
+ const termIds = Array.isArray(variant.attribute_term_ids) ? variant.attribute_term_ids : [];
1325
+ if (termIds.length > 0) {
1326
+ const { error: mappingError } = await supabase
1327
+ .from("variant_attribute_mapping")
1328
+ .insert(
1329
+ termIds.map((termId) => ({
1330
+ variant_id: (data as any).id,
1331
+ attribute_term_id: termId,
1332
+ })) as any
1333
+ );
1334
+
1335
+ if (mappingError) {
1336
+ throw new Error(`Failed to insert variant attributes for "${variant.sku}": ${mappingError.message}`);
1337
+ }
1338
+ }
1339
+ }
1340
+ }
1341
+
1342
+ async function applyProductImport(params: {
1343
+ auth: TransferAuth;
1344
+ item: PreparedProductImport;
1345
+ options: CmsImportOptions;
1346
+ }) {
1347
+ const { auth, item, options } = params;
1348
+ let productId = item.targetId;
1349
+
1350
+ if (!productId) {
1351
+ const payload = {
1352
+ ...toLiveProductPayload(item.meta),
1353
+ status: options.applyMode === "draft" ? "draft" : item.meta.status,
1354
+ metadata: {},
1355
+ created_at: new Date().toISOString(),
1356
+ };
1357
+ const { data, error } = await auth.supabase
1358
+ .from("products")
1359
+ .insert(payload as any)
1360
+ .select("id")
1361
+ .single();
1362
+
1363
+ if (error || !data) {
1364
+ throw new Error(`Failed to create product from row ${item.rowNumber}: ${error?.message ?? "unknown error"}`);
1365
+ }
1366
+
1367
+ productId = String((data as any).id);
1368
+ }
1369
+
1370
+ if (options.applyMode === "draft") {
1371
+ const { error } = await (auth.supabase as any)
1372
+ .from("product_drafts")
1373
+ .upsert(
1374
+ {
1375
+ product_id: productId,
1376
+ author_id: auth.userId,
1377
+ meta: item.meta,
1378
+ blocks: item.blocks,
1379
+ updated_at: new Date().toISOString(),
1380
+ },
1381
+ { onConflict: "product_id" }
1382
+ );
1383
+
1384
+ if (error) {
1385
+ throw new Error(`Failed to save product draft from row ${item.rowNumber}: ${error.message}`);
1386
+ }
1387
+ } else {
1388
+ const { error } = await auth.supabase
1389
+ .from("products")
1390
+ .update(toLiveProductPayload(item.meta) as any)
1391
+ .eq("id", productId);
1392
+
1393
+ if (error) {
1394
+ throw new Error(`Failed to update product from row ${item.rowNumber}: ${error.message}`);
1395
+ }
1396
+
1397
+ if (item.replaceBlocks) {
1398
+ await replaceBlocks(auth.supabase, "products", productId, item.blocks);
1399
+ }
1400
+ if (item.replaceMedia) {
1401
+ await replaceProductMedia(auth.supabase, productId, item.mediaIds);
1402
+ }
1403
+ if (item.replaceVariants) {
1404
+ await replaceProductVariants(auth.supabase, productId, item.variants);
1405
+ }
1406
+ if (item.syncCategories) {
1407
+ await syncCategoriesForTranslationGroup(auth.supabase as any, productId, item.categoryIds);
1408
+ }
1409
+ await (auth.supabase as any).from("product_drafts").delete().eq("product_id", productId);
1410
+ }
1411
+
1412
+ revalidatePath("/cms/products");
1413
+ const slug = typeof item.meta.slug === "string" ? item.meta.slug : null;
1414
+ if (slug) revalidatePath(`/product/${slug}`);
1415
+ if (item.oldSlug && item.oldSlug !== slug) revalidatePath(`/product/${item.oldSlug}`);
1416
+ }
1417
+
1418
+ async function applyPreparedImports(
1419
+ auth: TransferAuth,
1420
+ prepared: PreparedImport[],
1421
+ options: CmsImportOptions
1422
+ ) {
1423
+ for (const item of prepared) {
1424
+ if (item.contentType === "products") {
1425
+ await applyProductImport({ auth, item, options });
1426
+ } else {
1427
+ await applyContentImport({ auth, item, options });
1428
+ }
1429
+ }
1430
+ }
1431
+
1432
+ async function prepareImports(params: {
1433
+ supabase: SupabaseAny;
1434
+ contentType: CmsContentType;
1435
+ rows: ImportSourceRow[];
1436
+ options: CmsImportOptions;
1437
+ }) {
1438
+ if (params.options.ignoreBlankFields) {
1439
+ const summary = emptySummary();
1440
+ summary.totalRows = params.rows.length;
1441
+ if (params.options.conflictMode !== "overwrite_existing") {
1442
+ addMessage(summary.errors, 0, "Ignore blank fields can only be used with overwrite imports.");
1443
+ }
1444
+ if (params.options.applyMode !== "live") {
1445
+ addMessage(summary.errors, 0, "Ignore blank fields can only be used with live imports.");
1446
+ }
1447
+ if (summary.errors.length > 0) {
1448
+ summary.success = false;
1449
+ return { summary, prepared: [] as PreparedImport[] };
1450
+ }
1451
+ }
1452
+
1453
+ if (params.contentType === "products") {
1454
+ if (params.rows.length === 0) {
1455
+ const summary = emptySummary();
1456
+ summary.totalRows = 0;
1457
+ return { summary, prepared: [] as PreparedImport[] };
1458
+ }
1459
+
1460
+ const ecommerceAvailable = await isEcommerceContentAvailable(params.supabase);
1461
+ if (!ecommerceAvailable) {
1462
+ const summary = emptySummary();
1463
+ summary.totalRows = params.rows.length;
1464
+ if (params.options.skipUnavailableProductContent) {
1465
+ summary.skipped = params.rows.length;
1466
+ addMessage(
1467
+ summary.warnings,
1468
+ 0,
1469
+ "Products were skipped because the ecommerce package is not active.",
1470
+ "warning"
1471
+ );
1472
+ } else {
1473
+ addMessage(summary.errors, 0, "The ecommerce package must be active to import products.");
1474
+ }
1475
+ summary.success = summary.errors.length === 0;
1476
+ return { summary, prepared: [] as PreparedImport[] };
1477
+ }
1478
+
1479
+ return prepareProductImports({
1480
+ supabase: params.supabase,
1481
+ rows: params.rows,
1482
+ options: params.options,
1483
+ });
1484
+ }
1485
+
1486
+ return prepareContentImports({
1487
+ supabase: params.supabase,
1488
+ contentType: params.contentType,
1489
+ rows: params.rows,
1490
+ options: params.options,
1491
+ });
1492
+ }
1493
+
1494
+ export function buildCsvTemplate(contentType: CmsContentType) {
1495
+ return getTemplateCsv(contentType);
1496
+ }
1497
+
1498
+ export async function dryRunCsvImport(params: {
1499
+ contentType: CmsContentType;
1500
+ csv: string;
1501
+ options: CmsImportOptions;
1502
+ }) {
1503
+ const auth = await requireTransferUser(false);
1504
+ const parsed = parseCsv(params.csv);
1505
+ const { summary } = await prepareImports({
1506
+ supabase: auth.supabase,
1507
+ contentType: params.contentType,
1508
+ rows: parsed.rows,
1509
+ options: params.options,
1510
+ });
1511
+
1512
+ for (const parseError of parsed.errors) {
1513
+ addMessage(summary.errors, parseError.row, parseError.message);
1514
+ }
1515
+ summary.success = summary.errors.length === 0;
1516
+ return summary;
1517
+ }
1518
+
1519
+ export async function applyCsvImport(params: {
1520
+ contentType: CmsContentType;
1521
+ csv: string;
1522
+ options: CmsImportOptions;
1523
+ }) {
1524
+ const auth = await requireTransferUser(false);
1525
+ const parsed = parseCsv(params.csv);
1526
+ const { summary, prepared } = await prepareImports({
1527
+ supabase: auth.supabase,
1528
+ contentType: params.contentType,
1529
+ rows: parsed.rows,
1530
+ options: params.options,
1531
+ });
1532
+
1533
+ for (const parseError of parsed.errors) {
1534
+ addMessage(summary.errors, parseError.row, parseError.message);
1535
+ }
1536
+ summary.success = summary.errors.length === 0;
1537
+ if (!summary.success) {
1538
+ return summary;
1539
+ }
1540
+
1541
+ await applyPreparedImports(auth, prepared, params.options);
1542
+ return summary;
1543
+ }
1544
+
1545
+ async function fetchBlocksByParent(supabase: SupabaseAny, parentColumn: "page_id" | "post_id" | "product_id", ids: Array<number | string>) {
1546
+ if (ids.length === 0) {
1547
+ return new Map<string, BackupBlockRecord[]>();
1548
+ }
1549
+
1550
+ const { data, error } = await supabase
1551
+ .from("blocks")
1552
+ .select("id, page_id, post_id, product_id, language_id, block_type, content, order")
1553
+ .in(parentColumn, ids as any)
1554
+ .order("order", { ascending: true });
1555
+
1556
+ if (error) {
1557
+ throw new Error(`Failed to load blocks: ${error.message}`);
1558
+ }
1559
+
1560
+ const map = new Map<string, BackupBlockRecord[]>();
1561
+ for (const block of data || []) {
1562
+ const parentId = String((block as any)[parentColumn]);
1563
+ const list = map.get(parentId) ?? [];
1564
+ list.push({
1565
+ language_id: (block as any).language_id,
1566
+ block_type: (block as any).block_type,
1567
+ content: (block as any).content,
1568
+ order: (block as any).order,
1569
+ });
1570
+ map.set(parentId, list);
1571
+ }
1572
+
1573
+ return map;
1574
+ }
1575
+
1576
+ async function exportPages(supabase: SupabaseAny, languageId?: number) {
1577
+ let query = supabase
1578
+ .from("pages")
1579
+ .select("*, languages(code), media(object_key)")
1580
+ .order("created_at", { ascending: false });
1581
+
1582
+ if (languageId) query = query.eq("language_id", languageId);
1583
+
1584
+ const { data, error } = await query;
1585
+ if (error) throw new Error(`Failed to export pages: ${error.message}`);
1586
+
1587
+ const pages = data || [];
1588
+ const blockMap = await fetchBlocksByParent(
1589
+ supabase,
1590
+ "page_id",
1591
+ pages.map((page: any) => page.id)
1592
+ );
1593
+
1594
+ return pages.map((page: any): BackupPageRecord => ({
1595
+ id: page.id,
1596
+ translation_group_id: page.translation_group_id,
1597
+ language_code: page.languages?.code || "",
1598
+ title: page.title,
1599
+ slug: page.slug,
1600
+ status: page.status,
1601
+ meta_title: page.meta_title,
1602
+ meta_description: page.meta_description,
1603
+ feature_image_id: page.feature_image_id,
1604
+ feature_image_object_key: page.media?.object_key ?? null,
1605
+ blocks: blockMap.get(String(page.id)) || [],
1606
+ }));
1607
+ }
1608
+
1609
+ async function exportPosts(supabase: SupabaseAny, languageId?: number) {
1610
+ let query = supabase
1611
+ .from("posts")
1612
+ .select("*, languages(code), media(object_key)")
1613
+ .order("created_at", { ascending: false });
1614
+
1615
+ if (languageId) query = query.eq("language_id", languageId);
1616
+
1617
+ const { data, error } = await query;
1618
+ if (error) throw new Error(`Failed to export posts: ${error.message}`);
1619
+
1620
+ const posts = data || [];
1621
+ const blockMap = await fetchBlocksByParent(
1622
+ supabase,
1623
+ "post_id",
1624
+ posts.map((post: any) => post.id)
1625
+ );
1626
+
1627
+ return posts.map((post: any): BackupPostRecord => ({
1628
+ id: post.id,
1629
+ translation_group_id: post.translation_group_id,
1630
+ language_code: post.languages?.code || "",
1631
+ title: post.title,
1632
+ slug: post.slug,
1633
+ status: post.status,
1634
+ meta_title: post.meta_title,
1635
+ meta_description: post.meta_description,
1636
+ feature_image_id: post.feature_image_id,
1637
+ feature_image_object_key: post.media?.object_key ?? null,
1638
+ label: post.label,
1639
+ excerpt: post.excerpt,
1640
+ subtitle: post.subtitle,
1641
+ published_at: post.published_at,
1642
+ blocks: blockMap.get(String(post.id)) || [],
1643
+ }));
1644
+ }
1645
+
1646
+ async function exportProducts(supabase: SupabaseAny, languageId?: number) {
1647
+ const ecommerceAvailable = await isEcommerceContentAvailable(supabase);
1648
+ if (!ecommerceAvailable) {
1649
+ return [];
1650
+ }
1651
+
1652
+ let query = supabase
1653
+ .from("products")
1654
+ .select("*, languages(code)")
1655
+ .order("created_at", { ascending: false });
1656
+
1657
+ if (languageId) query = query.eq("language_id", languageId);
1658
+
1659
+ const { data, error } = await query;
1660
+ if (error) throw new Error(`Failed to export products: ${error.message}`);
1661
+
1662
+ const products = data || [];
1663
+ if (products.length === 0) {
1664
+ return [];
1665
+ }
1666
+
1667
+ const productIds = products.map((product: any) => String(product.id));
1668
+ const blockMap = await fetchBlocksByParent(supabase, "product_id", productIds);
1669
+
1670
+ const [mediaResult, categoryResult, variantResult] = await Promise.all([
1671
+ supabase
1672
+ .from("product_media")
1673
+ .select("product_id, media_id, sort_order, media(object_key)")
1674
+ .in("product_id", productIds),
1675
+ supabase
1676
+ .from("product_categories" as any)
1677
+ .select("product_id, category:categories(slug)")
1678
+ .in("product_id", productIds),
1679
+ supabase
1680
+ .from("product_variants")
1681
+ .select("id, product_id, sku, upc, price, prices, sale_price, sale_prices, stock_quantity, main_media_id, media:main_media_id(object_key), variant_attribute_mapping(attribute_term_id)")
1682
+ .in("product_id", productIds),
1683
+ ]);
1684
+
1685
+ if (mediaResult.error) throw new Error(`Failed to export product media: ${mediaResult.error.message}`);
1686
+ if (categoryResult.error) throw new Error(`Failed to export product categories: ${categoryResult.error.message}`);
1687
+ if (variantResult.error) throw new Error(`Failed to export product variants: ${variantResult.error.message}`);
1688
+
1689
+ const mediaMap = new Map<string, Array<{ id: string; objectKey: string | null; sortOrder: number }>>();
1690
+ for (const media of mediaResult.data || []) {
1691
+ const productId = String((media as any).product_id);
1692
+ const list = mediaMap.get(productId) ?? [];
1693
+ list.push({
1694
+ id: String((media as any).media_id),
1695
+ objectKey: (media as any).media?.object_key ?? null,
1696
+ sortOrder: Number((media as any).sort_order ?? 0),
1697
+ });
1698
+ mediaMap.set(productId, list);
1699
+ }
1700
+
1701
+ const categoryMap = new Map<string, string[]>();
1702
+ for (const categoryRow of categoryResult.data || []) {
1703
+ const productId = String((categoryRow as any).product_id);
1704
+ const list = categoryMap.get(productId) ?? [];
1705
+ if ((categoryRow as any).category?.slug) list.push((categoryRow as any).category.slug);
1706
+ categoryMap.set(productId, list);
1707
+ }
1708
+
1709
+ const variantMap = new Map<string, BackupProductVariantRecord[]>();
1710
+ for (const variant of variantResult.data || []) {
1711
+ const productId = String((variant as any).product_id);
1712
+ const list = variantMap.get(productId) ?? [];
1713
+ list.push({
1714
+ id: (variant as any).id,
1715
+ sku: (variant as any).sku,
1716
+ upc: (variant as any).upc,
1717
+ price: minorToMajor((variant as any).price) ?? 0,
1718
+ prices: priceMapMinorToMajor((variant as any).prices),
1719
+ sale_price: minorToMajor((variant as any).sale_price),
1720
+ sale_prices: salePriceMapMinorToMajor((variant as any).sale_prices),
1721
+ stock_quantity: Number((variant as any).stock_quantity ?? 0),
1722
+ main_media_id: (variant as any).main_media_id,
1723
+ main_media_object_key: (variant as any).media?.object_key ?? null,
1724
+ attribute_term_ids: ((variant as any).variant_attribute_mapping || [])
1725
+ .map((mapping: any) => mapping.attribute_term_id)
1726
+ .filter(Boolean),
1727
+ });
1728
+ variantMap.set(productId, list);
1729
+ }
1730
+
1731
+ return products.map((product: any): BackupProductRecord => {
1732
+ const media = (mediaMap.get(String(product.id)) || []).sort((a, b) => a.sortOrder - b.sortOrder);
1733
+ return {
1734
+ id: product.id,
1735
+ translation_group_id: product.translation_group_id,
1736
+ language_code: product.languages?.code || "",
1737
+ product_type: product.product_type,
1738
+ payment_provider: product.payment_provider,
1739
+ title: product.title,
1740
+ slug: product.slug,
1741
+ sku: product.sku,
1742
+ upc: product.upc,
1743
+ status: product.status,
1744
+ price: minorToMajor(product.price) ?? 0,
1745
+ prices: priceMapMinorToMajor(product.prices),
1746
+ sale_price: minorToMajor(product.sale_price),
1747
+ sale_prices: salePriceMapMinorToMajor(product.sale_prices),
1748
+ stock: Number(product.stock ?? 0),
1749
+ is_taxable: Boolean(product.is_taxable),
1750
+ meta_title: product.meta_title,
1751
+ meta_description: product.meta_description,
1752
+ short_description: product.short_description,
1753
+ description_json: product.description_json,
1754
+ description_blocks: blockMap.get(String(product.id)) || [],
1755
+ category_slugs: categoryMap.get(String(product.id)) || [],
1756
+ media_ids: media.map((item) => item.id),
1757
+ media_object_keys: media.map((item) => item.objectKey).filter(Boolean) as string[],
1758
+ variants: variantMap.get(String(product.id)) || [],
1759
+ freemius_product_id: product.freemius_product_id,
1760
+ freemius_plan_id: product.freemius_plan_id,
1761
+ trial_period_days: Number(product.trial_period_days ?? 0),
1762
+ trial_requires_payment_method: Boolean(product.trial_requires_payment_method),
1763
+ };
1764
+ });
1765
+ }
1766
+
1767
+ function pageToCsvRow(page: BackupPageRecord) {
1768
+ const firstText = page.blocks.find((block) => block.block_type === "text" && isRecord(block.content));
1769
+ return {
1770
+ ...page,
1771
+ content_html: isRecord(firstText?.content) ? String(firstText?.content.html_content ?? "") : "",
1772
+ blocks_json: toJsonCell(page.blocks),
1773
+ };
1774
+ }
1775
+
1776
+ function postToCsvRow(post: BackupPostRecord) {
1777
+ return {
1778
+ ...pageToCsvRow(post),
1779
+ label: post.label ?? "",
1780
+ excerpt: post.excerpt ?? "",
1781
+ subtitle: post.subtitle ?? "",
1782
+ published_at: post.published_at ?? "",
1783
+ };
1784
+ }
1785
+
1786
+ function productToCsvRow(product: BackupProductRecord) {
1787
+ const firstText = product.description_blocks.find((block) => block.block_type === "text" && isRecord(block.content));
1788
+ return {
1789
+ id: product.id ?? "",
1790
+ translation_group_id: product.translation_group_id ?? "",
1791
+ language_code: product.language_code,
1792
+ product_type: product.product_type,
1793
+ payment_provider: product.payment_provider,
1794
+ title: product.title,
1795
+ slug: product.slug,
1796
+ sku: product.sku,
1797
+ upc: product.upc ?? "",
1798
+ status: product.status,
1799
+ price: product.price,
1800
+ prices_json: toJsonCell(product.prices),
1801
+ sale_price: product.sale_price ?? "",
1802
+ sale_prices_json: toJsonCell(product.sale_prices),
1803
+ stock: product.stock,
1804
+ is_taxable: String(product.is_taxable),
1805
+ meta_title: product.meta_title ?? "",
1806
+ meta_description: product.meta_description ?? "",
1807
+ short_description: product.short_description ?? "",
1808
+ description_html: isRecord(firstText?.content) ? String(firstText?.content.html_content ?? "") : "",
1809
+ description_blocks_json: toJsonCell(product.description_blocks),
1810
+ description_json: toJsonCell(product.description_json),
1811
+ category_slugs: (product.category_slugs || []).join(";"),
1812
+ media_ids: (product.media_ids || []).join(";"),
1813
+ media_object_keys: (product.media_object_keys || []).join(";"),
1814
+ variants_json: toJsonCell(product.variants),
1815
+ freemius_product_id: product.freemius_product_id ?? "",
1816
+ freemius_plan_id: product.freemius_plan_id ?? "",
1817
+ trial_period_days: product.trial_period_days ?? 0,
1818
+ trial_requires_payment_method: String(Boolean(product.trial_requires_payment_method)),
1819
+ };
1820
+ }
1821
+
1822
+ export async function exportCsv(params: {
1823
+ contentType: CmsContentType;
1824
+ languageId?: number;
1825
+ }) {
1826
+ const auth = await requireTransferUser(false);
1827
+ const records =
1828
+ params.contentType === "pages"
1829
+ ? await exportPages(auth.supabase, params.languageId)
1830
+ : params.contentType === "posts"
1831
+ ? await exportPosts(auth.supabase, params.languageId)
1832
+ : await exportProducts(auth.supabase, params.languageId);
1833
+
1834
+ const rows =
1835
+ params.contentType === "pages"
1836
+ ? (records as BackupPageRecord[]).map(pageToCsvRow)
1837
+ : params.contentType === "posts"
1838
+ ? (records as BackupPostRecord[]).map(postToCsvRow)
1839
+ : (records as BackupProductRecord[]).map(productToCsvRow);
1840
+
1841
+ return stringifyCsv(rows, getCsvHeaders(params.contentType));
1842
+ }
1843
+
1844
+ export async function exportBackupBundle() {
1845
+ const auth = await requireTransferUser(true);
1846
+ const { languages } = await loadLanguages(auth.supabase);
1847
+ const bundle: CmsBackupBundleV1 = {
1848
+ version: 1,
1849
+ exported_at: new Date().toISOString(),
1850
+ languages,
1851
+ content: {
1852
+ pages: await exportPages(auth.supabase),
1853
+ posts: await exportPosts(auth.supabase),
1854
+ products: await exportProducts(auth.supabase),
1855
+ },
1856
+ custom_blocks: await exportCustomBlockRecords(auth.supabase),
1857
+ };
1858
+
1859
+ return JSON.stringify(bundle, null, 2);
1860
+ }
1861
+
1862
+ function parseBundle(content: string): CmsBackupBundleV1 {
1863
+ const parsed = JSON.parse(content) as CmsBackupBundleV1;
1864
+ if (!parsed || parsed.version !== 1 || !isRecord(parsed.content)) {
1865
+ throw new Error("Backup bundle must be a NextBlock content backup with version 1.");
1866
+ }
1867
+
1868
+ return parsed;
1869
+ }
1870
+
1871
+ export async function dryRunBundleImport(params: {
1872
+ bundleJson: string;
1873
+ contentTypes: CmsContentType[];
1874
+ options: CmsImportOptions;
1875
+ includeBlocks?: boolean;
1876
+ }) {
1877
+ const auth = await requireTransferUser(true);
1878
+ const bundle = parseBundle(params.bundleJson);
1879
+ const summary = emptySummary();
1880
+
1881
+ for (const contentType of params.contentTypes) {
1882
+ const rows = bundle.content[contentType] || [];
1883
+ const result = await prepareImports({
1884
+ supabase: auth.supabase,
1885
+ contentType,
1886
+ rows: rows as ImportSourceRow[],
1887
+ options: { ...params.options, skipUnavailableProductContent: true },
1888
+ });
1889
+
1890
+ mergeBundleSummary(summary, result.summary);
1891
+ }
1892
+
1893
+ if (params.includeBlocks) {
1894
+ const blockResult = await prepareBlocksLibraryImport({
1895
+ supabase: auth.supabase,
1896
+ records: Array.isArray(bundle.custom_blocks) ? bundle.custom_blocks : [],
1897
+ options: { conflictMode: params.options.conflictMode },
1898
+ });
1899
+ mergeBundleSummary(summary, blockResult.summary);
1900
+ }
1901
+
1902
+ summary.success = summary.errors.length === 0;
1903
+ return summary;
1904
+ }
1905
+
1906
+ export async function applyBundleImport(params: {
1907
+ bundleJson: string;
1908
+ contentTypes: CmsContentType[];
1909
+ options: CmsImportOptions;
1910
+ includeBlocks?: boolean;
1911
+ }) {
1912
+ const auth = await requireTransferUser(true);
1913
+ const bundle = parseBundle(params.bundleJson);
1914
+ const summary = emptySummary();
1915
+ const allPrepared: PreparedImport[] = [];
1916
+ let preparedBlocks: PreparedBlockImport[] = [];
1917
+
1918
+ for (const contentType of params.contentTypes) {
1919
+ const rows = bundle.content[contentType] || [];
1920
+ const result = await prepareImports({
1921
+ supabase: auth.supabase,
1922
+ contentType,
1923
+ rows: rows as ImportSourceRow[],
1924
+ options: { ...params.options, skipUnavailableProductContent: true },
1925
+ });
1926
+
1927
+ mergeBundleSummary(summary, result.summary);
1928
+ allPrepared.push(...(result.prepared as PreparedImport[]));
1929
+ }
1930
+
1931
+ if (params.includeBlocks) {
1932
+ const blockResult = await prepareBlocksLibraryImport({
1933
+ supabase: auth.supabase,
1934
+ records: Array.isArray(bundle.custom_blocks) ? bundle.custom_blocks : [],
1935
+ options: { conflictMode: params.options.conflictMode },
1936
+ });
1937
+ mergeBundleSummary(summary, blockResult.summary);
1938
+ preparedBlocks = blockResult.prepared;
1939
+ }
1940
+
1941
+ summary.success = summary.errors.length === 0;
1942
+ if (!summary.success) {
1943
+ return summary;
1944
+ }
1945
+
1946
+ await applyPreparedImports(auth, allPrepared, params.options);
1947
+ if (preparedBlocks.length > 0) {
1948
+ await applyPreparedBlockImports(auth.supabase, preparedBlocks);
1949
+ }
1950
+ return summary;
1951
+ }
1952
+
1953
+ function mergeBundleSummary(target: CmsImportSummary, source: CmsImportSummary) {
1954
+ target.totalRows += source.totalRows;
1955
+ target.created += source.created;
1956
+ target.updated += source.updated;
1957
+ target.skipped += source.skipped;
1958
+ target.errors.push(...source.errors);
1959
+ target.warnings.push(...source.warnings);
1960
+ target.preview.push(...source.preview);
1961
+ }
1962
+
1963
+ // ---------------------------------------------------------------------------
1964
+ // Blocks Library (custom block definitions) transfer
1965
+ //
1966
+ // Custom blocks are nested JSON (fields + layout_schema), so they use the same
1967
+ // JSON bundle approach as the content backup rather than CSV. The Blocks Library
1968
+ // page exports/imports a dedicated bundle, and the content backup embeds the
1969
+ // same records under `custom_blocks`.
1970
+ // ---------------------------------------------------------------------------
1971
+
1972
+ export const BLOCKS_LIBRARY_BUNDLE_TYPE = "nextblock-blocks-library-backup";
1973
+ export const BLOCKS_LIBRARY_BUNDLE_VERSION = 1;
1974
+
1975
+ const CUSTOM_BLOCK_DEFINITION_SELECT =
1976
+ "id, slug, name, description, fields, layout_schema, is_original";
1977
+
1978
+ export interface BlocksLibraryImportOptions {
1979
+ conflictMode: CmsImportConflictMode;
1980
+ }
1981
+
1982
+ interface PreparedBlockImport {
1983
+ rowNumber: number;
1984
+ action: "create" | "update";
1985
+ targetId: string | null;
1986
+ slug: string;
1987
+ payload: {
1988
+ slug: string;
1989
+ name: string;
1990
+ description: string;
1991
+ fields: Json;
1992
+ layout_schema: Json;
1993
+ is_original: boolean;
1994
+ };
1995
+ }
1996
+
1997
+ function toJson(value: unknown): Json {
1998
+ return JSON.parse(JSON.stringify(value)) as Json;
1999
+ }
2000
+
2001
+ async function exportCustomBlockRecords(supabase: SupabaseAny): Promise<BackupCustomBlockRecord[]> {
2002
+ const { data, error } = await supabase
2003
+ .from("custom_block_definitions")
2004
+ .select(CUSTOM_BLOCK_DEFINITION_SELECT)
2005
+ .order("name", { ascending: true });
2006
+
2007
+ if (error) {
2008
+ throw new Error(`Failed to export custom block definitions: ${error.message}`);
2009
+ }
2010
+
2011
+ return (data || []).map((row: any) => ({
2012
+ slug: row.slug,
2013
+ name: row.name,
2014
+ description: typeof row.description === "string" ? row.description : "",
2015
+ fields: row.fields,
2016
+ layout_schema: row.layout_schema,
2017
+ is_original: Boolean(row.is_original),
2018
+ }));
2019
+ }
2020
+
2021
+ function extractBlocksLibraryRecords(bundleJson: string): BackupCustomBlockRecord[] {
2022
+ let parsed: unknown;
2023
+ try {
2024
+ parsed = JSON.parse(bundleJson);
2025
+ } catch {
2026
+ throw new Error("Blocks library file is not valid JSON.");
2027
+ }
2028
+
2029
+ if (!isRecord(parsed)) {
2030
+ throw new Error("Blocks library file must be a JSON object.");
2031
+ }
2032
+
2033
+ // Accept a dedicated blocks-library bundle (`blocks`) or a full content
2034
+ // backup bundle that carries the same records under `custom_blocks`.
2035
+ const raw = Array.isArray((parsed as any).blocks)
2036
+ ? (parsed as any).blocks
2037
+ : Array.isArray((parsed as any).custom_blocks)
2038
+ ? (parsed as any).custom_blocks
2039
+ : null;
2040
+
2041
+ if (!raw) {
2042
+ throw new Error('Blocks library file is missing its "blocks" array.');
2043
+ }
2044
+
2045
+ return raw as BackupCustomBlockRecord[];
2046
+ }
2047
+
2048
+ async function prepareBlocksLibraryImport(params: {
2049
+ supabase: SupabaseAny;
2050
+ records: BackupCustomBlockRecord[];
2051
+ options: BlocksLibraryImportOptions;
2052
+ }): Promise<{ summary: CmsImportSummary; prepared: PreparedBlockImport[] }> {
2053
+ const summary = emptySummary();
2054
+ summary.totalRows = params.records.length;
2055
+ const prepared: PreparedBlockImport[] = [];
2056
+
2057
+ const { data, error } = await params.supabase
2058
+ .from("custom_block_definitions")
2059
+ .select("id, slug");
2060
+ if (error) {
2061
+ addMessage(summary.errors, 0, `Failed to load existing custom blocks: ${error.message}`);
2062
+ summary.success = false;
2063
+ return { summary, prepared };
2064
+ }
2065
+
2066
+ const existingBySlug = new Map<string, string>();
2067
+ const knownSlugs = new Set<string>();
2068
+ for (const row of data || []) {
2069
+ existingBySlug.set(String((row as any).slug), String((row as any).id));
2070
+ knownSlugs.add(String((row as any).slug));
2071
+ }
2072
+ const seenSlugs = new Set<string>();
2073
+
2074
+ for (const [index, record] of params.records.entries()) {
2075
+ const rowNumber = index + 1;
2076
+ if (!isRecord(record)) {
2077
+ addMessage(summary.errors, rowNumber, "Block entry must be an object.");
2078
+ continue;
2079
+ }
2080
+
2081
+ const parsedDefinition = customBlockDefinitionCreateSchema.safeParse({
2082
+ description: record.description,
2083
+ fields: record.fields,
2084
+ is_original: record.is_original,
2085
+ layout_schema: record.layout_schema,
2086
+ name: record.name,
2087
+ slug: record.slug,
2088
+ });
2089
+
2090
+ const label =
2091
+ (typeof record.slug === "string" && record.slug) ||
2092
+ (typeof record.name === "string" && record.name) ||
2093
+ `entry ${rowNumber}`;
2094
+
2095
+ if (!parsedDefinition.success) {
2096
+ for (const issue of parsedDefinition.error.issues) {
2097
+ const path = issue.path.join(".");
2098
+ addMessage(
2099
+ summary.errors,
2100
+ rowNumber,
2101
+ `Block "${label}": ${path ? `${path}: ` : ""}${issue.message}`
2102
+ );
2103
+ }
2104
+ continue;
2105
+ }
2106
+
2107
+ const definition = parsedDefinition.data;
2108
+ const existingId = existingBySlug.get(definition.slug) ?? null;
2109
+
2110
+ let action: "create" | "update";
2111
+ let targetId: string | null;
2112
+ let slug = definition.slug;
2113
+ let isOriginal = definition.is_original;
2114
+
2115
+ if (params.options.conflictMode === "overwrite_existing" && existingId) {
2116
+ action = "update";
2117
+ targetId = existingId;
2118
+ } else {
2119
+ action = "create";
2120
+ targetId = null;
2121
+ // "Create new copies" always clones; "Overwrite" still needs a fresh slug
2122
+ // when it collides with an existing block or another row in this import.
2123
+ const mustRename =
2124
+ params.options.conflictMode === "create_new" ||
2125
+ knownSlugs.has(slug) ||
2126
+ seenSlugs.has(slug);
2127
+ if (mustRename) {
2128
+ slug = buildCustomBlockCopySlug(slug, new Set([...knownSlugs, ...seenSlugs]));
2129
+ isOriginal = false;
2130
+ }
2131
+ }
2132
+
2133
+ seenSlugs.add(slug);
2134
+ knownSlugs.add(slug);
2135
+
2136
+ prepared.push({
2137
+ rowNumber,
2138
+ action,
2139
+ targetId,
2140
+ slug,
2141
+ payload: {
2142
+ slug,
2143
+ name: definition.name,
2144
+ description: definition.description,
2145
+ fields: toJson(definition.fields),
2146
+ layout_schema: toJson(definition.layout_schema),
2147
+ is_original: isOriginal,
2148
+ },
2149
+ });
2150
+ }
2151
+
2152
+ summary.created = prepared.filter((item) => item.action === "create").length;
2153
+ summary.updated = prepared.filter((item) => item.action === "update").length;
2154
+ summary.success = summary.errors.length === 0;
2155
+ return { summary, prepared };
2156
+ }
2157
+
2158
+ async function applyPreparedBlockImports(
2159
+ supabase: SupabaseAny,
2160
+ prepared: PreparedBlockImport[]
2161
+ ): Promise<void> {
2162
+ if (prepared.length === 0) return;
2163
+ const touched: Array<{ id: string; slug: string }> = [];
2164
+
2165
+ for (const item of prepared) {
2166
+ const writer =
2167
+ item.action === "update" && item.targetId
2168
+ ? supabase
2169
+ .from("custom_block_definitions")
2170
+ .update(item.payload as any)
2171
+ .eq("id", item.targetId)
2172
+ .select("id, slug")
2173
+ .single()
2174
+ : supabase
2175
+ .from("custom_block_definitions")
2176
+ .insert(item.payload as any)
2177
+ .select("id, slug")
2178
+ .single();
2179
+
2180
+ const { data, error } = await writer;
2181
+ if (error || !data) {
2182
+ throw new Error(
2183
+ `Failed to ${item.action} custom block "${item.slug}": ${error?.message ?? "unknown error"}`
2184
+ );
2185
+ }
2186
+ touched.push({ id: String((data as any).id), slug: String((data as any).slug) });
2187
+ }
2188
+
2189
+ revalidateTag(CUSTOM_BLOCK_DEFINITIONS_CACHE_TAG, "max");
2190
+ for (const item of touched) {
2191
+ revalidateTag(getCustomBlockDefinitionCacheTag(item.id), "max");
2192
+ revalidateTag(getCustomBlockDefinitionCacheTag(item.slug), "max");
2193
+ }
2194
+ revalidatePath("/cms/blocks");
2195
+ revalidatePath("/cms/custom-blocks");
2196
+ }
2197
+
2198
+ export async function exportBlocksLibraryBundle() {
2199
+ const auth = await requireTransferUser(false);
2200
+ const blocks = await exportCustomBlockRecords(auth.supabase);
2201
+ return JSON.stringify(
2202
+ {
2203
+ type: BLOCKS_LIBRARY_BUNDLE_TYPE,
2204
+ version: BLOCKS_LIBRARY_BUNDLE_VERSION,
2205
+ exported_at: new Date().toISOString(),
2206
+ blocks,
2207
+ },
2208
+ null,
2209
+ 2
2210
+ );
2211
+ }
2212
+
2213
+ export async function dryRunBlocksLibraryImport(params: {
2214
+ bundleJson: string;
2215
+ options: BlocksLibraryImportOptions;
2216
+ }) {
2217
+ const auth = await requireTransferUser(false);
2218
+ const records = extractBlocksLibraryRecords(params.bundleJson);
2219
+ const { summary } = await prepareBlocksLibraryImport({
2220
+ supabase: auth.supabase,
2221
+ records,
2222
+ options: params.options,
2223
+ });
2224
+ return summary;
2225
+ }
2226
+
2227
+ export async function applyBlocksLibraryImport(params: {
2228
+ bundleJson: string;
2229
+ options: BlocksLibraryImportOptions;
2230
+ }) {
2231
+ const auth = await requireTransferUser(false);
2232
+ const records = extractBlocksLibraryRecords(params.bundleJson);
2233
+ const { summary, prepared } = await prepareBlocksLibraryImport({
2234
+ supabase: auth.supabase,
2235
+ records,
2236
+ options: params.options,
2237
+ });
2238
+ if (!summary.success) {
2239
+ return summary;
2240
+ }
2241
+ await applyPreparedBlockImports(auth.supabase, prepared);
2242
+ return summary;
2243
+ }