create-nextblock 0.15.10 → 0.16.2

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 (266) hide show
  1. package/docker-template/.dockerignore +1 -1
  2. package/libs/db/tsconfig.lib.json +3 -3
  3. package/libs/editor/tsconfig.lib.json +3 -3
  4. package/libs/ui/tsconfig.lib.json +3 -3
  5. package/libs/utils/tsconfig.json +3 -3
  6. package/package.json +50 -50
  7. package/project.json +19 -19
  8. package/templates/nextblock-template/.dockerignore +1 -1
  9. package/templates/nextblock-template/.swcrc +30 -30
  10. package/templates/nextblock-template/AGENTS.md +9 -9
  11. package/templates/nextblock-template/CLAUDE.md +1 -1
  12. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
  13. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
  14. package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
  15. package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
  16. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
  17. package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
  18. package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
  19. package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
  20. package/templates/nextblock-template/app/actions/email.ts +241 -241
  21. package/templates/nextblock-template/app/actions/formActions.ts +245 -245
  22. package/templates/nextblock-template/app/actions/interactions.ts +489 -489
  23. package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
  24. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
  25. package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -0
  26. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -0
  27. package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
  28. package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
  29. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +3092 -2900
  30. package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
  31. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  32. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  33. package/templates/nextblock-template/app/api/view/route.ts +114 -114
  34. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  35. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  36. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  37. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  38. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  39. package/templates/nextblock-template/app/checkout/page.tsx +57 -57
  40. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -552
  41. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +27 -0
  42. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
  43. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  44. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
  45. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +180 -3
  46. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +171 -6
  47. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  48. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  49. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  50. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +12 -3
  51. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  52. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  53. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  54. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  55. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  56. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  57. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  58. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  59. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  60. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  61. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  62. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  63. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  64. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  65. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +177 -2
  66. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  67. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
  68. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
  69. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
  70. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
  71. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
  72. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  73. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +28 -0
  74. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +248 -5
  75. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +33 -0
  76. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +240 -5
  77. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  78. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  79. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  80. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  81. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  82. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  83. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  84. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  85. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  86. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  87. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  88. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  90. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  91. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  92. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  93. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  95. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  96. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  97. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  98. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  99. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  100. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  101. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  102. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  103. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -0
  104. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -0
  105. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -0
  106. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -0
  107. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -0
  108. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -0
  109. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -0
  110. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  111. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  112. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  113. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  114. package/templates/nextblock-template/app/layout.tsx +671 -671
  115. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  116. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  117. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  118. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  119. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  120. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  121. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  122. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  123. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  124. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  125. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  126. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  127. package/templates/nextblock-template/app/providers.tsx +96 -96
  128. package/templates/nextblock-template/app/robots.ts +123 -0
  129. package/templates/nextblock-template/app/sitemap.ts +1 -1
  130. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
  131. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
  132. package/templates/nextblock-template/app/thread/layout.tsx +15 -15
  133. package/templates/nextblock-template/app/thread/page.tsx +98 -98
  134. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
  135. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
  136. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
  137. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
  138. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
  139. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
  140. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
  141. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  142. package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
  143. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
  144. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  145. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
  146. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
  147. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  148. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  149. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  150. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
  151. package/templates/nextblock-template/components/Header.tsx +38 -38
  152. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  153. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  154. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
  155. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
  156. package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
  157. package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
  158. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  159. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  160. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
  161. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
  162. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  163. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
  164. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
  165. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
  166. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
  167. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
  168. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
  169. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
  170. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
  171. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
  172. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
  173. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
  174. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
  175. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
  176. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  177. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
  178. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  179. package/templates/nextblock-template/components/form-message.tsx +32 -32
  180. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -0
  181. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -0
  182. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -0
  183. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -0
  184. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -0
  185. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -0
  186. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -0
  187. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  188. package/templates/nextblock-template/components/theme-icon.tsx +78 -78
  189. package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
  190. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  191. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
  192. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  193. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
  194. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
  195. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
  196. package/templates/nextblock-template/docs/README.md +42 -42
  197. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
  198. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  199. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  200. package/templates/nextblock-template/index.d.ts +7 -7
  201. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  202. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
  203. package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
  204. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
  205. package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
  206. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
  207. package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
  208. package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
  209. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
  210. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
  211. package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
  212. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
  213. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -0
  214. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -0
  215. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
  216. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
  217. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
  218. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
  219. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
  220. package/templates/nextblock-template/lib/messages/threads.ts +579 -579
  221. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  222. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
  223. package/templates/nextblock-template/lib/search/types.ts +27 -27
  224. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -0
  225. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -0
  226. package/templates/nextblock-template/lib/seo/block-content.ts +123 -0
  227. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -0
  228. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -0
  229. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -0
  230. package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -0
  231. package/templates/nextblock-template/lib/seo/page-document.ts +412 -0
  232. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -0
  233. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -0
  234. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -0
  235. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -0
  236. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -0
  237. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -0
  238. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -167
  239. package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
  240. package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
  241. package/templates/nextblock-template/lib/site-url.test.ts +89 -89
  242. package/templates/nextblock-template/lib/site-url.ts +102 -102
  243. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
  244. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
  245. package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
  246. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
  247. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
  248. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
  249. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
  250. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
  251. package/templates/nextblock-template/lib/zod-config.ts +5 -5
  252. package/templates/nextblock-template/next-env.d.ts +0 -1
  253. package/templates/nextblock-template/package.json +1 -1
  254. package/templates/nextblock-template/postcss.config.js +6 -6
  255. package/templates/nextblock-template/proxy.ts +240 -20
  256. package/templates/nextblock-template/scripts/backup.js +115 -115
  257. package/templates/nextblock-template/scripts/restore.js +385 -385
  258. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  259. package/templates/nextblock-template/tailwind.config.js +25 -25
  260. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  261. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  262. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  263. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  264. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  265. package/tsconfig.base.json +3 -3
  266. package/templates/nextblock-template/app/robots.txt/route.ts +0 -32
@@ -1,222 +1,222 @@
1
- 'use server';
2
-
3
- import { headers } from 'next/headers';
4
- import { after } from 'next/server';
5
- import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
6
-
7
- import { resolveSellerContactEmail } from '../../lib/commerce/seller-contact';
8
- import { createThread, notifyAdminOfMessage } from '../../lib/messages/threads';
9
- import { verifyBotProtection } from '../../lib/botProtection/verify';
10
-
11
- /**
12
- * Public "contact the seller" enquiry, shown in place of Add-to-Cart when the store
13
- * cannot take payment for a product.
14
- *
15
- * Two rules shape this module:
16
- *
17
- * 1. The recipient is resolved ENTIRELY server-side. The existing form-block action
18
- * takes its recipient from a bound argument, which means the address travels in the
19
- * client payload; doing that here would publish the shop owner's inbox on every
20
- * product page and turn the store's SMTP credentials into an open relay.
21
- *
22
- * 2. The database row is the deliverable, not the email. A store with no payment keys
23
- * very often has no SMTP either, so the enquiry is persisted FIRST and the
24
- * notification attempted afterwards. A failed send downgrades to `email_delivered:
25
- * false` — the visitor is still told their message got through, because it did.
26
- */
27
-
28
- const MAX_NAME_LENGTH = 120;
29
- const MAX_EMAIL_LENGTH = 254;
30
- const MAX_MESSAGE_LENGTH = 2000;
31
- const MAX_USER_AGENT_LENGTH = 500;
32
-
33
- /** Per-IP submission cap. Deliberately generous — this is anti-flood, not anti-user. */
34
- const THROTTLE_WINDOW_MINUTES = 10;
35
- const THROTTLE_MAX_SUBMISSIONS = 5;
36
-
37
- /**
38
- * Throttle bucket for a request whose origin cannot be determined. Deliberately a single
39
- * shared key: unattributable requests are throttled TOGETHER rather than exempted.
40
- *
41
- * This is the difference between this helper and the visually similar one in
42
- * app/actions/consent.ts. There, a null mask just means one audit row is less precise.
43
- * Here the value is load-bearing for a rate limit, so "I could not parse that" must
44
- * never be allowed to mean "no limit applies".
45
- */
46
- const UNKNOWN_IP_BUCKET = 'unknown';
47
-
48
- /**
49
- * Best available client address, preferring headers the platform sets over ones the
50
- * client can write.
51
- *
52
- * `x-forwarded-for` is a list the client can prepend to: on an appending proxy (nginx's
53
- * proxy_add_x_forwarded_for, the shape this repo's Dockerfile deploys) its LEFTMOST
54
- * entry is attacker-controlled, so keying a throttle on it lets one caller mint a fresh
55
- * bucket per request. `x-vercel-forwarded-for` and `x-real-ip` are overwritten by the
56
- * proxy on every request, so they are checked first.
57
- */
58
- function firstHop(headerValue: string | null): string | null {
59
- const [first] = (headerValue ?? '').split(',');
60
- const trimmed = first?.trim() ?? '';
61
- return trimmed || null;
62
- }
63
-
64
- function resolveClientIp(requestHeaders: Headers): string | null {
65
- return (
66
- firstHop(requestHeaders.get('x-vercel-forwarded-for')) ??
67
- firstHop(requestHeaders.get('x-real-ip')) ??
68
- firstHop(requestHeaders.get('x-forwarded-for'))
69
- );
70
- }
71
-
72
- /** Reduce an address to a non-identifying prefix — never store a full IP. */
73
- function maskIp(ip: string | null): string {
74
- if (!ip) return UNKNOWN_IP_BUCKET;
75
- if (ip.includes(':')) {
76
- const hextets = ip.split(':').filter(Boolean);
77
- return `${hextets.slice(0, 3).join(':')}::x`;
78
- }
79
- const octets = ip.split('.');
80
- if (octets.length === 4) {
81
- octets[3] = 'x';
82
- return octets.join('.');
83
- }
84
- return UNKNOWN_IP_BUCKET;
85
- }
86
-
87
- function readField(formData: FormData, name: string, maxLength: number): string {
88
- const raw = formData.get(name);
89
- return typeof raw === 'string' ? raw.trim().slice(0, maxLength) : '';
90
- }
91
-
92
- function isPlausibleEmail(value: string): boolean {
93
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
94
- }
95
-
96
- export interface ContactSellerState {
97
- success: boolean;
98
- /** Translation key the client renders, with its own English fallback. */
99
- messageKey:
100
- | 'ecommerce.contact_seller_sent'
101
- | 'ecommerce.contact_seller_error'
102
- | 'ecommerce.contact_seller_throttled'
103
- | 'ecommerce.contact_seller_invalid'
104
- | '';
105
- /** Set only for captcha failures, which carry their own provider-specific text. */
106
- message?: string;
107
- }
108
-
109
- export async function submitProductInquiry(
110
- _prevState: unknown,
111
- formData: FormData
112
- ): Promise<ContactSellerState> {
113
- const verification = await verifyBotProtection(formData);
114
- if (!verification.ok) {
115
- // Fake a success for the honeypot so the bot learns nothing about the check.
116
- if (verification.reason === 'honeypot') {
117
- return { success: true, messageKey: 'ecommerce.contact_seller_sent' };
118
- }
119
- return { success: false, messageKey: '', message: verification.message };
120
- }
121
-
122
- const productId = readField(formData, 'product_id', 64);
123
- const senderName = readField(formData, 'name', MAX_NAME_LENGTH);
124
- const senderEmail = readField(formData, 'email', MAX_EMAIL_LENGTH);
125
- const message = readField(formData, 'message', MAX_MESSAGE_LENGTH);
126
- const locale = readField(formData, 'locale', 12) || null;
127
-
128
- if (!productId || !senderName || !senderEmail || !message || !isPlausibleEmail(senderEmail)) {
129
- return { success: false, messageKey: 'ecommerce.contact_seller_invalid' };
130
- }
131
-
132
- try {
133
- const supabase = getServiceRoleSupabaseClient();
134
-
135
- const requestHeaders = await headers();
136
- // Always a string, so the throttle below always runs. See UNKNOWN_IP_BUCKET.
137
- const ipMasked = maskIp(resolveClientIp(requestHeaders));
138
- const userAgent = requestHeaders.get('user-agent');
139
-
140
- const since = new Date(Date.now() - THROTTLE_WINDOW_MINUTES * 60_000).toISOString();
141
- const { count } = await supabase
142
- .from('product_inquiries')
143
- .select('id', { count: 'exact', head: true })
144
- .eq('ip_masked', ipMasked)
145
- .gte('created_at', since);
146
-
147
- if ((count ?? 0) >= THROTTLE_MAX_SUBMISSIONS) {
148
- return { success: false, messageKey: 'ecommerce.contact_seller_throttled' };
149
- }
150
-
151
- // Look the product up server-side: a client-supplied title would let anyone send
152
- // arbitrary text through the owner's own SMTP identity.
153
- const { data: product } = await supabase
154
- .from('products')
155
- .select('id, title, slug')
156
- .eq('id', productId)
157
- .maybeSingle();
158
-
159
- if (!product) {
160
- return { success: false, messageKey: 'ecommerce.contact_seller_invalid' };
161
- }
162
-
163
- const { data: inserted, error: insertError } = await supabase
164
- .from('product_inquiries')
165
- .insert({
166
- product_id: product.id,
167
- product_slug: product.slug,
168
- product_title: product.title,
169
- sender_name: senderName,
170
- sender_email: senderEmail,
171
- message,
172
- locale,
173
- ip_masked: ipMasked,
174
- user_agent: userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null,
175
- })
176
- .select('id')
177
- .single();
178
-
179
- if (insertError) {
180
- console.error('Failed to record product inquiry:', insertError.message);
181
- return { success: false, messageKey: 'ecommerce.contact_seller_error' };
182
- }
183
-
184
- // Open the conversation this enquiry belongs to. The enquiry row above stays as
185
- // the enquiry's own record (and owns the ip_masked the throttle counts); the thread
186
- // is what the owner actually replies in.
187
- const thread = await createThread({
188
- source: 'product_inquiry',
189
- subjectId: inserted.id,
190
- subjectLabel: product.title,
191
- senderName,
192
- senderEmail,
193
- message,
194
- locale,
195
- ipMasked,
196
- userAgent: userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null,
197
- });
198
-
199
- // Notification is best-effort and runs after the response. A bare floating promise
200
- // would risk the serverless instance being frozen before the SMTP round trip ends.
201
- if (thread) {
202
- after(async () => {
203
- const { email: recipient } = await resolveSellerContactEmail();
204
- await notifyAdminOfMessage({
205
- threadId: thread.threadId,
206
- source: 'product_inquiry',
207
- messageId: thread.messageId,
208
- subjectLabel: product.title,
209
- senderName,
210
- senderEmail,
211
- message,
212
- recipient,
213
- });
214
- });
215
- }
216
-
217
- return { success: true, messageKey: 'ecommerce.contact_seller_sent' };
218
- } catch (error) {
219
- console.error('Product inquiry submission failed:', error);
220
- return { success: false, messageKey: 'ecommerce.contact_seller_error' };
221
- }
222
- }
1
+ 'use server';
2
+
3
+ import { headers } from 'next/headers';
4
+ import { after } from 'next/server';
5
+ import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
6
+
7
+ import { resolveSellerContactEmail } from '../../lib/commerce/seller-contact';
8
+ import { createThread, notifyAdminOfMessage } from '../../lib/messages/threads';
9
+ import { verifyBotProtection } from '../../lib/botProtection/verify';
10
+
11
+ /**
12
+ * Public "contact the seller" enquiry, shown in place of Add-to-Cart when the store
13
+ * cannot take payment for a product.
14
+ *
15
+ * Two rules shape this module:
16
+ *
17
+ * 1. The recipient is resolved ENTIRELY server-side. The existing form-block action
18
+ * takes its recipient from a bound argument, which means the address travels in the
19
+ * client payload; doing that here would publish the shop owner's inbox on every
20
+ * product page and turn the store's SMTP credentials into an open relay.
21
+ *
22
+ * 2. The database row is the deliverable, not the email. A store with no payment keys
23
+ * very often has no SMTP either, so the enquiry is persisted FIRST and the
24
+ * notification attempted afterwards. A failed send downgrades to `email_delivered:
25
+ * false` — the visitor is still told their message got through, because it did.
26
+ */
27
+
28
+ const MAX_NAME_LENGTH = 120;
29
+ const MAX_EMAIL_LENGTH = 254;
30
+ const MAX_MESSAGE_LENGTH = 2000;
31
+ const MAX_USER_AGENT_LENGTH = 500;
32
+
33
+ /** Per-IP submission cap. Deliberately generous — this is anti-flood, not anti-user. */
34
+ const THROTTLE_WINDOW_MINUTES = 10;
35
+ const THROTTLE_MAX_SUBMISSIONS = 5;
36
+
37
+ /**
38
+ * Throttle bucket for a request whose origin cannot be determined. Deliberately a single
39
+ * shared key: unattributable requests are throttled TOGETHER rather than exempted.
40
+ *
41
+ * This is the difference between this helper and the visually similar one in
42
+ * app/actions/consent.ts. There, a null mask just means one audit row is less precise.
43
+ * Here the value is load-bearing for a rate limit, so "I could not parse that" must
44
+ * never be allowed to mean "no limit applies".
45
+ */
46
+ const UNKNOWN_IP_BUCKET = 'unknown';
47
+
48
+ /**
49
+ * Best available client address, preferring headers the platform sets over ones the
50
+ * client can write.
51
+ *
52
+ * `x-forwarded-for` is a list the client can prepend to: on an appending proxy (nginx's
53
+ * proxy_add_x_forwarded_for, the shape this repo's Dockerfile deploys) its LEFTMOST
54
+ * entry is attacker-controlled, so keying a throttle on it lets one caller mint a fresh
55
+ * bucket per request. `x-vercel-forwarded-for` and `x-real-ip` are overwritten by the
56
+ * proxy on every request, so they are checked first.
57
+ */
58
+ function firstHop(headerValue: string | null): string | null {
59
+ const [first] = (headerValue ?? '').split(',');
60
+ const trimmed = first?.trim() ?? '';
61
+ return trimmed || null;
62
+ }
63
+
64
+ function resolveClientIp(requestHeaders: Headers): string | null {
65
+ return (
66
+ firstHop(requestHeaders.get('x-vercel-forwarded-for')) ??
67
+ firstHop(requestHeaders.get('x-real-ip')) ??
68
+ firstHop(requestHeaders.get('x-forwarded-for'))
69
+ );
70
+ }
71
+
72
+ /** Reduce an address to a non-identifying prefix — never store a full IP. */
73
+ function maskIp(ip: string | null): string {
74
+ if (!ip) return UNKNOWN_IP_BUCKET;
75
+ if (ip.includes(':')) {
76
+ const hextets = ip.split(':').filter(Boolean);
77
+ return `${hextets.slice(0, 3).join(':')}::x`;
78
+ }
79
+ const octets = ip.split('.');
80
+ if (octets.length === 4) {
81
+ octets[3] = 'x';
82
+ return octets.join('.');
83
+ }
84
+ return UNKNOWN_IP_BUCKET;
85
+ }
86
+
87
+ function readField(formData: FormData, name: string, maxLength: number): string {
88
+ const raw = formData.get(name);
89
+ return typeof raw === 'string' ? raw.trim().slice(0, maxLength) : '';
90
+ }
91
+
92
+ function isPlausibleEmail(value: string): boolean {
93
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
94
+ }
95
+
96
+ export interface ContactSellerState {
97
+ success: boolean;
98
+ /** Translation key the client renders, with its own English fallback. */
99
+ messageKey:
100
+ | 'ecommerce.contact_seller_sent'
101
+ | 'ecommerce.contact_seller_error'
102
+ | 'ecommerce.contact_seller_throttled'
103
+ | 'ecommerce.contact_seller_invalid'
104
+ | '';
105
+ /** Set only for captcha failures, which carry their own provider-specific text. */
106
+ message?: string;
107
+ }
108
+
109
+ export async function submitProductInquiry(
110
+ _prevState: unknown,
111
+ formData: FormData
112
+ ): Promise<ContactSellerState> {
113
+ const verification = await verifyBotProtection(formData);
114
+ if (!verification.ok) {
115
+ // Fake a success for the honeypot so the bot learns nothing about the check.
116
+ if (verification.reason === 'honeypot') {
117
+ return { success: true, messageKey: 'ecommerce.contact_seller_sent' };
118
+ }
119
+ return { success: false, messageKey: '', message: verification.message };
120
+ }
121
+
122
+ const productId = readField(formData, 'product_id', 64);
123
+ const senderName = readField(formData, 'name', MAX_NAME_LENGTH);
124
+ const senderEmail = readField(formData, 'email', MAX_EMAIL_LENGTH);
125
+ const message = readField(formData, 'message', MAX_MESSAGE_LENGTH);
126
+ const locale = readField(formData, 'locale', 12) || null;
127
+
128
+ if (!productId || !senderName || !senderEmail || !message || !isPlausibleEmail(senderEmail)) {
129
+ return { success: false, messageKey: 'ecommerce.contact_seller_invalid' };
130
+ }
131
+
132
+ try {
133
+ const supabase = getServiceRoleSupabaseClient();
134
+
135
+ const requestHeaders = await headers();
136
+ // Always a string, so the throttle below always runs. See UNKNOWN_IP_BUCKET.
137
+ const ipMasked = maskIp(resolveClientIp(requestHeaders));
138
+ const userAgent = requestHeaders.get('user-agent');
139
+
140
+ const since = new Date(Date.now() - THROTTLE_WINDOW_MINUTES * 60_000).toISOString();
141
+ const { count } = await supabase
142
+ .from('product_inquiries')
143
+ .select('id', { count: 'exact', head: true })
144
+ .eq('ip_masked', ipMasked)
145
+ .gte('created_at', since);
146
+
147
+ if ((count ?? 0) >= THROTTLE_MAX_SUBMISSIONS) {
148
+ return { success: false, messageKey: 'ecommerce.contact_seller_throttled' };
149
+ }
150
+
151
+ // Look the product up server-side: a client-supplied title would let anyone send
152
+ // arbitrary text through the owner's own SMTP identity.
153
+ const { data: product } = await supabase
154
+ .from('products')
155
+ .select('id, title, slug')
156
+ .eq('id', productId)
157
+ .maybeSingle();
158
+
159
+ if (!product) {
160
+ return { success: false, messageKey: 'ecommerce.contact_seller_invalid' };
161
+ }
162
+
163
+ const { data: inserted, error: insertError } = await supabase
164
+ .from('product_inquiries')
165
+ .insert({
166
+ product_id: product.id,
167
+ product_slug: product.slug,
168
+ product_title: product.title,
169
+ sender_name: senderName,
170
+ sender_email: senderEmail,
171
+ message,
172
+ locale,
173
+ ip_masked: ipMasked,
174
+ user_agent: userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null,
175
+ })
176
+ .select('id')
177
+ .single();
178
+
179
+ if (insertError) {
180
+ console.error('Failed to record product inquiry:', insertError.message);
181
+ return { success: false, messageKey: 'ecommerce.contact_seller_error' };
182
+ }
183
+
184
+ // Open the conversation this enquiry belongs to. The enquiry row above stays as
185
+ // the enquiry's own record (and owns the ip_masked the throttle counts); the thread
186
+ // is what the owner actually replies in.
187
+ const thread = await createThread({
188
+ source: 'product_inquiry',
189
+ subjectId: inserted.id,
190
+ subjectLabel: product.title,
191
+ senderName,
192
+ senderEmail,
193
+ message,
194
+ locale,
195
+ ipMasked,
196
+ userAgent: userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null,
197
+ });
198
+
199
+ // Notification is best-effort and runs after the response. A bare floating promise
200
+ // would risk the serverless instance being frozen before the SMTP round trip ends.
201
+ if (thread) {
202
+ after(async () => {
203
+ const { email: recipient } = await resolveSellerContactEmail();
204
+ await notifyAdminOfMessage({
205
+ threadId: thread.threadId,
206
+ source: 'product_inquiry',
207
+ messageId: thread.messageId,
208
+ subjectLabel: product.title,
209
+ senderName,
210
+ senderEmail,
211
+ message,
212
+ recipient,
213
+ });
214
+ });
215
+ }
216
+
217
+ return { success: true, messageKey: 'ecommerce.contact_seller_sent' };
218
+ } catch (error) {
219
+ console.error('Product inquiry submission failed:', error);
220
+ return { success: false, messageKey: 'ecommerce.contact_seller_error' };
221
+ }
222
+ }
@@ -1,62 +1,62 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import { describeSmtpError } from './email';
4
-
5
- /**
6
- * A reply failed with "The mail server did not respond in time" on an install whose
7
- * transport verifies in about a second and whose identical send succeeds moments later.
8
- * That signature — everything healthy, one send hangs — is a pooled connection the relay
9
- * reaped while it sat idle: nodemailer still believes it is open, so the send blocks
10
- * until the socket timeout.
11
- *
12
- * sendEmail now drops the pool and retries once on a fresh connection for that class of
13
- * fault only. These cover the classification, which is what decides retry vs report.
14
- */
15
-
16
- describe('describeSmtpError', () => {
17
- it('explains the TLS/port mismatch that produced an unreadable OpenSSL error', () => {
18
- const message = describeSmtpError(
19
- new Error('B4730000:error:0A00010B:SSL routines:ssl3_get_record:wrong version number:ssl3_record.c:355:')
20
- );
21
- expect(message).toContain('2525');
22
- expect(message).toContain('465');
23
- expect(message).not.toContain('ssl3_get_record');
24
- });
25
-
26
- it('names credentials as the cause of an auth rejection', () => {
27
- const error = Object.assign(new Error('Invalid login: 535 authentication failed'), {
28
- code: 'EAUTH',
29
- });
30
- expect(describeSmtpError(error)).toMatch(/username or password/i);
31
- });
32
-
33
- it('distinguishes a timeout from an unreachable host', () => {
34
- const timeout = Object.assign(new Error('Connection timeout'), { code: 'ETIMEDOUT' });
35
- expect(describeSmtpError(timeout)).toMatch(/did not respond in time/i);
36
-
37
- const dns = Object.assign(new Error('getaddrinfo ENOTFOUND smtp.example.com'), {
38
- code: 'ENOTFOUND',
39
- });
40
- expect(describeSmtpError(dns)).toMatch(/hostname could not be resolved/i);
41
-
42
- const refused = Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' });
43
- expect(describeSmtpError(refused)).toMatch(/refused the connection/i);
44
- });
45
-
46
- it('tells an unconfigured install what to do rather than echoing the throw', () => {
47
- expect(describeSmtpError(new Error('Email server is not configured.'))).toMatch(
48
- /CMS Settings/i
49
- );
50
- });
51
-
52
- it('passes an unrecognised failure through rather than inventing an explanation', () => {
53
- expect(describeSmtpError(new Error('552 5.3.4 Message too big'))).toBe(
54
- '552 5.3.4 Message too big'
55
- );
56
- });
57
-
58
- it('handles a non-Error throw without crashing', () => {
59
- expect(describeSmtpError('something odd')).toBe('something odd');
60
- expect(describeSmtpError(null)).toBe('null');
61
- });
62
- });
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { describeSmtpError } from './email';
4
+
5
+ /**
6
+ * A reply failed with "The mail server did not respond in time" on an install whose
7
+ * transport verifies in about a second and whose identical send succeeds moments later.
8
+ * That signature — everything healthy, one send hangs — is a pooled connection the relay
9
+ * reaped while it sat idle: nodemailer still believes it is open, so the send blocks
10
+ * until the socket timeout.
11
+ *
12
+ * sendEmail now drops the pool and retries once on a fresh connection for that class of
13
+ * fault only. These cover the classification, which is what decides retry vs report.
14
+ */
15
+
16
+ describe('describeSmtpError', () => {
17
+ it('explains the TLS/port mismatch that produced an unreadable OpenSSL error', () => {
18
+ const message = describeSmtpError(
19
+ new Error('B4730000:error:0A00010B:SSL routines:ssl3_get_record:wrong version number:ssl3_record.c:355:')
20
+ );
21
+ expect(message).toContain('2525');
22
+ expect(message).toContain('465');
23
+ expect(message).not.toContain('ssl3_get_record');
24
+ });
25
+
26
+ it('names credentials as the cause of an auth rejection', () => {
27
+ const error = Object.assign(new Error('Invalid login: 535 authentication failed'), {
28
+ code: 'EAUTH',
29
+ });
30
+ expect(describeSmtpError(error)).toMatch(/username or password/i);
31
+ });
32
+
33
+ it('distinguishes a timeout from an unreachable host', () => {
34
+ const timeout = Object.assign(new Error('Connection timeout'), { code: 'ETIMEDOUT' });
35
+ expect(describeSmtpError(timeout)).toMatch(/did not respond in time/i);
36
+
37
+ const dns = Object.assign(new Error('getaddrinfo ENOTFOUND smtp.example.com'), {
38
+ code: 'ENOTFOUND',
39
+ });
40
+ expect(describeSmtpError(dns)).toMatch(/hostname could not be resolved/i);
41
+
42
+ const refused = Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' });
43
+ expect(describeSmtpError(refused)).toMatch(/refused the connection/i);
44
+ });
45
+
46
+ it('tells an unconfigured install what to do rather than echoing the throw', () => {
47
+ expect(describeSmtpError(new Error('Email server is not configured.'))).toMatch(
48
+ /CMS Settings/i
49
+ );
50
+ });
51
+
52
+ it('passes an unrecognised failure through rather than inventing an explanation', () => {
53
+ expect(describeSmtpError(new Error('552 5.3.4 Message too big'))).toBe(
54
+ '552 5.3.4 Message too big'
55
+ );
56
+ });
57
+
58
+ it('handles a non-Error throw without crashing', () => {
59
+ expect(describeSmtpError('something odd')).toBe('something odd');
60
+ expect(describeSmtpError(null)).toBe('null');
61
+ });
62
+ });