create-nextblock 0.2.78 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (413) hide show
  1. package/bin/create-nextblock.js +740 -459
  2. package/package.json +1 -2
  3. package/scripts/sync-template.js +18 -1
  4. package/templates/nextblock-template/.browserslistrc +11 -0
  5. package/templates/nextblock-template/.swcrc +30 -30
  6. package/templates/nextblock-template/README.md +23 -114
  7. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -28
  8. package/templates/nextblock-template/app/(auth-pages)/sign-in/page.tsx +50 -25
  9. package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +111 -56
  10. package/templates/nextblock-template/app/(auth-pages)/two-factor/actions.ts +91 -0
  11. package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +118 -0
  12. package/templates/nextblock-template/app/(auth-pages)/two-factor/page.tsx +51 -0
  13. package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -0
  14. package/templates/nextblock-template/app/[slug]/PageClientContent.tsx +48 -28
  15. package/templates/nextblock-template/app/[slug]/page.tsx +63 -6
  16. package/templates/nextblock-template/app/[slug]/page.utils.ts +374 -157
  17. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -0
  18. package/templates/nextblock-template/app/actions/consent.ts +57 -0
  19. package/templates/nextblock-template/app/actions/formActions.ts +130 -11
  20. package/templates/nextblock-template/app/actions/languageActions.ts +31 -30
  21. package/templates/nextblock-template/app/actions/package-actions.ts +183 -0
  22. package/templates/nextblock-template/app/actions/postActions.ts +146 -48
  23. package/templates/nextblock-template/app/actions/twoFactorEmail.ts +21 -0
  24. package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +179 -0
  25. package/templates/nextblock-template/app/actions/visualEditingActions.ts +345 -0
  26. package/templates/nextblock-template/app/actions.ts +67 -12
  27. package/templates/nextblock-template/app/api/ai/cortex/build-widget/route.ts +153 -0
  28. package/templates/nextblock-template/app/api/ai/generate-blocks/route.ts +96 -0
  29. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +965 -0
  30. package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -0
  31. package/templates/nextblock-template/app/api/checkout/route.ts +146 -0
  32. package/templates/nextblock-template/app/api/cms/full-backup/export/route.ts +33 -0
  33. package/templates/nextblock-template/app/api/cms/full-backup/restore/route.ts +63 -0
  34. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +3413 -17
  35. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +7830 -0
  36. package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +35 -0
  37. package/templates/nextblock-template/app/api/custom-blocks/db-relations/route.ts +92 -0
  38. package/templates/nextblock-template/app/api/custom-blocks/editor-definitions/route.ts +43 -0
  39. package/templates/nextblock-template/app/api/draft/disable/route.ts +25 -0
  40. package/templates/nextblock-template/app/api/draft/route.ts +93 -0
  41. package/templates/nextblock-template/app/api/draft/start/route.ts +77 -0
  42. package/templates/nextblock-template/app/api/media/library/route.ts +65 -0
  43. package/templates/nextblock-template/app/api/media/r2-presigned/route.ts +53 -0
  44. package/templates/nextblock-template/app/api/media/record/route.ts +160 -0
  45. package/templates/nextblock-template/app/api/search/route.ts +43 -0
  46. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -0
  47. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -0
  48. package/templates/nextblock-template/app/api/webhooks/freemius/route.ts +34 -0
  49. package/templates/nextblock-template/app/api/webhooks/stripe/route.ts +27 -0
  50. package/templates/nextblock-template/app/article/[slug]/PostClientContent.tsx +392 -128
  51. package/templates/nextblock-template/app/article/[slug]/page.tsx +179 -127
  52. package/templates/nextblock-template/app/article/[slug]/page.utils.ts +262 -77
  53. package/templates/nextblock-template/app/auth/callback/route.ts +31 -58
  54. package/templates/nextblock-template/app/cart/page.tsx +7 -0
  55. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -0
  56. package/templates/nextblock-template/app/checkout/page.tsx +52 -0
  57. package/templates/nextblock-template/app/checkout/success/actions.ts +136 -0
  58. package/templates/nextblock-template/app/checkout/success/page.tsx +186 -0
  59. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +163 -33
  60. package/templates/nextblock-template/app/cms/blocks/actions.ts +424 -235
  61. package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +212 -151
  62. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +41 -20
  63. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +152 -19
  64. package/templates/nextblock-template/app/cms/blocks/components/BlockTypeCard.tsx +25 -17
  65. package/templates/nextblock-template/app/cms/blocks/components/BlockTypeSelector.tsx +200 -18
  66. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +33 -16
  67. package/templates/nextblock-template/app/cms/blocks/components/CustomBlockEditorPreview.tsx +160 -0
  68. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +37 -18
  69. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -67
  70. package/templates/nextblock-template/app/cms/blocks/components/SectionConfigPanel.tsx +108 -31
  71. package/templates/nextblock-template/app/cms/blocks/editors/DynamicCustomBlockEditor.tsx +167 -0
  72. package/templates/nextblock-template/app/cms/blocks/editors/FeaturedProductBlockEditor.tsx +31 -0
  73. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +2 -2
  74. package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +1 -1
  75. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +29 -29
  76. package/templates/nextblock-template/app/cms/blocks/editors/PostsGridBlockEditor.tsx +14 -18
  77. package/templates/nextblock-template/app/cms/blocks/editors/ProductGridBlockEditor.tsx +41 -0
  78. package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +318 -118
  79. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +98 -21
  80. package/templates/nextblock-template/app/cms/blocks/editors/VideoEmbedBlockEditor.tsx +1 -1
  81. package/templates/nextblock-template/app/cms/components/ContentLanguageSwitcher.tsx +27 -9
  82. package/templates/nextblock-template/app/cms/components/CopyContentFromLanguage.tsx +1 -1
  83. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -0
  84. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -0
  85. package/templates/nextblock-template/app/cms/components/CortexGlobalAgentChat.tsx +1507 -0
  86. package/templates/nextblock-template/app/cms/components/DraftStatusActions.tsx +145 -0
  87. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +244 -0
  88. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +38 -24
  89. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -0
  90. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -0
  91. package/templates/nextblock-template/app/cms/custom-blocks/[id]/edit/page.tsx +66 -0
  92. package/templates/nextblock-template/app/cms/custom-blocks/actions.ts +519 -0
  93. package/templates/nextblock-template/app/cms/custom-blocks/components/BlockComposer.tsx +1522 -0
  94. package/templates/nextblock-template/app/cms/custom-blocks/components/BlocksLibraryTransferControls.tsx +256 -0
  95. package/templates/nextblock-template/app/cms/custom-blocks/components/DBRelationSelect.tsx +384 -0
  96. package/templates/nextblock-template/app/cms/custom-blocks/components/ImageR2Picker.tsx +221 -0
  97. package/templates/nextblock-template/app/cms/custom-blocks/new/page.tsx +12 -0
  98. package/templates/nextblock-template/app/cms/custom-blocks/page.tsx +438 -0
  99. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -98
  100. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -0
  101. package/templates/nextblock-template/app/cms/dashboard/page.tsx +182 -154
  102. package/templates/nextblock-template/app/cms/import-export/ContentTransferControls.tsx +391 -0
  103. package/templates/nextblock-template/app/cms/import-export/actions.ts +226 -0
  104. package/templates/nextblock-template/app/cms/layout.tsx +29 -10
  105. package/templates/nextblock-template/app/cms/media/UploadFolderContext.tsx +22 -22
  106. package/templates/nextblock-template/app/cms/media/actions.ts +45 -124
  107. package/templates/nextblock-template/app/cms/media/components/DeleteMediaButtonClient.tsx +1 -1
  108. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +26 -26
  109. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -64
  110. package/templates/nextblock-template/app/cms/media/components/MediaPickerDialog.tsx +227 -158
  111. package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +101 -89
  112. package/templates/nextblock-template/app/cms/media/page.tsx +1 -1
  113. package/templates/nextblock-template/app/cms/navigation/components/NavigationItemForm.tsx +2 -2
  114. package/templates/nextblock-template/app/cms/orders/[id]/MarkPaidButton.tsx +44 -0
  115. package/templates/nextblock-template/app/cms/orders/[id]/page.tsx +16 -0
  116. package/templates/nextblock-template/app/cms/orders/actions.ts +201 -0
  117. package/templates/nextblock-template/app/cms/orders/page.tsx +20 -0
  118. package/templates/nextblock-template/app/cms/orders/types.ts +20 -0
  119. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +156 -121
  120. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +79 -26
  121. package/templates/nextblock-template/app/cms/pages/actions.ts +54 -38
  122. package/templates/nextblock-template/app/cms/pages/components/DeletePageButtonClient.tsx +1 -1
  123. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +267 -116
  124. package/templates/nextblock-template/app/cms/pages/page.tsx +25 -18
  125. package/templates/nextblock-template/app/cms/payments/page.tsx +16 -0
  126. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +132 -90
  127. package/templates/nextblock-template/app/cms/posts/actions.ts +71 -72
  128. package/templates/nextblock-template/app/cms/posts/components/DeletePostButtonClient.tsx +1 -1
  129. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +256 -245
  130. package/templates/nextblock-template/app/cms/posts/new/page.tsx +1 -1
  131. package/templates/nextblock-template/app/cms/posts/page.tsx +20 -13
  132. package/templates/nextblock-template/app/cms/products/ClientNotionEditor.tsx +16 -0
  133. package/templates/nextblock-template/app/cms/products/ProductFormClientShell.tsx +56 -0
  134. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +292 -0
  135. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -0
  136. package/templates/nextblock-template/app/cms/products/categories/page.tsx +12 -0
  137. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -0
  138. package/templates/nextblock-template/app/cms/products/new/page.tsx +143 -0
  139. package/templates/nextblock-template/app/cms/products/page.tsx +42 -0
  140. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -0
  141. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -0
  142. package/templates/nextblock-template/app/cms/promotions/PromotionsWorkspace.tsx +456 -0
  143. package/templates/nextblock-template/app/cms/promotions/actions.ts +115 -0
  144. package/templates/nextblock-template/app/cms/promotions/page.tsx +31 -0
  145. package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +2 -2
  146. package/templates/nextblock-template/app/cms/revisions/actions.ts +285 -285
  147. package/templates/nextblock-template/app/cms/revisions/service.ts +19 -16
  148. package/templates/nextblock-template/app/cms/revisions/utils.ts +8 -3
  149. package/templates/nextblock-template/app/cms/settings/backup-restore/BackupRestoreWorkspace.tsx +1004 -0
  150. package/templates/nextblock-template/app/cms/settings/backup-restore/page.tsx +29 -0
  151. package/templates/nextblock-template/app/cms/settings/bot-protection/actions.ts +93 -0
  152. package/templates/nextblock-template/app/cms/settings/bot-protection/components/BotProtectionForm.tsx +129 -0
  153. package/templates/nextblock-template/app/cms/settings/bot-protection/page.tsx +24 -0
  154. package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +1 -1
  155. package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +2 -2
  156. package/templates/nextblock-template/app/cms/settings/copyright/page.tsx +1 -1
  157. package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +496 -0
  158. package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +410 -0
  159. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +248 -0
  160. package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +80 -0
  161. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -0
  162. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -0
  163. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -0
  164. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +203 -44
  165. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -242
  166. package/templates/nextblock-template/app/cms/settings/global-css/actions.ts +65 -0
  167. package/templates/nextblock-template/app/cms/settings/global-css/components/GlobalCssForm.tsx +46 -0
  168. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +24 -0
  169. package/templates/nextblock-template/app/cms/settings/languages/components/DeleteLanguageButton.tsx +1 -1
  170. package/templates/nextblock-template/app/cms/settings/languages/components/LanguageForm.tsx +2 -2
  171. package/templates/nextblock-template/app/cms/settings/languages/page.tsx +1 -1
  172. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  173. package/templates/nextblock-template/app/cms/settings/logos/actions.ts +82 -6
  174. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -0
  175. package/templates/nextblock-template/app/cms/settings/logos/components/DeleteLogoButton.tsx +21 -18
  176. package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +20 -16
  177. package/templates/nextblock-template/app/cms/settings/logos/components/SiteSeoSettingsForm.tsx +133 -0
  178. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  179. package/templates/nextblock-template/app/cms/settings/logos/page.tsx +120 -82
  180. package/templates/nextblock-template/app/cms/settings/logos/types.ts +8 -8
  181. package/templates/nextblock-template/app/cms/settings/packages/activation-form.tsx +84 -0
  182. package/templates/nextblock-template/app/cms/settings/packages/package-card.tsx +122 -0
  183. package/templates/nextblock-template/app/cms/settings/packages/page.tsx +49 -0
  184. package/templates/nextblock-template/app/cms/settings/privacy/actions.ts +53 -0
  185. package/templates/nextblock-template/app/cms/settings/privacy/components/PrivacyForm.tsx +196 -0
  186. package/templates/nextblock-template/app/cms/settings/privacy/page.tsx +26 -0
  187. package/templates/nextblock-template/app/cms/settings/security/actions.ts +251 -0
  188. package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +453 -0
  189. package/templates/nextblock-template/app/cms/settings/security/page.tsx +13 -0
  190. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -0
  191. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -0
  192. package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +28 -23
  193. package/templates/nextblock-template/app/cms/users/actions.ts +105 -40
  194. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +1 -1
  195. package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +65 -152
  196. package/templates/nextblock-template/app/cms/users/page.tsx +15 -10
  197. package/templates/nextblock-template/app/globals.css +9 -0
  198. package/templates/nextblock-template/app/layout.tsx +372 -120
  199. package/templates/nextblock-template/app/lib/seo.test.ts +52 -0
  200. package/templates/nextblock-template/app/lib/seo.ts +279 -0
  201. package/templates/nextblock-template/app/lib/site-settings.ts +87 -0
  202. package/templates/nextblock-template/app/lib/sitemap-utils.ts +224 -39
  203. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -0
  204. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -0
  205. package/templates/nextblock-template/app/lib/ucp/server.ts +1914 -0
  206. package/templates/nextblock-template/app/page.tsx +165 -73
  207. package/templates/nextblock-template/app/product/[slug]/page.tsx +433 -0
  208. package/templates/nextblock-template/app/profile/ProfileAccountSidebar.tsx +73 -0
  209. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -0
  210. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -0
  211. package/templates/nextblock-template/app/profile/account-data.ts +37 -0
  212. package/templates/nextblock-template/app/profile/account-links.ts +22 -0
  213. package/templates/nextblock-template/app/profile/account-types.ts +11 -0
  214. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -0
  215. package/templates/nextblock-template/app/profile/orders/[id]/CustomerOrderDetailPageClient.tsx +79 -0
  216. package/templates/nextblock-template/app/profile/orders/[id]/page.tsx +32 -0
  217. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -0
  218. package/templates/nextblock-template/app/profile/page.tsx +51 -0
  219. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -0
  220. package/templates/nextblock-template/app/profile/password/actions.ts +59 -0
  221. package/templates/nextblock-template/app/profile/password/page.tsx +27 -0
  222. package/templates/nextblock-template/app/providers.tsx +55 -17
  223. package/templates/nextblock-template/app/robots.txt/route.ts +11 -1
  224. package/templates/nextblock-template/app/sitemap.ts +128 -0
  225. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -0
  226. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -0
  227. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -0
  228. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -0
  229. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -0
  230. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -0
  231. package/templates/nextblock-template/components/AppShell.tsx +154 -0
  232. package/templates/nextblock-template/components/BlockRenderer.tsx +210 -64
  233. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -0
  234. package/templates/nextblock-template/components/CartTranslator.tsx +210 -0
  235. package/templates/nextblock-template/components/CurrentContentSetter.tsx +25 -0
  236. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -0
  237. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -0
  238. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -0
  239. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -0
  240. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -0
  241. package/templates/nextblock-template/components/FeatureImageHero.tsx +47 -0
  242. package/templates/nextblock-template/components/GitHubLoginButton.tsx +36 -0
  243. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -0
  244. package/templates/nextblock-template/components/Header.tsx +49 -41
  245. package/templates/nextblock-template/components/LanguageSwitcher.tsx +55 -32
  246. package/templates/nextblock-template/components/ResponsiveNav.tsx +138 -43
  247. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -8
  248. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -55
  249. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +42 -37
  250. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +6 -2
  251. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -0
  252. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -0
  253. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -84
  254. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +17 -0
  255. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +19 -0
  256. package/templates/nextblock-template/components/blocks/renderers/ClientTextBlockRenderer.tsx +262 -8
  257. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +22 -0
  258. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +320 -37
  259. package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +11 -8
  260. package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +12 -3
  261. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +18 -13
  262. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +90 -0
  263. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +31 -0
  264. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +424 -55
  265. package/templates/nextblock-template/components/blocks/renderers/SectionSlider.tsx +137 -0
  266. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -0
  267. package/templates/nextblock-template/components/blocks/renderers/TextBlockRenderer.tsx +37 -22
  268. package/templates/nextblock-template/components/blocks/renderers/VideoEmbedBlockRenderer.tsx +23 -15
  269. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +1 -3
  270. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +1 -3
  271. package/templates/nextblock-template/components/blocks/types.ts +7 -6
  272. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  273. package/templates/nextblock-template/components/form-message.tsx +32 -26
  274. package/templates/nextblock-template/components/header-auth.tsx +69 -17
  275. package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +127 -0
  276. package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +59 -0
  277. package/templates/nextblock-template/components/renderers/CachedDynamicLayoutEngine.tsx +28 -0
  278. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.test.tsx +166 -0
  279. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.tsx +464 -0
  280. package/templates/nextblock-template/components/theme-switcher.tsx +8 -8
  281. package/templates/nextblock-template/components/visual-editing/DeferredVisualEditing.tsx +21 -0
  282. package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +1172 -0
  283. package/templates/nextblock-template/context/AuthContext.tsx +23 -90
  284. package/templates/nextblock-template/context/CurrentContentContext.tsx +10 -4
  285. package/templates/nextblock-template/context/LanguageContext.tsx +16 -16
  286. package/templates/nextblock-template/context/language-rest-client.ts +31 -0
  287. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -0
  288. package/templates/nextblock-template/docs/02-ECOMMERCE-CAPABILITIES.md +364 -0
  289. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +202 -0
  290. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +252 -0
  291. package/templates/nextblock-template/docs/05-DEVELOPER-GUIDE.md +238 -0
  292. package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +125 -0
  293. package/templates/nextblock-template/docs/07-BLOCK-SDK-AND-EXTENSIBILITY.md +146 -0
  294. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +1319 -0
  295. package/templates/nextblock-template/docs/09-LIVE-DRAFT-MODE.md +104 -0
  296. package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +222 -0
  297. package/templates/nextblock-template/docs/README.md +34 -0
  298. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12507 -0
  299. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -14
  300. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -0
  301. package/templates/nextblock-template/index.d.ts +2 -0
  302. package/templates/nextblock-template/lib/ai-block-generation.ts +339 -0
  303. package/templates/nextblock-template/lib/ai-client.ts +247 -0
  304. package/templates/nextblock-template/lib/ai-config.ts +81 -0
  305. package/templates/nextblock-template/lib/ai-cortex-widget-builder.ts +125 -0
  306. package/templates/nextblock-template/lib/ai-global-agent-custom-block-tools.ts +363 -0
  307. package/templates/nextblock-template/lib/ai-global-agent-db-tools.test.ts +405 -0
  308. package/templates/nextblock-template/lib/ai-global-agent-db-tools.ts +1228 -0
  309. package/templates/nextblock-template/lib/ai-global-agent-ecommerce.ts +5 -0
  310. package/templates/nextblock-template/lib/ai-global-agent-tools-stats.test.ts +223 -0
  311. package/templates/nextblock-template/lib/ai-global-agent-tools.test.ts +2183 -0
  312. package/templates/nextblock-template/lib/ai-global-agent-tools.ts +4807 -0
  313. package/templates/nextblock-template/lib/ai-key-crypto.test.ts +70 -0
  314. package/templates/nextblock-template/lib/ai-key-crypto.ts +132 -0
  315. package/templates/nextblock-template/lib/ai-model-catalog.test.ts +49 -0
  316. package/templates/nextblock-template/lib/ai-model-catalog.ts +41 -0
  317. package/templates/nextblock-template/lib/ai-model-registry.test.ts +231 -0
  318. package/templates/nextblock-template/lib/ai-model-registry.ts +522 -0
  319. package/templates/nextblock-template/lib/auth/cookies.ts +47 -0
  320. package/templates/nextblock-template/lib/auth/crypto.ts +42 -0
  321. package/templates/nextblock-template/lib/auth/trustedDevices.ts +92 -0
  322. package/templates/nextblock-template/lib/auth/twoFactor.ts +167 -0
  323. package/templates/nextblock-template/lib/auth-redirects.ts +46 -0
  324. package/templates/nextblock-template/lib/blocks/FeaturedProductBlock.tsx +94 -0
  325. package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +137 -0
  326. package/templates/nextblock-template/lib/blocks/README.md +13 -670
  327. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +138 -56
  328. package/templates/nextblock-template/lib/blocks/blockTypes.ts +18 -0
  329. package/templates/nextblock-template/lib/blocks/ecommerce-block-schemas.ts +31 -0
  330. package/templates/nextblock-template/lib/cms-transfer/csv.test.ts +77 -0
  331. package/templates/nextblock-template/lib/cms-transfer/csv.ts +399 -0
  332. package/templates/nextblock-template/lib/cms-transfer/server.ts +2243 -0
  333. package/templates/nextblock-template/lib/cms-transfer/types.ts +145 -0
  334. package/templates/nextblock-template/lib/cortex-widget-registry.test.ts +199 -0
  335. package/templates/nextblock-template/lib/cortex-widget-registry.ts +88 -0
  336. package/templates/nextblock-template/lib/cortex-widget-schema.test.tsx +237 -0
  337. package/templates/nextblock-template/lib/cortex-widget-schema.ts +393 -0
  338. package/templates/nextblock-template/lib/custom-block-definitions.ts +87 -0
  339. package/templates/nextblock-template/lib/custom-block-r2-upload-shared.ts +178 -0
  340. package/templates/nextblock-template/lib/custom-block-r2-upload.test.ts +140 -0
  341. package/templates/nextblock-template/lib/custom-block-r2-upload.ts +68 -0
  342. package/templates/nextblock-template/lib/custom-block-relation-registry.ts +256 -0
  343. package/templates/nextblock-template/lib/custom-block-relations.test.ts +227 -0
  344. package/templates/nextblock-template/lib/custom-block-relations.ts +279 -0
  345. package/templates/nextblock-template/lib/custom-block-safelist.ts +14 -0
  346. package/templates/nextblock-template/lib/editor/dynamic-extension-core.test.ts +172 -0
  347. package/templates/nextblock-template/lib/editor/dynamic-extension-core.ts +213 -0
  348. package/templates/nextblock-template/lib/editor/dynamic-extension-loader.ts +22 -0
  349. package/templates/nextblock-template/lib/editor/dynamic-extensions.tsx +193 -0
  350. package/templates/nextblock-template/lib/full-backup/manifest.test.ts +121 -0
  351. package/templates/nextblock-template/lib/full-backup/manifest.ts +206 -0
  352. package/templates/nextblock-template/lib/full-backup/server.ts +743 -0
  353. package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +45 -0
  354. package/templates/nextblock-template/lib/posts/readTime.ts +60 -0
  355. package/templates/nextblock-template/lib/privacy/consent-client.ts +57 -0
  356. package/templates/nextblock-template/lib/privacy/settings.ts +103 -0
  357. package/templates/nextblock-template/lib/privacy/types.ts +67 -0
  358. package/templates/nextblock-template/lib/promotions/server.test.ts +74 -0
  359. package/templates/nextblock-template/lib/promotions/server.ts +741 -0
  360. package/templates/nextblock-template/lib/resolve-block-relations.test.ts +142 -0
  361. package/templates/nextblock-template/lib/resolve-block-relations.ts +255 -0
  362. package/templates/nextblock-template/lib/search/server.ts +585 -0
  363. package/templates/nextblock-template/lib/search/types.ts +27 -0
  364. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -0
  365. package/templates/nextblock-template/lib/visual-editing/draft-content.ts +380 -0
  366. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -0
  367. package/templates/nextblock-template/lib/visual-editing/draft-route.ts +82 -0
  368. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -0
  369. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -0
  370. package/templates/nextblock-template/lib/visual-editing/mutations.ts +190 -0
  371. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -0
  372. package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +511 -0
  373. package/templates/nextblock-template/lib/visual-editing/types.ts +122 -0
  374. package/templates/nextblock-template/lib/zod-config.ts +5 -0
  375. package/templates/nextblock-template/next.config.js +190 -66
  376. package/templates/nextblock-template/package.json +34 -30
  377. package/templates/nextblock-template/proxy.ts +435 -253
  378. package/templates/nextblock-template/public/images/NBcover.webp +0 -0
  379. package/templates/nextblock-template/public/images/cap.webp +0 -0
  380. package/templates/nextblock-template/public/images/commerce-plan.webp +0 -0
  381. package/templates/nextblock-template/public/images/commerce-square.webp +0 -0
  382. package/templates/nextblock-template/public/images/commerce-wide.webp +0 -0
  383. package/templates/nextblock-template/public/images/cortex-ai-square.webp +0 -0
  384. package/templates/nextblock-template/public/images/cortex-ai.webp +0 -0
  385. package/templates/nextblock-template/public/images/extensibility.webp +0 -0
  386. package/templates/nextblock-template/public/images/goals.webp +0 -0
  387. package/templates/nextblock-template/public/images/included.webp +0 -0
  388. package/templates/nextblock-template/public/images/nx-graph.webp +0 -0
  389. package/templates/nextblock-template/public/images/pants.webp +0 -0
  390. package/templates/nextblock-template/public/images/t-shirt.webp +0 -0
  391. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -0
  392. package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +100 -0
  393. package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +62 -0
  394. package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +537 -0
  395. package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +58 -0
  396. package/templates/nextblock-template/scripts/verify-custom-block-definitions.ts +188 -0
  397. package/templates/nextblock-template/scripts/verify-dynamic-custom-block-extensions.ts +123 -0
  398. package/templates/nextblock-template/scripts/verify-dynamic-layout-engine.tsx +133 -0
  399. package/templates/nextblock-template/scripts/verify-milestone-2-custom-blocks.ts +65 -0
  400. package/templates/nextblock-template/tailwind.config.js +1 -0
  401. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -0
  402. package/templates/nextblock-template/tools/deploy-supabase.js +69 -71
  403. package/templates/nextblock-template/tsconfig.json +52 -66
  404. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
  405. package/templates/nextblock-template/types/jsdom.d.ts +6 -0
  406. package/templates/nextblock-template/app/force-styles.tsx +0 -31
  407. package/templates/nextblock-template/app/sitemap.xml/route.ts +0 -63
  408. package/templates/nextblock-template/components/blocks/renderers/HeroBlockRenderer.tsx +0 -273
  409. package/templates/nextblock-template/docs/How to Create a Custom Block.md +0 -149
  410. package/templates/nextblock-template/docs/cms-application-overview.md +0 -56
  411. package/templates/nextblock-template/docs/cms-architecture-overview.md +0 -73
  412. package/templates/nextblock-template/docs/files-structure.md +0 -426
  413. package/templates/nextblock-template/docs/tiptap-bundle-optimization-summary.md +0 -174
@@ -0,0 +1,1914 @@
1
+ import 'server-only';
2
+
3
+ import { getServiceRoleSupabaseClient, verifyPackageOnline } from '@nextblock-cms/db/server';
4
+ import {
5
+ getDefaultCurrency,
6
+ inferCurrencyCodeFromLocale,
7
+ normalizeCurrencyRecord,
8
+ normalizePriceMap,
9
+ normalizeSalePriceMap,
10
+ resolveEffectivePriceForCurrency,
11
+ type CurrencyRecord,
12
+ } from '@nextblock-cms/ecommerce/currency';
13
+ import { mapRawVariantRelations } from '@nextblock-cms/ecommerce/variation-utils';
14
+ import type { CartItem, ProductVariant } from '@nextblock-cms/ecommerce/types';
15
+ import { resolveMediaUrl } from '../../../lib/media/resolveMediaUrl';
16
+ import { resolveProductMetaDescription, stripHtmlToText } from '../seo';
17
+
18
+ export const UCP_VERSION = '2026-04-08';
19
+ export const UCP_REST_BASE_PATH = '/ucp/v1';
20
+ export const UCP_SHOPPING_SERVICE = 'dev.ucp.shopping';
21
+ export const UCP_CAPABILITIES = {
22
+ catalogSearch: 'dev.ucp.shopping.catalog.search',
23
+ catalogLookup: 'dev.ucp.shopping.catalog.lookup',
24
+ cart: 'dev.ucp.shopping.cart',
25
+ checkout: 'dev.ucp.shopping.checkout',
26
+ } as const;
27
+
28
+ const UCP_SPEC_ROOT = `https://ucp.dev/${UCP_VERSION}`;
29
+ const DEFAULT_PAGE_LIMIT = 10;
30
+ const MAX_PAGE_LIMIT = 50;
31
+ const MAX_LOOKUP_IDS = 50;
32
+
33
+ const PRODUCT_SELECT = `
34
+ *,
35
+ languages (
36
+ id,
37
+ code,
38
+ is_default
39
+ ),
40
+ product_media (
41
+ media_id,
42
+ sort_order,
43
+ media (
44
+ id,
45
+ file_path,
46
+ object_key,
47
+ file_name,
48
+ blur_data_url,
49
+ width,
50
+ height
51
+ )
52
+ ),
53
+ product_categories (
54
+ category:categories (
55
+ id,
56
+ name,
57
+ slug,
58
+ description,
59
+ name_translations,
60
+ description_translations
61
+ )
62
+ ),
63
+ product_variants (
64
+ id,
65
+ sku,
66
+ upc,
67
+ main_media_id,
68
+ price,
69
+ prices,
70
+ sale_price,
71
+ sale_prices,
72
+ sale_start_at,
73
+ sale_end_at,
74
+ scheduled_price,
75
+ scheduled_prices,
76
+ scheduled_price_at,
77
+ stock_quantity,
78
+ media:main_media_id (
79
+ id,
80
+ file_path,
81
+ object_key,
82
+ description
83
+ ),
84
+ variant_attribute_mapping (
85
+ attribute_term_id,
86
+ product_attribute_terms (
87
+ id,
88
+ attribute_id,
89
+ value,
90
+ slug,
91
+ sort_order,
92
+ value_translations,
93
+ product_attributes (
94
+ id,
95
+ name,
96
+ slug,
97
+ name_translations
98
+ )
99
+ )
100
+ )
101
+ ),
102
+ freemius_plans (
103
+ id,
104
+ name,
105
+ title,
106
+ freemius_pricing (
107
+ id,
108
+ license_quota,
109
+ api_monthly_price,
110
+ api_annual_price,
111
+ api_lifetime_price,
112
+ override_monthly_price,
113
+ override_annual_price,
114
+ override_lifetime_price,
115
+ is_active
116
+ )
117
+ )
118
+ `;
119
+
120
+ type JsonRecord = Record<string, unknown>;
121
+ type SupabaseAnyClient = any;
122
+
123
+ type UcpCapabilityName = (typeof UCP_CAPABILITIES)[keyof typeof UCP_CAPABILITIES];
124
+
125
+ interface PaginationInput {
126
+ limit: number;
127
+ offset: number;
128
+ }
129
+
130
+ interface ProductMapOptions {
131
+ baseUrl: string;
132
+ currencyCode: string;
133
+ currencies: CurrencyRecord[];
134
+ mode?: 'search' | 'lookup' | 'detail';
135
+ lookupInputs?: string[];
136
+ requestedVariantId?: string | null;
137
+ selected?: Array<{ name?: string; label?: string; value?: string }>;
138
+ }
139
+
140
+ interface CartBuildResult {
141
+ currencyCode: string;
142
+ locale: string | null;
143
+ context: JsonRecord;
144
+ signals: JsonRecord;
145
+ attribution: JsonRecord;
146
+ buyer: JsonRecord;
147
+ lineItems: JsonRecord[];
148
+ totals: Array<{ type: string; amount: number; currency: string; display_text?: string }>;
149
+ messages: JsonRecord[];
150
+ }
151
+
152
+ function asRecord(value: unknown): JsonRecord {
153
+ return value && typeof value === 'object' && !Array.isArray(value)
154
+ ? (value as JsonRecord)
155
+ : {};
156
+ }
157
+
158
+ function asString(value: unknown) {
159
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
160
+ }
161
+
162
+ function toInteger(value: unknown, fallback: number) {
163
+ const parsed =
164
+ typeof value === 'string'
165
+ ? Number.parseInt(value, 10)
166
+ : typeof value === 'number'
167
+ ? value
168
+ : Number.NaN;
169
+
170
+ return Number.isFinite(parsed) ? Math.round(parsed) : fallback;
171
+ }
172
+
173
+ function toMoneyAmount(value: unknown) {
174
+ const parsed =
175
+ typeof value === 'string'
176
+ ? Number.parseFloat(value)
177
+ : typeof value === 'number'
178
+ ? value
179
+ : Number.NaN;
180
+
181
+ return Number.isFinite(parsed) ? Math.max(0, Math.round(parsed)) : null;
182
+ }
183
+
184
+ function getOriginFromRequest(request: Request) {
185
+ const configuredUrl = process.env.NEXT_PUBLIC_URL?.replace(/\/+$/, '');
186
+ if (configuredUrl) {
187
+ return configuredUrl;
188
+ }
189
+
190
+ return new URL(request.url).origin;
191
+ }
192
+
193
+ function getUcpSupabaseClient(): SupabaseAnyClient {
194
+ return getServiceRoleSupabaseClient() as SupabaseAnyClient;
195
+ }
196
+
197
+ function getLanguageCode(product: any) {
198
+ const language = Array.isArray(product?.languages)
199
+ ? product.languages[0]
200
+ : product?.languages;
201
+
202
+ return typeof language?.code === 'string' ? language.code : null;
203
+ }
204
+
205
+ function normalizeTranslationMap(value: unknown) {
206
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
207
+ return null;
208
+ }
209
+
210
+ return Object.entries(value as Record<string, unknown>).reduce<Record<string, string>>(
211
+ (accumulator, [key, entry]) => {
212
+ if (typeof entry === 'string' && entry.trim()) {
213
+ accumulator[key] = entry.trim();
214
+ }
215
+
216
+ return accumulator;
217
+ },
218
+ {}
219
+ );
220
+ }
221
+
222
+ function resolveTranslatedCategoryText(
223
+ baseValue: unknown,
224
+ translations: unknown,
225
+ languageCode?: string | null
226
+ ) {
227
+ const fallback = typeof baseValue === 'string' ? baseValue.trim() : '';
228
+ const normalizedTranslations = normalizeTranslationMap(translations);
229
+
230
+ if (!languageCode || !normalizedTranslations) {
231
+ return fallback;
232
+ }
233
+
234
+ return normalizedTranslations[languageCode]?.trim() || fallback;
235
+ }
236
+
237
+ function getProductCategoryRecords(product: any) {
238
+ const categoryRows = Array.isArray(product?.product_categories)
239
+ ? product.product_categories
240
+ : [];
241
+
242
+ return categoryRows
243
+ .map((row: any) => row?.category)
244
+ .filter((category: any) => category?.id && category?.slug && category?.name);
245
+ }
246
+
247
+ function getProductCategories(product: any, languageCode?: string | null) {
248
+ return getProductCategoryRecords(product).map((category: any) => ({
249
+ id: category.id,
250
+ value: category.slug,
251
+ label: resolveTranslatedCategoryText(
252
+ category.name,
253
+ category.name_translations,
254
+ languageCode
255
+ ),
256
+ name: resolveTranslatedCategoryText(
257
+ category.name,
258
+ category.name_translations,
259
+ languageCode
260
+ ),
261
+ slug: category.slug,
262
+ description:
263
+ resolveTranslatedCategoryText(
264
+ category.description,
265
+ category.description_translations,
266
+ languageCode
267
+ ) || undefined,
268
+ }));
269
+ }
270
+
271
+ function encodeCursor(offset: number) {
272
+ return Buffer.from(JSON.stringify({ offset }), 'utf8').toString('base64url');
273
+ }
274
+
275
+ function decodeCursor(cursor: unknown) {
276
+ const value = asString(cursor);
277
+ if (!value) {
278
+ return null;
279
+ }
280
+
281
+ try {
282
+ const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
283
+ const offset = toInteger(asRecord(parsed).offset, -1);
284
+ return offset >= 0 ? offset : null;
285
+ } catch {
286
+ return null;
287
+ }
288
+ }
289
+
290
+ export function normalizeUcpPagination(body: unknown): PaginationInput {
291
+ const record = asRecord(body);
292
+ const pagination = asRecord(record.pagination);
293
+ const requestedLimit = toInteger(
294
+ pagination.limit ?? pagination.page_size ?? record.limit,
295
+ DEFAULT_PAGE_LIMIT
296
+ );
297
+ const limit = Math.min(Math.max(requestedLimit, 1), MAX_PAGE_LIMIT);
298
+ const cursorOffset = decodeCursor(
299
+ pagination.cursor ?? pagination.after ?? record.cursor ?? record.page_token
300
+ );
301
+ const offset = cursorOffset ?? Math.max(toInteger(pagination.offset ?? record.offset, 0), 0);
302
+
303
+ return { limit, offset };
304
+ }
305
+
306
+ export function buildPaginationResponse(params: {
307
+ limit: number;
308
+ offset: number;
309
+ count: number;
310
+ totalCount?: number | null;
311
+ }) {
312
+ const nextOffset = params.offset + params.count;
313
+ const hasNextPage =
314
+ typeof params.totalCount === 'number'
315
+ ? nextOffset < params.totalCount
316
+ : params.count === params.limit;
317
+
318
+ return {
319
+ cursor: hasNextPage ? encodeCursor(nextOffset) : null,
320
+ has_next_page: hasNextPage,
321
+ total_count: params.totalCount ?? undefined,
322
+ limit: params.limit,
323
+ };
324
+ }
325
+
326
+ export function buildUcpMetadata(
327
+ capabilityNames: UcpCapabilityName[] = [
328
+ UCP_CAPABILITIES.catalogSearch,
329
+ UCP_CAPABILITIES.catalogLookup,
330
+ UCP_CAPABILITIES.cart,
331
+ ],
332
+ status: 'success' | 'error' = 'success'
333
+ ): JsonRecord {
334
+ return {
335
+ version: UCP_VERSION,
336
+ status,
337
+ capabilities: capabilityNames.reduce<Record<string, Array<{ version: string }>>>(
338
+ (accumulator, capabilityName) => {
339
+ accumulator[capabilityName] = [{ version: UCP_VERSION }];
340
+ return accumulator;
341
+ },
342
+ {}
343
+ ),
344
+ };
345
+ }
346
+
347
+ export function buildUcpProfile(baseUrl: string) {
348
+ const normalizedBaseUrl = baseUrl.replace(/\/+$/, '');
349
+ const endpoint = `${normalizedBaseUrl}${UCP_REST_BASE_PATH}`;
350
+
351
+ return {
352
+ ucp: {
353
+ version: UCP_VERSION,
354
+ supported_versions: {
355
+ [UCP_VERSION]: `${normalizedBaseUrl}/.well-known/ucp`,
356
+ },
357
+ services: {
358
+ [UCP_SHOPPING_SERVICE]: [
359
+ {
360
+ version: UCP_VERSION,
361
+ spec: `${UCP_SPEC_ROOT}/specification/overview`,
362
+ transport: 'rest',
363
+ schema: `${UCP_SPEC_ROOT}/services/shopping/rest.openapi.json`,
364
+ endpoint,
365
+ },
366
+ ],
367
+ },
368
+ capabilities: {
369
+ [UCP_CAPABILITIES.catalogSearch]: [
370
+ {
371
+ version: UCP_VERSION,
372
+ spec: `${UCP_SPEC_ROOT}/specification/catalog/search`,
373
+ schema: `${UCP_SPEC_ROOT}/schemas/shopping/catalog_search.json`,
374
+ },
375
+ ],
376
+ [UCP_CAPABILITIES.catalogLookup]: [
377
+ {
378
+ version: UCP_VERSION,
379
+ spec: `${UCP_SPEC_ROOT}/specification/catalog/lookup`,
380
+ schema: `${UCP_SPEC_ROOT}/schemas/shopping/catalog_lookup.json`,
381
+ },
382
+ ],
383
+ [UCP_CAPABILITIES.cart]: [
384
+ {
385
+ version: UCP_VERSION,
386
+ spec: `${UCP_SPEC_ROOT}/specification/cart`,
387
+ schema: `${UCP_SPEC_ROOT}/schemas/shopping/cart.json`,
388
+ },
389
+ ],
390
+ },
391
+ },
392
+ business: {
393
+ name: 'NextBlock',
394
+ url: normalizedBaseUrl,
395
+ },
396
+ };
397
+ }
398
+
399
+ export async function parseJsonBody(request: Request) {
400
+ try {
401
+ return await request.json();
402
+ } catch {
403
+ return {};
404
+ }
405
+ }
406
+
407
+ export function buildUcpBusinessError(params: {
408
+ capability: UcpCapabilityName;
409
+ code: string;
410
+ content: string;
411
+ severity?: 'recoverable' | 'unrecoverable';
412
+ continueUrl?: string;
413
+ }) {
414
+ return {
415
+ ucp: buildUcpMetadata([params.capability], 'error'),
416
+ messages: [
417
+ {
418
+ type: 'error',
419
+ code: params.code,
420
+ content: params.content,
421
+ severity: params.severity ?? 'unrecoverable',
422
+ },
423
+ ],
424
+ continue_url: params.continueUrl,
425
+ };
426
+ }
427
+
428
+ export function buildProtocolError(code: string, content: string) {
429
+ return { code, content };
430
+ }
431
+
432
+ export async function ensureEcommerceOnline() {
433
+ try {
434
+ return await verifyPackageOnline('ecommerce');
435
+ } catch {
436
+ return false;
437
+ }
438
+ }
439
+
440
+ async function fetchCurrencies(client: SupabaseAnyClient): Promise<CurrencyRecord[]> {
441
+ const { data } = await client
442
+ .from('currencies')
443
+ .select(
444
+ 'code, symbol, exchange_rate, is_default, is_active, auto_sync_product_prices, rounding_mode, rounding_increment, rounding_charm_amount, auto_update_exchange_rate, exchange_rate_source, exchange_rate_updated_at'
445
+ )
446
+ .eq('is_active', true)
447
+ .order('is_default', { ascending: false });
448
+
449
+ const currencies = (data || []).map((currency: any) =>
450
+ normalizeCurrencyRecord(currency)
451
+ );
452
+
453
+ return currencies.length > 0
454
+ ? currencies
455
+ : [
456
+ normalizeCurrencyRecord({
457
+ code: 'USD',
458
+ symbol: '$',
459
+ exchange_rate: 1,
460
+ is_default: true,
461
+ is_active: true,
462
+ }),
463
+ ];
464
+ }
465
+
466
+ function resolveRequestedLocale(body: unknown) {
467
+ const record = asRecord(body);
468
+ const context = asRecord(record.context);
469
+ return (
470
+ asString(context.language) ||
471
+ asString(context.locale) ||
472
+ asString(record.locale) ||
473
+ null
474
+ );
475
+ }
476
+
477
+ function resolveRequestedCurrency(
478
+ body: unknown,
479
+ currencies: CurrencyRecord[]
480
+ ) {
481
+ const record = asRecord(body);
482
+ const context = asRecord(record.context);
483
+ const requestedCurrency =
484
+ asString(context.currency) ||
485
+ asString(record.currency) ||
486
+ asString(record.currencyCode);
487
+
488
+ if (requestedCurrency) {
489
+ const normalized = requestedCurrency.toUpperCase();
490
+ if (currencies.some((currency) => currency.code === normalized)) {
491
+ return normalized;
492
+ }
493
+ }
494
+
495
+ const locale = resolveRequestedLocale(body);
496
+ return inferCurrencyCodeFromLocale(locale, currencies) || getDefaultCurrency(currencies).code;
497
+ }
498
+
499
+ function buildProductUrl(baseUrl: string, product: any) {
500
+ return `${baseUrl.replace(/\/+$/, '')}/product/${product.slug}`;
501
+ }
502
+
503
+ function buildProductInputValues(product: any, baseUrl: string) {
504
+ return new Set(
505
+ [
506
+ product?.id,
507
+ product?.slug,
508
+ product?.sku,
509
+ product?.upc,
510
+ buildProductUrl(baseUrl, product),
511
+ `${baseUrl.replace(/\/+$/, '')}/products/${product?.slug}`,
512
+ ]
513
+ .filter(Boolean)
514
+ .map((value) => String(value).toLowerCase())
515
+ );
516
+ }
517
+
518
+ function buildVariantInputValues(variant: ProductVariant) {
519
+ return new Set(
520
+ [variant.id, variant.sku, variant.upc]
521
+ .filter(Boolean)
522
+ .map((value) => String(value).toLowerCase())
523
+ );
524
+ }
525
+
526
+ function getLookupInputsForProduct(product: any, baseUrl: string, inputs: string[]) {
527
+ const productValues = buildProductInputValues(product, baseUrl);
528
+
529
+ return inputs
530
+ .filter((input) => productValues.has(input.toLowerCase()))
531
+ .map((input) => ({
532
+ id: input,
533
+ match: product.sku === input || product.upc === input ? 'exact' : 'featured',
534
+ }));
535
+ }
536
+
537
+ function getLookupInputsForVariant(variant: ProductVariant, inputs: string[]) {
538
+ const variantValues = buildVariantInputValues(variant);
539
+
540
+ return inputs
541
+ .filter((input) => variantValues.has(input.toLowerCase()))
542
+ .map((input) => ({
543
+ id: input,
544
+ match: 'exact',
545
+ }));
546
+ }
547
+
548
+ function getProductImages(product: any) {
549
+ const mediaRows = Array.isArray(product?.product_media)
550
+ ? [...product.product_media].sort(
551
+ (left, right) => (left?.sort_order ?? 0) - (right?.sort_order ?? 0)
552
+ )
553
+ : [];
554
+
555
+ return mediaRows
556
+ .map((row) => {
557
+ const media = row?.media;
558
+ const url = resolveMediaUrl(media?.file_path ?? media?.object_key ?? null);
559
+ if (!url) {
560
+ return null;
561
+ }
562
+
563
+ return {
564
+ type: 'image',
565
+ url,
566
+ alt_text: media?.file_name || product.title,
567
+ width: media?.width ?? undefined,
568
+ height: media?.height ?? undefined,
569
+ };
570
+ })
571
+ .filter(Boolean);
572
+ }
573
+
574
+ function resolveEffectivePrice(params: {
575
+ price: number;
576
+ prices?: unknown;
577
+ salePrice?: number | null;
578
+ salePrices?: unknown;
579
+ saleStartAt?: string | null;
580
+ saleEndAt?: string | null;
581
+ scheduledPrice?: number | null;
582
+ scheduledPrices?: unknown;
583
+ scheduledPriceAt?: string | null;
584
+ currencyCode: string;
585
+ currencies: CurrencyRecord[];
586
+ }) {
587
+ const resolvedPrice = resolveEffectivePriceForCurrency({
588
+ prices: normalizePriceMap(params.prices),
589
+ salePrices: normalizeSalePriceMap(params.salePrices),
590
+ fallbackPrice: params.price,
591
+ fallbackSalePrice: params.salePrice,
592
+ saleStartAt: params.saleStartAt,
593
+ saleEndAt: params.saleEndAt,
594
+ scheduledPrice: params.scheduledPrice,
595
+ scheduledPrices: normalizePriceMap(params.scheduledPrices),
596
+ scheduledPriceAt: params.scheduledPriceAt,
597
+ currencyCode: params.currencyCode,
598
+ currencies: params.currencies,
599
+ });
600
+
601
+ return {
602
+ regularAmount: resolvedPrice.price,
603
+ saleAmount: resolvedPrice.sale_price,
604
+ amount: resolvedPrice.sale_price ?? resolvedPrice.price,
605
+ currency: resolvedPrice.currencyCode,
606
+ };
607
+ }
608
+
609
+ function getProductPriceRange(product: any, variants: ProductVariant[], options: ProductMapOptions) {
610
+ const entries =
611
+ variants.length > 0
612
+ ? variants.map((variant) => ({
613
+ price: variant.price,
614
+ prices: variant.prices,
615
+ sale_price: variant.sale_price,
616
+ sale_prices: variant.sale_prices,
617
+ sale_start_at: variant.sale_start_at,
618
+ sale_end_at: variant.sale_end_at,
619
+ scheduled_price: variant.scheduled_price,
620
+ scheduled_prices: variant.scheduled_prices,
621
+ scheduled_price_at: variant.scheduled_price_at,
622
+ }))
623
+ : [
624
+ {
625
+ price: product.price ?? 0,
626
+ prices: product.prices,
627
+ sale_price: product.sale_price,
628
+ sale_prices: product.sale_prices,
629
+ sale_start_at: product.sale_start_at,
630
+ sale_end_at: product.sale_end_at,
631
+ scheduled_price: product.scheduled_price,
632
+ scheduled_prices: product.scheduled_prices,
633
+ scheduled_price_at: product.scheduled_price_at,
634
+ },
635
+ ];
636
+
637
+ const amounts = entries.map((entry) =>
638
+ resolveEffectivePrice({
639
+ price: entry.price ?? 0,
640
+ prices: entry.prices,
641
+ salePrice: entry.sale_price,
642
+ salePrices: entry.sale_prices,
643
+ saleStartAt: entry.sale_start_at,
644
+ saleEndAt: entry.sale_end_at,
645
+ scheduledPrice: entry.scheduled_price,
646
+ scheduledPrices: entry.scheduled_prices,
647
+ scheduledPriceAt: entry.scheduled_price_at,
648
+ currencyCode: options.currencyCode,
649
+ currencies: options.currencies,
650
+ }).amount
651
+ );
652
+
653
+ return {
654
+ min: { amount: Math.min(...amounts), currency: options.currencyCode },
655
+ max: { amount: Math.max(...amounts), currency: options.currencyCode },
656
+ };
657
+ }
658
+
659
+ function getVariantAvailability(product: any, variant?: ProductVariant | null) {
660
+ if (product?.product_type === 'digital') {
661
+ return { available: true };
662
+ }
663
+
664
+ const stockQuantity =
665
+ typeof variant?.stock_quantity === 'number'
666
+ ? variant.stock_quantity
667
+ : typeof product?.stock === 'number'
668
+ ? product.stock
669
+ : null;
670
+
671
+ return {
672
+ available: stockQuantity === null || stockQuantity > 0,
673
+ quantity: stockQuantity ?? undefined,
674
+ };
675
+ }
676
+
677
+ function matchesSelectedOptions(
678
+ variant: ProductVariant,
679
+ selected: ProductMapOptions['selected']
680
+ ) {
681
+ if (!selected?.length) {
682
+ return true;
683
+ }
684
+
685
+ return selected.every((selection) => {
686
+ const name = selection.name?.toLowerCase();
687
+ const label = (selection.label || selection.value || '').toLowerCase();
688
+
689
+ if (!name || !label) {
690
+ return true;
691
+ }
692
+
693
+ return variant.selected_options.some(
694
+ (option) =>
695
+ option.attribute_name.toLowerCase() === name &&
696
+ option.term_value.toLowerCase() === label
697
+ );
698
+ });
699
+ }
700
+
701
+ function buildUcpOptions(attributes: any[], variants: ProductVariant[], selected: ProductMapOptions['selected']) {
702
+ return attributes.map((attribute) => ({
703
+ name: attribute.name,
704
+ values: attribute.terms.map((term: any) => {
705
+ const exists = variants.some((variant) =>
706
+ variant.selected_options.some((option) => option.term_id === term.id)
707
+ );
708
+ const available = variants.some(
709
+ (variant) =>
710
+ variant.stock_quantity > 0 &&
711
+ matchesSelectedOptions(variant, selected) &&
712
+ variant.selected_options.some((option) => option.term_id === term.id)
713
+ );
714
+
715
+ return {
716
+ label: term.value,
717
+ value: term.id,
718
+ available,
719
+ exists,
720
+ };
721
+ }),
722
+ }));
723
+ }
724
+
725
+ function buildDefaultVariant(product: any, options: ProductMapOptions): ProductVariant {
726
+ return {
727
+ id: product.id,
728
+ combination_key: product.id,
729
+ sku: product.sku || product.id,
730
+ upc: product.upc ?? null,
731
+ price: product.price ?? 0,
732
+ prices: normalizePriceMap(product.prices),
733
+ sale_price: product.sale_price ?? null,
734
+ sale_prices: normalizeSalePriceMap(product.sale_prices),
735
+ sale_start_at: product.sale_start_at ?? null,
736
+ sale_end_at: product.sale_end_at ?? null,
737
+ scheduled_price: product.scheduled_price ?? null,
738
+ scheduled_prices: normalizePriceMap(product.scheduled_prices),
739
+ scheduled_price_at: product.scheduled_price_at ?? null,
740
+ stock_quantity:
741
+ typeof product.stock === 'number'
742
+ ? product.stock
743
+ : product.product_type === 'digital'
744
+ ? 999999
745
+ : 0,
746
+ attribute_term_ids: [],
747
+ selected_options: [],
748
+ label: product.title,
749
+ image_url: getProductImages(product)[0]?.url as string | undefined,
750
+ };
751
+ }
752
+
753
+ function sortFeaturedVariantFirst(
754
+ variants: ProductVariant[],
755
+ requestedVariantId?: string | null
756
+ ) {
757
+ const requested = requestedVariantId
758
+ ? variants.find((variant) => variant.id === requestedVariantId)
759
+ : null;
760
+
761
+ if (requested) {
762
+ return [requested, ...variants.filter((variant) => variant.id !== requested.id)];
763
+ }
764
+
765
+ const inStock = variants.find((variant) => variant.stock_quantity > 0);
766
+ if (inStock) {
767
+ return [inStock, ...variants.filter((variant) => variant.id !== inStock.id)];
768
+ }
769
+
770
+ return variants;
771
+ }
772
+
773
+ function mapVariantToUcp(
774
+ product: any,
775
+ variant: ProductVariant,
776
+ options: ProductMapOptions,
777
+ lookupInputs: Array<{ id: string; match: string }> = []
778
+ ) {
779
+ const price = resolveEffectivePrice({
780
+ price: variant.price ?? product.price ?? 0,
781
+ prices: variant.prices ?? product.prices,
782
+ salePrice: variant.sale_price ?? product.sale_price ?? null,
783
+ salePrices: variant.sale_prices ?? product.sale_prices,
784
+ saleStartAt: variant.sale_start_at ?? product.sale_start_at,
785
+ saleEndAt: variant.sale_end_at ?? product.sale_end_at,
786
+ scheduledPrice: variant.scheduled_price ?? product.scheduled_price,
787
+ scheduledPrices: variant.scheduled_prices ?? product.scheduled_prices,
788
+ scheduledPriceAt: variant.scheduled_price_at ?? product.scheduled_price_at,
789
+ currencyCode: options.currencyCode,
790
+ currencies: options.currencies,
791
+ });
792
+ const imageUrl =
793
+ variant.image_url || (getProductImages(product)[0]?.url as string | undefined);
794
+ const title =
795
+ variant.label && variant.label !== product.title
796
+ ? `${product.title} - ${variant.label}`
797
+ : product.title;
798
+
799
+ return {
800
+ id: variant.id,
801
+ product_id: product.id,
802
+ sku: variant.sku || product.sku,
803
+ gtin: variant.upc || product.upc || undefined,
804
+ title,
805
+ description: { plain: variant.label || product.title },
806
+ price: { amount: price.amount, currency: price.currency },
807
+ compare_at_price:
808
+ price.saleAmount && price.regularAmount > price.saleAmount
809
+ ? { amount: price.regularAmount, currency: price.currency }
810
+ : undefined,
811
+ availability: getVariantAvailability(product, variant),
812
+ options: variant.selected_options.map((option) => ({
813
+ name: option.attribute_name,
814
+ label: option.term_value,
815
+ value: option.term_id,
816
+ })),
817
+ media: imageUrl
818
+ ? [
819
+ {
820
+ type: 'image',
821
+ url: imageUrl,
822
+ alt_text: title,
823
+ },
824
+ ]
825
+ : [],
826
+ inputs: lookupInputs.length > 0 ? lookupInputs : undefined,
827
+ };
828
+ }
829
+
830
+ export function productToUcpProduct(product: any, options: ProductMapOptions) {
831
+ const languageCode = getLanguageCode(product);
832
+ const categories = getProductCategories(product, languageCode);
833
+ const { attributes, variants: mappedVariants } = mapRawVariantRelations(
834
+ product?.product_variants || [],
835
+ languageCode
836
+ );
837
+ const variants =
838
+ mappedVariants.length > 0
839
+ ? sortFeaturedVariantFirst(mappedVariants, options.requestedVariantId)
840
+ : [buildDefaultVariant(product, options)];
841
+ const selectedVariants =
842
+ options.mode === 'detail'
843
+ ? variants.filter((variant) =>
844
+ options.requestedVariantId
845
+ ? variant.id === options.requestedVariantId ||
846
+ matchesSelectedOptions(variant, options.selected)
847
+ : matchesSelectedOptions(variant, options.selected)
848
+ )
849
+ : variants;
850
+ const displayVariants = selectedVariants.length > 0 ? selectedVariants : variants.slice(0, 1);
851
+ const productInputs = getLookupInputsForProduct(
852
+ product,
853
+ options.baseUrl,
854
+ options.lookupInputs ?? []
855
+ );
856
+ const isLookupMode = options.mode === 'lookup';
857
+ const featuredVariantId = variants[0]?.id;
858
+
859
+ const ucpVariants = displayVariants
860
+ .map((variant) => {
861
+ const variantInputs = getLookupInputsForVariant(variant, options.lookupInputs ?? []);
862
+ const inputs =
863
+ variantInputs.length > 0
864
+ ? variantInputs
865
+ : isLookupMode && variant.id === featuredVariantId
866
+ ? productInputs
867
+ : [];
868
+
869
+ if (isLookupMode && inputs.length === 0) {
870
+ return null;
871
+ }
872
+
873
+ return mapVariantToUcp(product, variant, options, inputs);
874
+ })
875
+ .filter(Boolean);
876
+
877
+ return {
878
+ id: product.id,
879
+ handle: product.slug,
880
+ sku: product.sku || undefined,
881
+ gtin: product.upc || undefined,
882
+ title: product.title,
883
+ description: {
884
+ plain: resolveProductMetaDescription(
885
+ product.meta_description,
886
+ product.short_description
887
+ ),
888
+ },
889
+ url: buildProductUrl(options.baseUrl, product),
890
+ language: languageCode || undefined,
891
+ status: product.status,
892
+ categories,
893
+ price_range: getProductPriceRange(product, variants, options),
894
+ media: getProductImages(product),
895
+ options: buildUcpOptions(attributes, variants, options.selected),
896
+ variants: ucpVariants,
897
+ seller: {
898
+ name: 'NextBlock',
899
+ url: options.baseUrl,
900
+ },
901
+ metadata: {
902
+ product_type: product.product_type ?? null,
903
+ payment_provider: product.payment_provider ?? null,
904
+ category_ids: categories.map((category: any) => category.id),
905
+ category_slugs: categories.map((category: any) => category.slug),
906
+ translation_group_id: product.translation_group_id ?? null,
907
+ short_description: stripHtmlToText(product.short_description || ''),
908
+ },
909
+ };
910
+ }
911
+
912
+ function normalizeSearchText(value: unknown) {
913
+ return asString(value)?.replace(/[,%()]/g, ' ').trim() || '';
914
+ }
915
+
916
+ function getPriceFilter(body: unknown) {
917
+ const filters = asRecord(asRecord(body).filters);
918
+ const price = asRecord(filters.price);
919
+
920
+ return {
921
+ min: toMoneyAmount(asRecord(price.min).amount ?? price.min),
922
+ max: toMoneyAmount(asRecord(price.max).amount ?? price.max),
923
+ };
924
+ }
925
+
926
+ function normalizeCategoryFilterValues(body: unknown) {
927
+ const filters = asRecord(asRecord(body).filters);
928
+ const rawCategories = filters.categories;
929
+ const values = Array.isArray(rawCategories)
930
+ ? rawCategories
931
+ : rawCategories
932
+ ? [rawCategories]
933
+ : [];
934
+
935
+ return [
936
+ ...new Set(
937
+ values
938
+ .map((value) =>
939
+ typeof value === 'string' || typeof value === 'number'
940
+ ? String(value).trim()
941
+ : ''
942
+ )
943
+ .filter(Boolean)
944
+ ),
945
+ ];
946
+ }
947
+
948
+ function normalizeCategoryToken(value: unknown) {
949
+ return typeof value === 'string' || typeof value === 'number'
950
+ ? String(value).trim().toLowerCase()
951
+ : '';
952
+ }
953
+
954
+ function categoryMatchesFilterValue(
955
+ category: any,
956
+ filterTokens: Set<string>,
957
+ languageCode?: string | null
958
+ ) {
959
+ const translatedName = resolveTranslatedCategoryText(
960
+ category.name,
961
+ category.name_translations,
962
+ languageCode
963
+ );
964
+ const translatedDescription = resolveTranslatedCategoryText(
965
+ category.description,
966
+ category.description_translations,
967
+ languageCode
968
+ );
969
+ const translationValues = [
970
+ ...Object.values(normalizeTranslationMap(category.name_translations) ?? {}),
971
+ ...Object.values(normalizeTranslationMap(category.description_translations) ?? {}),
972
+ ];
973
+ const candidates = [
974
+ category.id,
975
+ category.slug,
976
+ category.name,
977
+ category.description,
978
+ translatedName,
979
+ translatedDescription,
980
+ ...translationValues,
981
+ ]
982
+ .map(normalizeCategoryToken)
983
+ .filter(Boolean);
984
+
985
+ return candidates.some((candidate) => filterTokens.has(candidate));
986
+ }
987
+
988
+ async function resolveCategoryFilterProductIds(params: {
989
+ client: SupabaseAnyClient;
990
+ values: string[];
991
+ languageCode?: string | null;
992
+ }) {
993
+ const filterTokens = new Set(params.values.map(normalizeCategoryToken).filter(Boolean));
994
+ if (filterTokens.size === 0) {
995
+ return {
996
+ productIds: null as string[] | null,
997
+ matchedCategoryIds: [] as string[],
998
+ messages: [] as JsonRecord[],
999
+ };
1000
+ }
1001
+
1002
+ const { data: categories, error: categoryError } = await params.client
1003
+ .from('categories')
1004
+ .select('id, name, slug, description, name_translations, description_translations');
1005
+
1006
+ if (categoryError) {
1007
+ throw categoryError;
1008
+ }
1009
+
1010
+ const matchedCategories = (categories || []).filter((category: any) =>
1011
+ categoryMatchesFilterValue(category, filterTokens, params.languageCode)
1012
+ );
1013
+
1014
+ if (matchedCategories.length === 0) {
1015
+ return {
1016
+ productIds: [],
1017
+ matchedCategoryIds: [],
1018
+ messages: [
1019
+ {
1020
+ type: 'info',
1021
+ code: 'category_filter_no_match',
1022
+ content: `No categories matched: ${params.values.join(', ')}`,
1023
+ },
1024
+ ],
1025
+ };
1026
+ }
1027
+
1028
+ const matchedCategoryIds = matchedCategories.map((category: any) => category.id);
1029
+ const { data: productCategoryRows, error: productCategoryError } = await params.client
1030
+ .from('product_categories')
1031
+ .select('product_id')
1032
+ .in('category_id', matchedCategoryIds);
1033
+
1034
+ if (productCategoryError) {
1035
+ throw productCategoryError;
1036
+ }
1037
+
1038
+ return {
1039
+ productIds: [
1040
+ ...new Set(
1041
+ (productCategoryRows || [])
1042
+ .map((row: any) => row.product_id)
1043
+ .filter(Boolean)
1044
+ ),
1045
+ ],
1046
+ matchedCategoryIds,
1047
+ messages: [] as JsonRecord[],
1048
+ };
1049
+ }
1050
+
1051
+ function productPassesPriceFilter(product: any, priceFilter: ReturnType<typeof getPriceFilter>) {
1052
+ if (priceFilter.min === null && priceFilter.max === null) {
1053
+ return true;
1054
+ }
1055
+
1056
+ const minAmount = product?.price_range?.min?.amount;
1057
+ const maxAmount = product?.price_range?.max?.amount;
1058
+
1059
+ if (typeof maxAmount === 'number' && priceFilter.min !== null && maxAmount < priceFilter.min) {
1060
+ return false;
1061
+ }
1062
+
1063
+ if (typeof minAmount === 'number' && priceFilter.max !== null && minAmount > priceFilter.max) {
1064
+ return false;
1065
+ }
1066
+
1067
+ return true;
1068
+ }
1069
+
1070
+ export async function searchCatalogProducts(body: unknown, request: Request) {
1071
+ const client = getUcpSupabaseClient();
1072
+ const baseUrl = getOriginFromRequest(request);
1073
+ const currencies = await fetchCurrencies(client);
1074
+ const currencyCode = resolveRequestedCurrency(body, currencies);
1075
+ const pagination = normalizeUcpPagination(body);
1076
+ const queryText = normalizeSearchText(asRecord(body).query ?? asRecord(body).q);
1077
+ const priceFilter = getPriceFilter(body);
1078
+ const categoryFilterValues = normalizeCategoryFilterValues(body);
1079
+ const categoryFilter = await resolveCategoryFilterProductIds({
1080
+ client,
1081
+ values: categoryFilterValues,
1082
+ languageCode: resolveRequestedLocale(body),
1083
+ });
1084
+
1085
+ if (categoryFilter.productIds && categoryFilter.productIds.length === 0) {
1086
+ return {
1087
+ ucp: buildUcpMetadata([UCP_CAPABILITIES.catalogSearch]),
1088
+ products: [],
1089
+ pagination: buildPaginationResponse({
1090
+ ...pagination,
1091
+ count: 0,
1092
+ totalCount: 0,
1093
+ }),
1094
+ messages: categoryFilter.messages,
1095
+ };
1096
+ }
1097
+
1098
+ let query = client
1099
+ .from('products')
1100
+ .select(PRODUCT_SELECT, { count: 'exact' })
1101
+ .eq('status', 'active')
1102
+ .order('created_at', { ascending: false })
1103
+ .range(pagination.offset, pagination.offset + pagination.limit - 1);
1104
+
1105
+ if (queryText) {
1106
+ query = query.or(
1107
+ `title.ilike.%${queryText}%,sku.ilike.%${queryText}%,slug.ilike.%${queryText}%`
1108
+ );
1109
+ }
1110
+
1111
+ if (categoryFilter.productIds) {
1112
+ query = query.in('id', categoryFilter.productIds);
1113
+ }
1114
+
1115
+ const { data, error, count } = await query;
1116
+ if (error) {
1117
+ throw error;
1118
+ }
1119
+
1120
+ const products = (data || [])
1121
+ .map((product: any) =>
1122
+ productToUcpProduct(product, {
1123
+ baseUrl,
1124
+ currencyCode,
1125
+ currencies,
1126
+ mode: 'search',
1127
+ })
1128
+ )
1129
+ .filter((product: any) => productPassesPriceFilter(product, priceFilter));
1130
+
1131
+ return {
1132
+ ucp: buildUcpMetadata([UCP_CAPABILITIES.catalogSearch]),
1133
+ products,
1134
+ pagination: buildPaginationResponse({
1135
+ ...pagination,
1136
+ count: data?.length ?? 0,
1137
+ totalCount: count,
1138
+ }),
1139
+ messages:
1140
+ queryText || Object.keys(asRecord(asRecord(body).filters)).length > 0
1141
+ ? categoryFilter.messages
1142
+ : [
1143
+ {
1144
+ type: 'info',
1145
+ code: 'browse_default',
1146
+ content: 'No search query was supplied; returning the latest active products.',
1147
+ },
1148
+ ],
1149
+ };
1150
+ }
1151
+
1152
+ function normalizeLookupIds(value: unknown) {
1153
+ const rawIds = Array.isArray(value) ? value : [];
1154
+ const ids = rawIds
1155
+ .map((id) => (typeof id === 'string' || typeof id === 'number' ? String(id).trim() : ''))
1156
+ .filter(Boolean);
1157
+
1158
+ return [...new Set(ids)].slice(0, MAX_LOOKUP_IDS);
1159
+ }
1160
+
1161
+ function expandIdentifierCandidates(id: string) {
1162
+ const candidates = new Set([id]);
1163
+
1164
+ try {
1165
+ const url = new URL(id);
1166
+ const segments = url.pathname.split('/').filter(Boolean);
1167
+ const productIndex = segments.findIndex((segment) => segment === 'product' || segment === 'products');
1168
+ if (productIndex >= 0 && segments[productIndex + 1]) {
1169
+ candidates.add(decodeURIComponent(segments[productIndex + 1]));
1170
+ }
1171
+ } catch {
1172
+ // Not a URL; the original identifier is already included.
1173
+ }
1174
+
1175
+ return [...candidates];
1176
+ }
1177
+
1178
+ async function selectRowsByField(
1179
+ client: SupabaseAnyClient,
1180
+ table: 'products' | 'product_variants',
1181
+ field: string,
1182
+ values: string[]
1183
+ ): Promise<any[]> {
1184
+ if (values.length === 0) {
1185
+ return [];
1186
+ }
1187
+
1188
+ const select =
1189
+ table === 'products'
1190
+ ? 'id, slug, sku, upc, status'
1191
+ : 'id, product_id, sku, upc';
1192
+
1193
+ let query = client.from(table).select(select).in(field, values);
1194
+ if (table === 'products') {
1195
+ query = query.eq('status', 'active');
1196
+ }
1197
+
1198
+ const { data } = await query;
1199
+ return (data || []) as any[];
1200
+ }
1201
+
1202
+ async function resolveProductRowsByIdentifiers(ids: string[]): Promise<{
1203
+ rows: any[];
1204
+ variantMatches: any[];
1205
+ }> {
1206
+ const client = getUcpSupabaseClient();
1207
+ const expandedIds = [...new Set(ids.flatMap(expandIdentifierCandidates))];
1208
+ const [
1209
+ productsById,
1210
+ productsBySlug,
1211
+ productsBySku,
1212
+ productsByUpc,
1213
+ variantsById,
1214
+ variantsBySku,
1215
+ variantsByUpc,
1216
+ ] = await Promise.all([
1217
+ selectRowsByField(client, 'products', 'id', expandedIds),
1218
+ selectRowsByField(client, 'products', 'slug', expandedIds),
1219
+ selectRowsByField(client, 'products', 'sku', expandedIds),
1220
+ selectRowsByField(client, 'products', 'upc', expandedIds),
1221
+ selectRowsByField(client, 'product_variants', 'id', expandedIds),
1222
+ selectRowsByField(client, 'product_variants', 'sku', expandedIds),
1223
+ selectRowsByField(client, 'product_variants', 'upc', expandedIds),
1224
+ ]);
1225
+ const productMatches = [
1226
+ ...productsById,
1227
+ ...productsBySlug,
1228
+ ...productsBySku,
1229
+ ...productsByUpc,
1230
+ ];
1231
+ const variantMatches = [
1232
+ ...variantsById,
1233
+ ...variantsBySku,
1234
+ ...variantsByUpc,
1235
+ ];
1236
+ const productIds = [
1237
+ ...new Set([
1238
+ ...productMatches.map((product: any) => product.id),
1239
+ ...variantMatches.map((variant: any) => variant.product_id),
1240
+ ]),
1241
+ ];
1242
+
1243
+ if (productIds.length === 0) {
1244
+ return {
1245
+ rows: [],
1246
+ variantMatches,
1247
+ };
1248
+ }
1249
+
1250
+ const { data, error } = await client
1251
+ .from('products')
1252
+ .select(PRODUCT_SELECT)
1253
+ .eq('status', 'active')
1254
+ .in('id', productIds);
1255
+
1256
+ if (error) {
1257
+ throw error;
1258
+ }
1259
+
1260
+ return {
1261
+ rows: (data || []) as any[],
1262
+ variantMatches: variantMatches as any[],
1263
+ };
1264
+ }
1265
+
1266
+ function getFoundLookupIds(params: {
1267
+ ids: string[];
1268
+ rows: any[];
1269
+ variantMatches: any[];
1270
+ baseUrl: string;
1271
+ }) {
1272
+ const found = new Set<string>();
1273
+
1274
+ for (const id of params.ids) {
1275
+ const expanded = expandIdentifierCandidates(id).map((candidate) => candidate.toLowerCase());
1276
+ const productFound = params.rows.some((product) => {
1277
+ const values = buildProductInputValues(product, params.baseUrl);
1278
+ return expanded.some((candidate) => values.has(candidate));
1279
+ });
1280
+ const variantFound = params.variantMatches.some((variant) => {
1281
+ const values = [variant.id, variant.sku, variant.upc]
1282
+ .filter(Boolean)
1283
+ .map((value) => String(value).toLowerCase());
1284
+ return expanded.some((candidate) => values.includes(candidate));
1285
+ });
1286
+
1287
+ if (productFound || variantFound) {
1288
+ found.add(id);
1289
+ }
1290
+ }
1291
+
1292
+ return found;
1293
+ }
1294
+
1295
+ export async function lookupCatalogProducts(body: unknown, request: Request) {
1296
+ const record = asRecord(body);
1297
+ const ids = normalizeLookupIds(record.ids);
1298
+ if (Array.isArray(record.ids) && record.ids.length > MAX_LOOKUP_IDS) {
1299
+ return {
1300
+ status: 400,
1301
+ body: buildProtocolError(
1302
+ 'request_too_large',
1303
+ `Catalog lookup accepts at most ${MAX_LOOKUP_IDS} identifiers.`
1304
+ ),
1305
+ };
1306
+ }
1307
+
1308
+ if (ids.length === 0) {
1309
+ return {
1310
+ status: 400,
1311
+ body: buildProtocolError('invalid_request', 'Catalog lookup requires ids[].'),
1312
+ };
1313
+ }
1314
+
1315
+ const baseUrl = getOriginFromRequest(request);
1316
+ const client = getUcpSupabaseClient();
1317
+ const currencies = await fetchCurrencies(client);
1318
+ const currencyCode = resolveRequestedCurrency(body, currencies);
1319
+ const { rows, variantMatches } = await resolveProductRowsByIdentifiers(ids);
1320
+ const foundIds = getFoundLookupIds({ ids, rows, variantMatches, baseUrl });
1321
+ const requestedVariantIds = new Set(
1322
+ variantMatches
1323
+ .filter((variant) => ids.some((id) => expandIdentifierCandidates(id).includes(variant.id)))
1324
+ .map((variant) => variant.id)
1325
+ );
1326
+ const products = rows.map((product) =>
1327
+ productToUcpProduct(product, {
1328
+ baseUrl,
1329
+ currencyCode,
1330
+ currencies,
1331
+ mode: 'lookup',
1332
+ lookupInputs: ids,
1333
+ requestedVariantId:
1334
+ product.product_variants?.find((variant: any) =>
1335
+ requestedVariantIds.has(variant.id)
1336
+ )?.id ?? null,
1337
+ })
1338
+ );
1339
+ const messages = ids
1340
+ .filter((id) => !foundIds.has(id))
1341
+ .map((id) => ({
1342
+ type: 'info',
1343
+ code: 'not_found',
1344
+ content: id,
1345
+ }));
1346
+
1347
+ return {
1348
+ status: 200,
1349
+ body: {
1350
+ ucp: buildUcpMetadata([UCP_CAPABILITIES.catalogLookup]),
1351
+ products,
1352
+ messages,
1353
+ },
1354
+ };
1355
+ }
1356
+
1357
+ export async function getCatalogProduct(body: unknown, request: Request) {
1358
+ const record = asRecord(body);
1359
+ const id = asString(record.id ?? record.product_id ?? record.productId);
1360
+ if (!id) {
1361
+ return {
1362
+ status: 400,
1363
+ body: buildProtocolError('invalid_request', 'Catalog product lookup requires id.'),
1364
+ };
1365
+ }
1366
+
1367
+ const baseUrl = getOriginFromRequest(request);
1368
+ const client = getUcpSupabaseClient();
1369
+ const currencies = await fetchCurrencies(client);
1370
+ const currencyCode = resolveRequestedCurrency(body, currencies);
1371
+ const { rows, variantMatches } = await resolveProductRowsByIdentifiers([id]);
1372
+ const product = rows[0];
1373
+
1374
+ if (!product) {
1375
+ return {
1376
+ status: 200,
1377
+ body: buildUcpBusinessError({
1378
+ capability: UCP_CAPABILITIES.catalogLookup,
1379
+ code: 'not_found',
1380
+ content: `Product not found: ${id}`,
1381
+ }),
1382
+ };
1383
+ }
1384
+
1385
+ const requestedVariantId =
1386
+ variantMatches.find((variant) =>
1387
+ expandIdentifierCandidates(id).includes(variant.id)
1388
+ )?.id ?? null;
1389
+ const selected = Array.isArray(record.selected)
1390
+ ? (record.selected as ProductMapOptions['selected'])
1391
+ : undefined;
1392
+
1393
+ return {
1394
+ status: 200,
1395
+ body: {
1396
+ ucp: buildUcpMetadata([UCP_CAPABILITIES.catalogLookup]),
1397
+ product: productToUcpProduct(product, {
1398
+ baseUrl,
1399
+ currencyCode,
1400
+ currencies,
1401
+ mode: 'detail',
1402
+ lookupInputs: [id],
1403
+ requestedVariantId,
1404
+ selected,
1405
+ }),
1406
+ },
1407
+ };
1408
+ }
1409
+
1410
+ function normalizeCartLineItems(body: unknown) {
1411
+ const items = asRecord(body).line_items ?? asRecord(body).items;
1412
+ return Array.isArray(items) ? items.map(asRecord) : [];
1413
+ }
1414
+
1415
+ function normalizeQuantity(value: unknown) {
1416
+ return Math.max(1, Math.min(toInteger(value, 1), 999));
1417
+ }
1418
+
1419
+ function getLineItemIdentity(lineItem: JsonRecord) {
1420
+ const item = asRecord(lineItem.item);
1421
+ return {
1422
+ requestedId:
1423
+ asString(item.id) ||
1424
+ asString(lineItem.product_id) ||
1425
+ asString(lineItem.productId) ||
1426
+ asString(lineItem.variant_id) ||
1427
+ asString(lineItem.variantId) ||
1428
+ asString(item.product_id) ||
1429
+ asString(item.variant_id) ||
1430
+ asString(lineItem.sku) ||
1431
+ asString(item.sku),
1432
+ variantId:
1433
+ asString(lineItem.variant_id) ||
1434
+ asString(lineItem.variantId) ||
1435
+ asString(item.variant_id),
1436
+ sku: asString(lineItem.sku) || asString(item.sku),
1437
+ };
1438
+ }
1439
+
1440
+ function resolveCheckoutProvider(product: any) {
1441
+ if (product.payment_provider === 'stripe' || product.payment_provider === 'freemius') {
1442
+ return product.payment_provider;
1443
+ }
1444
+
1445
+ return product.product_type === 'digital' || product.freemius_product_id
1446
+ ? 'freemius'
1447
+ : 'stripe';
1448
+ }
1449
+
1450
+ function buildCheckoutCartItem(params: {
1451
+ product: any;
1452
+ variant: ProductVariant;
1453
+ price: ReturnType<typeof resolveEffectivePrice>;
1454
+ quantity: number;
1455
+ currencyCode: string;
1456
+ imageUrl?: string | null;
1457
+ }): CartItem {
1458
+ const { product, variant, price } = params;
1459
+ const title =
1460
+ variant.label && variant.label !== product.title
1461
+ ? `${product.title} - ${variant.label}`
1462
+ : product.title;
1463
+
1464
+ return {
1465
+ id: variant.id,
1466
+ product_id: product.id,
1467
+ title,
1468
+ slug: product.slug,
1469
+ sku: variant.sku || product.sku || variant.id,
1470
+ upc: variant.upc || product.upc || undefined,
1471
+ price: price.regularAmount,
1472
+ prices: { [params.currencyCode]: price.regularAmount },
1473
+ sale_price: price.saleAmount,
1474
+ sale_prices:
1475
+ price.saleAmount !== null ? { [params.currencyCode]: price.saleAmount } : {},
1476
+ is_taxable: product.is_taxable ?? true,
1477
+ product_type: product.product_type ?? undefined,
1478
+ payment_provider: resolveCheckoutProvider(product),
1479
+ provider: resolveCheckoutProvider(product),
1480
+ short_description: product.short_description || undefined,
1481
+ stock:
1482
+ product.product_type === 'digital'
1483
+ ? undefined
1484
+ : typeof variant.stock_quantity === 'number'
1485
+ ? variant.stock_quantity
1486
+ : typeof product.stock === 'number'
1487
+ ? product.stock
1488
+ : undefined,
1489
+ image_url: params.imageUrl || undefined,
1490
+ images: params.imageUrl ? [{ url: params.imageUrl, alt: title }] : [],
1491
+ freemius_product_id: product.freemius_product_id || undefined,
1492
+ freemius_plan_id: product.freemius_plan_id || undefined,
1493
+ trial_period_days: product.trial_period_days ?? 0,
1494
+ trial_requires_payment_method: product.trial_requires_payment_method ?? false,
1495
+ language_id: product.language_id,
1496
+ translation_group_id: product.translation_group_id || '',
1497
+ has_variants: Array.isArray(product.product_variants) && product.product_variants.length > 0,
1498
+ variant_id: variant.id,
1499
+ variant_label: variant.label,
1500
+ selected_options: variant.selected_options,
1501
+ quantity: params.quantity,
1502
+ currency_code: params.currencyCode,
1503
+ };
1504
+ }
1505
+
1506
+ async function resolveCartProductForLineItem(lineItem: JsonRecord) {
1507
+ const identity = getLineItemIdentity(lineItem);
1508
+ if (!identity.requestedId) {
1509
+ return null;
1510
+ }
1511
+
1512
+ const { rows, variantMatches } = await resolveProductRowsByIdentifiers([
1513
+ identity.variantId || identity.sku || identity.requestedId,
1514
+ ]);
1515
+ const product = rows[0];
1516
+ if (!product) {
1517
+ return null;
1518
+ }
1519
+
1520
+ const languageCode = getLanguageCode(product);
1521
+ const { variants: mappedVariants } = mapRawVariantRelations(
1522
+ product.product_variants || [],
1523
+ languageCode
1524
+ );
1525
+ const variants =
1526
+ mappedVariants.length > 0 ? mappedVariants : [buildDefaultVariant(product, {
1527
+ baseUrl: '',
1528
+ currencyCode: 'USD',
1529
+ currencies: [],
1530
+ })];
1531
+ const variantMatch = variantMatches[0];
1532
+ const variant =
1533
+ (identity.variantId &&
1534
+ variants.find((candidate) => candidate.id === identity.variantId)) ||
1535
+ (identity.sku &&
1536
+ variants.find((candidate) => candidate.sku === identity.sku)) ||
1537
+ (variantMatch &&
1538
+ variants.find((candidate) => candidate.id === variantMatch.id)) ||
1539
+ variants.find((candidate) => candidate.stock_quantity > 0) ||
1540
+ variants[0];
1541
+
1542
+ return { product, variant };
1543
+ }
1544
+
1545
+ async function buildCartFromRequest(body: unknown): Promise<CartBuildResult> {
1546
+ const client = getUcpSupabaseClient();
1547
+ const currencies = await fetchCurrencies(client);
1548
+ const currencyCode = resolveRequestedCurrency(body, currencies);
1549
+ const locale = resolveRequestedLocale(body);
1550
+ const context = asRecord(asRecord(body).context);
1551
+ const signals = asRecord(asRecord(body).signals);
1552
+ const attribution = asRecord(asRecord(body).attribution);
1553
+ const buyer = asRecord(asRecord(body).buyer);
1554
+ const inputLineItems = normalizeCartLineItems(body);
1555
+ const lineItems: JsonRecord[] = [];
1556
+ const messages: JsonRecord[] = [];
1557
+
1558
+ for (const inputLineItem of inputLineItems) {
1559
+ const resolved = await resolveCartProductForLineItem(inputLineItem);
1560
+ const requestedId = getLineItemIdentity(inputLineItem).requestedId;
1561
+
1562
+ if (!resolved) {
1563
+ messages.push({
1564
+ type: 'error',
1565
+ code: 'not_found',
1566
+ content: requestedId
1567
+ ? `Product or variant not found: ${requestedId}`
1568
+ : 'Line item is missing item.id, product_id, variant_id, or sku.',
1569
+ severity: 'recoverable',
1570
+ });
1571
+ continue;
1572
+ }
1573
+
1574
+ const quantity = normalizeQuantity(inputLineItem.quantity);
1575
+ const imageUrl =
1576
+ resolved.variant.image_url || (getProductImages(resolved.product)[0]?.url as string | undefined);
1577
+ const price = resolveEffectivePrice({
1578
+ price: resolved.variant.price ?? resolved.product.price ?? 0,
1579
+ prices: resolved.variant.prices ?? resolved.product.prices,
1580
+ salePrice: resolved.variant.sale_price ?? resolved.product.sale_price ?? null,
1581
+ salePrices: resolved.variant.sale_prices ?? resolved.product.sale_prices,
1582
+ saleStartAt: resolved.variant.sale_start_at ?? resolved.product.sale_start_at,
1583
+ saleEndAt: resolved.variant.sale_end_at ?? resolved.product.sale_end_at,
1584
+ scheduledPrice: resolved.variant.scheduled_price ?? resolved.product.scheduled_price,
1585
+ scheduledPrices: resolved.variant.scheduled_prices ?? resolved.product.scheduled_prices,
1586
+ scheduledPriceAt: resolved.variant.scheduled_price_at ?? resolved.product.scheduled_price_at,
1587
+ currencyCode,
1588
+ currencies,
1589
+ });
1590
+ const subtotal = price.amount * quantity;
1591
+ const title =
1592
+ resolved.variant.label && resolved.variant.label !== resolved.product.title
1593
+ ? `${resolved.product.title} - ${resolved.variant.label}`
1594
+ : resolved.product.title;
1595
+ const cartItem = buildCheckoutCartItem({
1596
+ product: resolved.product,
1597
+ variant: resolved.variant,
1598
+ price,
1599
+ quantity,
1600
+ currencyCode,
1601
+ imageUrl,
1602
+ });
1603
+
1604
+ lineItems.push({
1605
+ id: asString(inputLineItem.id) || `li_${crypto.randomUUID()}`,
1606
+ item: {
1607
+ id: resolved.variant.id,
1608
+ product_id: resolved.product.id,
1609
+ variant_id:
1610
+ resolved.variant.id !== resolved.product.id ? resolved.variant.id : undefined,
1611
+ handle: resolved.product.slug,
1612
+ sku: resolved.variant.sku || resolved.product.sku,
1613
+ title,
1614
+ description: {
1615
+ plain: stripHtmlToText(resolved.product.short_description || title),
1616
+ },
1617
+ url: buildProductUrl(process.env.NEXT_PUBLIC_URL || '', resolved.product),
1618
+ price: price.amount,
1619
+ price_object: { amount: price.amount, currency: price.currency },
1620
+ image: imageUrl || undefined,
1621
+ },
1622
+ quantity,
1623
+ totals: [
1624
+ { type: 'subtotal', amount: subtotal, currency: price.currency },
1625
+ { type: 'total', amount: subtotal, currency: price.currency },
1626
+ ],
1627
+ cart_item: cartItem,
1628
+ });
1629
+ }
1630
+
1631
+ const subtotal = lineItems.reduce((sum, lineItem) => {
1632
+ const totals = Array.isArray(lineItem.totals) ? lineItem.totals : [];
1633
+ const subtotalLine = totals.find((total: any) => total?.type === 'subtotal');
1634
+ return sum + (typeof subtotalLine?.amount === 'number' ? subtotalLine.amount : 0);
1635
+ }, 0);
1636
+
1637
+ return {
1638
+ currencyCode,
1639
+ locale,
1640
+ context,
1641
+ signals,
1642
+ attribution,
1643
+ buyer,
1644
+ lineItems,
1645
+ totals: [
1646
+ { type: 'subtotal', amount: subtotal, currency: currencyCode },
1647
+ {
1648
+ type: 'total',
1649
+ amount: subtotal,
1650
+ currency: currencyCode,
1651
+ display_text: 'Estimated total. Taxes, shipping, and discounts are finalized at checkout.',
1652
+ },
1653
+ ],
1654
+ messages,
1655
+ };
1656
+ }
1657
+
1658
+ function buildCartContinueUrl(baseUrl: string, cartId: string) {
1659
+ return `${baseUrl.replace(/\/+$/, '')}/checkout?ucp_cart=${encodeURIComponent(cartId)}`;
1660
+ }
1661
+
1662
+ function rowToUcpCart(row: any, baseUrl: string) {
1663
+ return {
1664
+ ucp: buildUcpMetadata([UCP_CAPABILITIES.cart]),
1665
+ id: row.id,
1666
+ line_items: row.line_items || [],
1667
+ context: row.context || {},
1668
+ signals: row.signals || {},
1669
+ attribution: row.attribution || {},
1670
+ buyer: row.buyer_identity || {},
1671
+ currency: row.currency,
1672
+ totals: row.totals || [],
1673
+ messages: [],
1674
+ links: [
1675
+ {
1676
+ type: 'self',
1677
+ url: `${baseUrl.replace(/\/+$/, '')}${UCP_REST_BASE_PATH}/carts/${row.id}`,
1678
+ },
1679
+ ],
1680
+ continue_url: row.checkout_url || buildCartContinueUrl(baseUrl, row.id),
1681
+ expires_at: row.expires_at,
1682
+ };
1683
+ }
1684
+
1685
+ export async function createUcpCart(body: unknown, request: Request) {
1686
+ const baseUrl = getOriginFromRequest(request);
1687
+ const cart = await buildCartFromRequest(body);
1688
+
1689
+ if (cart.lineItems.length === 0) {
1690
+ return {
1691
+ status: 200,
1692
+ body: {
1693
+ ucp: buildUcpMetadata([UCP_CAPABILITIES.cart], 'error'),
1694
+ messages:
1695
+ cart.messages.length > 0
1696
+ ? cart.messages
1697
+ : [
1698
+ {
1699
+ type: 'error',
1700
+ code: 'empty_cart',
1701
+ content: 'At least one valid line item is required to create a cart.',
1702
+ severity: 'unrecoverable',
1703
+ },
1704
+ ],
1705
+ continue_url: baseUrl,
1706
+ },
1707
+ };
1708
+ }
1709
+
1710
+ const client = getUcpSupabaseClient();
1711
+ const { data, error } = await client
1712
+ .from('ucp_cart_sessions')
1713
+ .insert({
1714
+ status: 'active',
1715
+ currency: cart.currencyCode,
1716
+ locale: cart.locale,
1717
+ buyer_identity: cart.buyer,
1718
+ context: cart.context,
1719
+ signals: cart.signals,
1720
+ attribution: cart.attribution,
1721
+ line_items: cart.lineItems,
1722
+ totals: cart.totals,
1723
+ checkout_url: null,
1724
+ metadata: {
1725
+ source: 'ucp',
1726
+ version: UCP_VERSION,
1727
+ },
1728
+ })
1729
+ .select('*')
1730
+ .single();
1731
+
1732
+ if (error) {
1733
+ throw error;
1734
+ }
1735
+
1736
+ const checkoutUrl = buildCartContinueUrl(baseUrl, data.id);
1737
+ const { data: updatedRow, error: updateError } = await client
1738
+ .from('ucp_cart_sessions')
1739
+ .update({ checkout_url: checkoutUrl })
1740
+ .eq('id', data.id)
1741
+ .select('*')
1742
+ .single();
1743
+
1744
+ if (updateError) {
1745
+ throw updateError;
1746
+ }
1747
+
1748
+ return {
1749
+ status: 201,
1750
+ body: {
1751
+ ...rowToUcpCart(updatedRow, baseUrl),
1752
+ messages: cart.messages,
1753
+ },
1754
+ };
1755
+ }
1756
+
1757
+ async function fetchActiveCartRow(id: string) {
1758
+ const client = getUcpSupabaseClient();
1759
+ const { data, error } = await client
1760
+ .from('ucp_cart_sessions')
1761
+ .select('*')
1762
+ .eq('id', id)
1763
+ .neq('status', 'cancelled')
1764
+ .gt('expires_at', new Date().toISOString())
1765
+ .maybeSingle();
1766
+
1767
+ if (error) {
1768
+ throw error;
1769
+ }
1770
+
1771
+ return data;
1772
+ }
1773
+
1774
+ export async function getUcpCart(id: string, request: Request) {
1775
+ const baseUrl = getOriginFromRequest(request);
1776
+ const row = await fetchActiveCartRow(id);
1777
+
1778
+ if (!row) {
1779
+ return {
1780
+ status: 200,
1781
+ body: buildUcpBusinessError({
1782
+ capability: UCP_CAPABILITIES.cart,
1783
+ code: 'not_found',
1784
+ content: 'Cart not found or has expired',
1785
+ continueUrl: baseUrl,
1786
+ }),
1787
+ };
1788
+ }
1789
+
1790
+ return {
1791
+ status: 200,
1792
+ body: rowToUcpCart(row, baseUrl),
1793
+ };
1794
+ }
1795
+
1796
+ export async function updateUcpCart(id: string, body: unknown, request: Request) {
1797
+ const baseUrl = getOriginFromRequest(request);
1798
+ const existing = await fetchActiveCartRow(id);
1799
+
1800
+ if (!existing) {
1801
+ return {
1802
+ status: 200,
1803
+ body: buildUcpBusinessError({
1804
+ capability: UCP_CAPABILITIES.cart,
1805
+ code: 'not_found',
1806
+ content: 'Cart not found or has expired',
1807
+ continueUrl: baseUrl,
1808
+ }),
1809
+ };
1810
+ }
1811
+
1812
+ const cart = await buildCartFromRequest(body);
1813
+ if (cart.lineItems.length === 0) {
1814
+ return {
1815
+ status: 200,
1816
+ body: {
1817
+ ucp: buildUcpMetadata([UCP_CAPABILITIES.cart], 'error'),
1818
+ messages:
1819
+ cart.messages.length > 0
1820
+ ? cart.messages
1821
+ : [
1822
+ {
1823
+ type: 'error',
1824
+ code: 'empty_cart',
1825
+ content: 'A full cart replacement must include at least one valid line item.',
1826
+ severity: 'unrecoverable',
1827
+ },
1828
+ ],
1829
+ continue_url: existing.checkout_url || buildCartContinueUrl(baseUrl, existing.id),
1830
+ },
1831
+ };
1832
+ }
1833
+
1834
+ const client = getUcpSupabaseClient();
1835
+ const { data, error } = await client
1836
+ .from('ucp_cart_sessions')
1837
+ .update({
1838
+ currency: cart.currencyCode,
1839
+ locale: cart.locale,
1840
+ buyer_identity: cart.buyer,
1841
+ context: cart.context,
1842
+ signals: cart.signals,
1843
+ attribution: cart.attribution,
1844
+ line_items: cart.lineItems,
1845
+ totals: cart.totals,
1846
+ checkout_url: existing.checkout_url || buildCartContinueUrl(baseUrl, existing.id),
1847
+ })
1848
+ .eq('id', id)
1849
+ .select('*')
1850
+ .single();
1851
+
1852
+ if (error) {
1853
+ throw error;
1854
+ }
1855
+
1856
+ return {
1857
+ status: 200,
1858
+ body: {
1859
+ ...rowToUcpCart(data, baseUrl),
1860
+ messages: cart.messages,
1861
+ },
1862
+ };
1863
+ }
1864
+
1865
+ export async function cancelUcpCart(id: string, request: Request) {
1866
+ const baseUrl = getOriginFromRequest(request);
1867
+ const existing = await fetchActiveCartRow(id);
1868
+
1869
+ if (!existing) {
1870
+ return {
1871
+ status: 200,
1872
+ body: buildUcpBusinessError({
1873
+ capability: UCP_CAPABILITIES.cart,
1874
+ code: 'not_found',
1875
+ content: 'Cart not found or has expired',
1876
+ continueUrl: baseUrl,
1877
+ }),
1878
+ };
1879
+ }
1880
+
1881
+ const client = getUcpSupabaseClient();
1882
+ const { data, error } = await client
1883
+ .from('ucp_cart_sessions')
1884
+ .update({ status: 'cancelled' })
1885
+ .eq('id', id)
1886
+ .select('*')
1887
+ .single();
1888
+
1889
+ if (error) {
1890
+ throw error;
1891
+ }
1892
+
1893
+ return {
1894
+ status: 200,
1895
+ body: rowToUcpCart(data, baseUrl),
1896
+ };
1897
+ }
1898
+
1899
+ export async function getUcpCartCheckoutItems(cartId: string | null | undefined) {
1900
+ if (!cartId) {
1901
+ return [];
1902
+ }
1903
+
1904
+ try {
1905
+ const row = await fetchActiveCartRow(cartId);
1906
+ const lineItems = Array.isArray(row?.line_items) ? row.line_items : [];
1907
+
1908
+ return lineItems
1909
+ .map((lineItem: any) => lineItem?.cart_item)
1910
+ .filter(Boolean) as CartItem[];
1911
+ } catch {
1912
+ return [];
1913
+ }
1914
+ }