create-nextblock 0.14.1 → 0.14.4

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 (263) hide show
  1. package/docker-template/.dockerignore +23 -23
  2. package/docker-template/.env.docker.example +69 -69
  3. package/docker-template/docker/db/init/99-jwt.sql +6 -6
  4. package/docker-template/docker/db/init/99-roles.sql +25 -25
  5. package/docker-template/docker/kong/kong.yml +112 -112
  6. package/docker-template/scripts/docker-setup.mjs +310 -310
  7. package/libs/db/tsconfig.lib.json +3 -3
  8. package/libs/editor/tsconfig.lib.json +3 -3
  9. package/libs/ui/tsconfig.lib.json +3 -3
  10. package/libs/utils/tsconfig.json +3 -3
  11. package/package.json +1 -1
  12. package/project.json +19 -19
  13. package/templates/nextblock-template/.browserslistrc +11 -11
  14. package/templates/nextblock-template/.dockerignore +23 -23
  15. package/templates/nextblock-template/README.md +34 -34
  16. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
  17. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
  18. package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +46 -46
  19. package/templates/nextblock-template/app/(auth-pages)/two-factor/page.tsx +51 -51
  20. package/templates/nextblock-template/app/ToasterProvider.tsx +17 -17
  21. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
  22. package/templates/nextblock-template/app/actions/consent.ts +57 -57
  23. package/templates/nextblock-template/app/actions/postActions.ts +146 -146
  24. package/templates/nextblock-template/app/actions/twoFactorEmail.ts +22 -22
  25. package/templates/nextblock-template/app/api/ai/cortex/build-widget/route.ts +153 -153
  26. package/templates/nextblock-template/app/api/ai/generate-blocks/route.ts +96 -96
  27. package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -48
  28. package/templates/nextblock-template/app/api/cms/check-updates/route.ts +44 -44
  29. package/templates/nextblock-template/app/api/cms/full-backup/export/route.ts +33 -33
  30. package/templates/nextblock-template/app/api/cms/full-backup/restore/route.ts +63 -63
  31. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +3490 -3490
  32. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +5881 -5623
  33. package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +39 -39
  34. package/templates/nextblock-template/app/api/custom-blocks/db-relations/route.ts +92 -92
  35. package/templates/nextblock-template/app/api/custom-blocks/editor-definitions/route.ts +43 -43
  36. package/templates/nextblock-template/app/api/media/library/route.ts +69 -69
  37. package/templates/nextblock-template/app/api/media/r2-presigned/route.ts +53 -53
  38. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  39. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  40. package/templates/nextblock-template/app/article/[slug]/PostClientContent.tsx +441 -441
  41. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  42. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  43. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +2 -2
  44. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +17 -22
  45. package/templates/nextblock-template/app/cms/blocks/components/CustomBlockEditorPreview.tsx +160 -160
  46. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +13 -19
  47. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  48. package/templates/nextblock-template/app/cms/blocks/editors/DynamicCustomBlockEditor.tsx +167 -167
  49. package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +45 -34
  50. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +4 -2
  51. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +90 -90
  52. package/templates/nextblock-template/app/cms/components/ConnectGitHubButton.tsx +122 -122
  53. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  54. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  55. package/templates/nextblock-template/app/cms/components/SystemAlertsBanner.tsx +112 -112
  56. package/templates/nextblock-template/app/cms/components/TwoFactorReminderBanner.tsx +45 -45
  57. package/templates/nextblock-template/app/cms/components/github-connect-actions.ts +102 -102
  58. package/templates/nextblock-template/app/cms/components/system-alerts-actions.ts +31 -31
  59. package/templates/nextblock-template/app/cms/custom-blocks/[id]/edit/page.tsx +66 -66
  60. package/templates/nextblock-template/app/cms/custom-blocks/actions.ts +519 -519
  61. package/templates/nextblock-template/app/cms/custom-blocks/components/BlocksLibraryTransferControls.tsx +256 -256
  62. package/templates/nextblock-template/app/cms/custom-blocks/components/DBRelationSelect.tsx +384 -384
  63. package/templates/nextblock-template/app/cms/custom-blocks/components/ImageR2Picker.tsx +221 -221
  64. package/templates/nextblock-template/app/cms/custom-blocks/new/page.tsx +12 -12
  65. package/templates/nextblock-template/app/cms/custom-blocks/page.tsx +438 -438
  66. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  67. package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +130 -130
  68. package/templates/nextblock-template/app/cms/import-export/actions.ts +226 -226
  69. package/templates/nextblock-template/app/cms/layout.tsx +73 -73
  70. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  71. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  72. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +26 -26
  73. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  74. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  75. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  76. package/templates/nextblock-template/app/cms/products/categories/page.tsx +12 -12
  77. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  78. package/templates/nextblock-template/app/cms/products/new/page.tsx +135 -135
  79. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  80. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  81. package/templates/nextblock-template/app/cms/promotions/PromotionsWorkspace.tsx +456 -456
  82. package/templates/nextblock-template/app/cms/promotions/actions.ts +115 -115
  83. package/templates/nextblock-template/app/cms/promotions/page.tsx +31 -31
  84. package/templates/nextblock-template/app/cms/revisions/service.ts +19 -19
  85. package/templates/nextblock-template/app/cms/revisions/utils.ts +132 -132
  86. package/templates/nextblock-template/app/cms/settings/backup-restore/BackupRestoreWorkspace.tsx +1004 -1004
  87. package/templates/nextblock-template/app/cms/settings/backup-restore/page.tsx +29 -29
  88. package/templates/nextblock-template/app/cms/settings/bot-protection/page.tsx +24 -24
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +688 -688
  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/page.tsx +28 -28
  93. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  95. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -0
  96. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -0
  97. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -24
  98. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -0
  99. package/templates/nextblock-template/app/cms/settings/google-analytics/page.tsx +26 -26
  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/components/DeleteLogoButton.tsx +21 -21
  103. package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +23 -21
  104. package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -42
  105. package/templates/nextblock-template/app/cms/settings/logos/components/SiteSeoSettingsForm.tsx +133 -133
  106. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  107. package/templates/nextblock-template/app/cms/settings/packages/package-card.tsx +122 -122
  108. package/templates/nextblock-template/app/cms/settings/privacy/page.tsx +27 -27
  109. package/templates/nextblock-template/app/cms/settings/registration/page.tsx +27 -27
  110. package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +9 -7
  111. package/templates/nextblock-template/app/cms/settings/security/page.tsx +33 -33
  112. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  113. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  114. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  115. package/templates/nextblock-template/app/layout.tsx +49 -0
  116. package/templates/nextblock-template/app/lib/site-settings.ts +105 -105
  117. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  118. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  119. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  120. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  121. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  122. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  123. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  124. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  125. package/templates/nextblock-template/app/providers.tsx +16 -4
  126. package/templates/nextblock-template/app/setup/SetupWizard.tsx +678 -678
  127. package/templates/nextblock-template/app/setup/layout.tsx +13 -13
  128. package/templates/nextblock-template/app/setup/page.tsx +111 -111
  129. package/templates/nextblock-template/app/sitemap.ts +130 -130
  130. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  131. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  132. package/templates/nextblock-template/components/DeferredGoogleAnalytics.tsx +70 -70
  133. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  134. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  135. package/templates/nextblock-template/components/FeatureImageHero.tsx +47 -47
  136. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  137. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  138. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  139. package/templates/nextblock-template/components/PublicEnvBootstrap.tsx +30 -30
  140. package/templates/nextblock-template/components/ResponsiveNav.tsx +14 -14
  141. package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -182
  142. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  143. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  144. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  145. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +249 -249
  146. package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +7 -10
  147. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  148. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  149. package/templates/nextblock-template/components/form-message.tsx +32 -32
  150. package/templates/nextblock-template/components/media/YouTubeFacade.tsx +105 -105
  151. package/templates/nextblock-template/components/media/youtube-embed-replace.tsx +32 -32
  152. package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +170 -170
  153. package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +70 -70
  154. package/templates/nextblock-template/components/renderers/CachedDynamicLayoutEngine.tsx +28 -28
  155. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.test.tsx +166 -166
  156. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.tsx +471 -471
  157. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  158. package/templates/nextblock-template/components/theme-icon.tsx +78 -0
  159. package/templates/nextblock-template/components/theme-switcher.tsx +77 -82
  160. package/templates/nextblock-template/components/visual-editing/DeferredVisualEditing.tsx +21 -21
  161. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  162. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -0
  163. package/templates/nextblock-template/context/language-rest-client.ts +32 -32
  164. package/templates/nextblock-template/docker/db/init/99-jwt.sql +6 -6
  165. package/templates/nextblock-template/docker/db/init/99-roles.sql +25 -25
  166. package/templates/nextblock-template/docker/kong/kong.yml +112 -112
  167. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  168. package/templates/nextblock-template/docs/02-ECOMMERCE-CAPABILITIES.md +364 -364
  169. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +279 -202
  170. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +246 -246
  171. package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +176 -176
  172. package/templates/nextblock-template/docs/07-BLOCK-SDK-AND-EXTENSIBILITY.md +146 -146
  173. package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +222 -222
  174. package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +173 -173
  175. package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +170 -170
  176. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +151 -151
  177. package/templates/nextblock-template/docs/README.md +39 -39
  178. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  179. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  180. package/templates/nextblock-template/index.d.ts +7 -7
  181. package/templates/nextblock-template/lib/app-secrets.ts +39 -39
  182. package/templates/nextblock-template/lib/auth/cookies.ts +47 -47
  183. package/templates/nextblock-template/lib/auth/crypto.ts +45 -45
  184. package/templates/nextblock-template/lib/auth/trustedDevices.ts +92 -92
  185. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  186. package/templates/nextblock-template/lib/blocks/README.md +13 -13
  187. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +114 -0
  188. package/templates/nextblock-template/lib/blocks/blockColors.ts +132 -0
  189. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +17 -1
  190. package/templates/nextblock-template/lib/botProtection/verify.ts +134 -134
  191. package/templates/nextblock-template/lib/cms-transfer/server.ts +2243 -2243
  192. package/templates/nextblock-template/lib/cms-transfer/types.ts +145 -145
  193. package/templates/nextblock-template/lib/custom-block-definitions.ts +87 -87
  194. package/templates/nextblock-template/lib/custom-block-r2-upload-shared.ts +178 -178
  195. package/templates/nextblock-template/lib/custom-block-r2-upload.test.ts +140 -140
  196. package/templates/nextblock-template/lib/custom-block-r2-upload.ts +88 -88
  197. package/templates/nextblock-template/lib/custom-block-relations.test.ts +227 -227
  198. package/templates/nextblock-template/lib/custom-block-relations.ts +279 -279
  199. package/templates/nextblock-template/lib/custom-block-safelist.ts +14 -14
  200. package/templates/nextblock-template/lib/editor/dynamic-extension-core.test.ts +172 -172
  201. package/templates/nextblock-template/lib/editor/dynamic-extension-core.ts +213 -213
  202. package/templates/nextblock-template/lib/editor/dynamic-extension-loader.ts +22 -22
  203. package/templates/nextblock-template/lib/editor/dynamic-extensions.tsx +193 -193
  204. package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -133
  205. package/templates/nextblock-template/lib/email/branding-format.ts +123 -123
  206. package/templates/nextblock-template/lib/email/branding.ts +76 -76
  207. package/templates/nextblock-template/lib/full-backup/manifest.test.ts +121 -121
  208. package/templates/nextblock-template/lib/full-backup/manifest.ts +206 -206
  209. package/templates/nextblock-template/lib/full-backup/server.ts +743 -743
  210. package/templates/nextblock-template/lib/logos/active-logo.ts +53 -53
  211. package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +54 -54
  212. package/templates/nextblock-template/lib/media/youtube.ts +99 -99
  213. package/templates/nextblock-template/lib/onboarding/actions.ts +31 -31
  214. package/templates/nextblock-template/lib/onboarding/status.ts +222 -222
  215. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  216. package/templates/nextblock-template/lib/privacy/consent-client.ts +57 -57
  217. package/templates/nextblock-template/lib/privacy/contact-emails.ts +64 -64
  218. package/templates/nextblock-template/lib/privacy/settings.ts +115 -115
  219. package/templates/nextblock-template/lib/privacy/types.ts +69 -69
  220. package/templates/nextblock-template/lib/promotions/server.test.ts +74 -74
  221. package/templates/nextblock-template/lib/promotions/server.ts +741 -741
  222. package/templates/nextblock-template/lib/resolve-block-relations.test.ts +142 -142
  223. package/templates/nextblock-template/lib/resolve-block-relations.ts +255 -255
  224. package/templates/nextblock-template/lib/search/types.ts +27 -27
  225. package/templates/nextblock-template/lib/setup/actions.ts +460 -460
  226. package/templates/nextblock-template/lib/setup/env-status.ts +125 -125
  227. package/templates/nextblock-template/lib/setup/env-write.ts +111 -111
  228. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +15 -10
  229. package/templates/nextblock-template/lib/setup/provisioning.ts +59 -59
  230. package/templates/nextblock-template/lib/setup/schema-apply.ts +408 -408
  231. package/templates/nextblock-template/lib/setup/system-config.ts +105 -105
  232. package/templates/nextblock-template/lib/setup/types.ts +18 -18
  233. package/templates/nextblock-template/lib/site-url.ts +48 -48
  234. package/templates/nextblock-template/lib/storage/provider.ts +66 -66
  235. package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -103
  236. package/templates/nextblock-template/lib/themes/buildThemeCss.test.ts +163 -0
  237. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -0
  238. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -0
  239. package/templates/nextblock-template/lib/themes/tokens.ts +143 -0
  240. package/templates/nextblock-template/lib/updates/check-upstream.ts +441 -441
  241. package/templates/nextblock-template/lib/updates/github-device.ts +206 -206
  242. package/templates/nextblock-template/lib/updates/repo-identity.ts +56 -56
  243. package/templates/nextblock-template/next-env.d.ts +2 -2
  244. package/templates/nextblock-template/package.json +1 -1
  245. package/templates/nextblock-template/postcss.config.js +6 -6
  246. package/templates/nextblock-template/scripts/backup.js +115 -115
  247. package/templates/nextblock-template/scripts/docker-setup.mjs +310 -310
  248. package/templates/nextblock-template/scripts/restore.js +385 -385
  249. package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +98 -98
  250. package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +62 -62
  251. package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +537 -537
  252. package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +58 -58
  253. package/templates/nextblock-template/scripts/verify-custom-block-definitions.ts +188 -188
  254. package/templates/nextblock-template/scripts/verify-dynamic-custom-block-extensions.ts +123 -123
  255. package/templates/nextblock-template/scripts/verify-dynamic-layout-engine.tsx +133 -133
  256. package/templates/nextblock-template/scripts/verify-milestone-2-custom-blocks.ts +65 -65
  257. package/templates/nextblock-template/scripts/verify-site-themes.ts +63 -0
  258. package/templates/nextblock-template/tailwind.config.js +25 -25
  259. package/templates/nextblock-template/tools/build-migrate.mjs +209 -209
  260. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  261. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
  262. package/templates/nextblock-template/types/jsdom.d.ts +6 -6
  263. package/tsconfig.base.json +3 -3
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ TEXT_COLOR_CLASSES,
4
+ TEXT_COLOR_TOKENS,
5
+ TEXT_COLOR_TOKEN_OPTIONS,
6
+ isCustomCssColor,
7
+ isTextColorToken,
8
+ resolveTextAlign,
9
+ resolveTextColor,
10
+ } from './blockColors';
11
+ import { HeadingBlockSchema } from './blockRegistry';
12
+
13
+ describe('resolveTextColor', () => {
14
+ it('maps theme tokens to a static class and no inline style', () => {
15
+ expect(resolveTextColor('primary')).toEqual({ className: 'text-primary' });
16
+ expect(resolveTextColor('foreground')).toEqual({ className: 'text-foreground' });
17
+ });
18
+
19
+ it('maps "muted" to the readable foreground pair, not the surface colour', () => {
20
+ // Regression: the renderer used to emit `text-muted` (hsl(var(--muted)),
21
+ // a near-white surface) while both CMS previews used muted-foreground.
22
+ expect(resolveTextColor('muted')).toEqual({ className: 'text-muted-foreground' });
23
+ });
24
+
25
+ it('renders custom colours as an inline style', () => {
26
+ expect(resolveTextColor('#FF8800')).toEqual({ style: { color: '#FF8800' } });
27
+ expect(resolveTextColor('rgba(255, 136, 0, 0.8)')).toEqual({ style: { color: 'rgba(255, 136, 0, 0.8)' } });
28
+ expect(resolveTextColor('hsl(28, 100%, 50%)')).toEqual({ style: { color: 'hsl(28, 100%, 50%)' } });
29
+ expect(resolveTextColor('#f80')).toEqual({ style: { color: '#f80' } });
30
+ expect(resolveTextColor('#FF880080')).toEqual({ style: { color: '#FF880080' } });
31
+ });
32
+
33
+ it('trims incidental whitespace', () => {
34
+ expect(resolveTextColor(' primary ')).toEqual({ className: 'text-primary' });
35
+ expect(resolveTextColor(' #FF8800 ')).toEqual({ style: { color: '#FF8800' } });
36
+ });
37
+
38
+ it('degrades to inherited colour rather than emitting a bogus class', () => {
39
+ expect(resolveTextColor(undefined)).toEqual({});
40
+ expect(resolveTextColor(null)).toEqual({});
41
+ expect(resolveTextColor('')).toEqual({});
42
+ expect(resolveTextColor('chartreuse')).toEqual({});
43
+ expect(resolveTextColor('javascript:alert(1)')).toEqual({});
44
+ expect(resolveTextColor('url(https://evil.test/x.png)')).toEqual({});
45
+ expect(resolveTextColor('red; background: url(x)')).toEqual({});
46
+ });
47
+
48
+ it('never emits an interpolated class name', () => {
49
+ for (const token of TEXT_COLOR_TOKENS) {
50
+ const { className } = resolveTextColor(token);
51
+ expect(className).toBe(TEXT_COLOR_CLASSES[token]);
52
+ expect(className?.startsWith('text-')).toBe(true);
53
+ }
54
+ });
55
+ });
56
+
57
+ describe('resolveTextAlign', () => {
58
+ it('covers every alignment the schema allows', () => {
59
+ expect(resolveTextAlign('left')).toBe('text-left');
60
+ expect(resolveTextAlign('center')).toBe('text-center');
61
+ expect(resolveTextAlign('right')).toBe('text-right');
62
+ expect(resolveTextAlign('justify')).toBe('text-justify');
63
+ });
64
+
65
+ it('ignores unknown values', () => {
66
+ expect(resolveTextAlign(undefined)).toBeUndefined();
67
+ expect(resolveTextAlign('sideways')).toBeUndefined();
68
+ });
69
+ });
70
+
71
+ describe('guards', () => {
72
+ it('distinguishes tokens from custom colours', () => {
73
+ expect(isTextColorToken('accent')).toBe(true);
74
+ expect(isTextColorToken('#FFF')).toBe(false);
75
+ expect(isCustomCssColor('#FFF')).toBe(true);
76
+ expect(isCustomCssColor('accent')).toBe(false);
77
+ });
78
+ });
79
+
80
+ describe('token options', () => {
81
+ it('exposes a label and swatch for every token', () => {
82
+ expect(TEXT_COLOR_TOKEN_OPTIONS).toHaveLength(TEXT_COLOR_TOKENS.length);
83
+ for (const option of TEXT_COLOR_TOKEN_OPTIONS) {
84
+ expect(option.label).toBeTruthy();
85
+ expect(option.cssColor).toMatch(/^hsl\(var\(--[a-z-]+\)\)$/);
86
+ }
87
+ });
88
+ });
89
+
90
+ describe('HeadingBlockSchema.textColor', () => {
91
+ const base = { level: 2 as const, text_content: 'Hello' };
92
+
93
+ it('still accepts every previously stored token', () => {
94
+ for (const token of ['primary', 'secondary', 'accent', 'muted', 'destructive', 'background']) {
95
+ expect(HeadingBlockSchema.safeParse({ ...base, textColor: token }).success).toBe(true);
96
+ }
97
+ });
98
+
99
+ it('accepts custom colours from the picker', () => {
100
+ for (const color of ['#FF8800', '#f80', '#FF880080', 'rgb(1,2,3)', 'rgba(1,2,3,0.5)', 'hsl(28, 100%, 50%)']) {
101
+ expect(HeadingBlockSchema.safeParse({ ...base, textColor: color }).success).toBe(true);
102
+ }
103
+ });
104
+
105
+ it('rejects values that are neither a token nor a colour', () => {
106
+ for (const bad of ['nonsense', 'url(x)', 'red', '']) {
107
+ expect(HeadingBlockSchema.safeParse({ ...base, textColor: bad }).success).toBe(false);
108
+ }
109
+ });
110
+
111
+ it('stays optional', () => {
112
+ expect(HeadingBlockSchema.safeParse(base).success).toBe(true);
113
+ });
114
+ });
@@ -0,0 +1,132 @@
1
+ import type { CSSProperties } from 'react';
2
+
3
+ /**
4
+ * Text-colour model shared by the block renderers and the CMS previews.
5
+ *
6
+ * A colour is stored as EITHER a theme token keyword (`"primary"`) — which keeps
7
+ * following the active theme and dark mode — OR a literal CSS colour emitted by
8
+ * the colour picker (`"#FF8800"`, `"rgba(...)"`, `"hsl(...)"`). Tokens render as
9
+ * a static Tailwind class; literals render as an inline style.
10
+ *
11
+ * Keep this the single source of truth: HeadingBlockRenderer, EditableBlock and
12
+ * ColumnEditor previously each carried their own map and had drifted apart
13
+ * (`muted` rendered as the near-invisible `text-muted` on the live site while
14
+ * both previews used `text-muted-foreground`).
15
+ */
16
+
17
+ export const TEXT_COLOR_TOKENS = [
18
+ 'foreground',
19
+ 'primary',
20
+ 'secondary',
21
+ 'accent',
22
+ 'muted',
23
+ 'destructive',
24
+ 'background',
25
+ ] as const;
26
+
27
+ export type TextColorToken = (typeof TEXT_COLOR_TOKENS)[number];
28
+
29
+ /**
30
+ * Token -> Tailwind class.
31
+ *
32
+ * These must be written as complete literals, never interpolated. Tailwind v4
33
+ * ignores the `safelist` key in the legacy JS config (libs/ui/tailwind.config.js
34
+ * still carries one, but v4 never reads it) — a class exists only if the scanner
35
+ * finds the whole string in a source file. Listing them here is what guarantees
36
+ * they are emitted.
37
+ */
38
+ export const TEXT_COLOR_CLASSES: Record<TextColorToken, string> = {
39
+ foreground: 'text-foreground',
40
+ primary: 'text-primary',
41
+ secondary: 'text-secondary',
42
+ accent: 'text-accent',
43
+ // `--muted` is a near-white surface colour; the readable pair is its foreground.
44
+ muted: 'text-muted-foreground',
45
+ destructive: 'text-destructive',
46
+ background: 'text-background',
47
+ };
48
+
49
+ /** Token -> CSS colour, for painting swatches in the editor. */
50
+ export const TEXT_COLOR_SWATCHES: Record<TextColorToken, string> = {
51
+ foreground: 'hsl(var(--foreground))',
52
+ primary: 'hsl(var(--primary))',
53
+ secondary: 'hsl(var(--secondary))',
54
+ accent: 'hsl(var(--accent))',
55
+ muted: 'hsl(var(--muted-foreground))',
56
+ destructive: 'hsl(var(--destructive))',
57
+ background: 'hsl(var(--background))',
58
+ };
59
+
60
+ export const TEXT_COLOR_LABELS: Record<TextColorToken, string> = {
61
+ foreground: 'Default text',
62
+ primary: 'Primary',
63
+ secondary: 'Secondary',
64
+ accent: 'Accent',
65
+ muted: 'Muted',
66
+ destructive: 'Destructive',
67
+ background: 'Background',
68
+ };
69
+
70
+ /** Token options in the shape `ColorField` expects. */
71
+ export const TEXT_COLOR_TOKEN_OPTIONS = TEXT_COLOR_TOKENS.map((token) => ({
72
+ value: token,
73
+ label: TEXT_COLOR_LABELS[token],
74
+ cssColor: TEXT_COLOR_SWATCHES[token],
75
+ }));
76
+
77
+ /**
78
+ * Accepts what the colour picker can emit. Deliberately permissive on the inner
79
+ * grammar — the picker only produces well-formed values, and being strict here
80
+ * would reject legitimate colours authored by hand or by Cortex AI.
81
+ */
82
+ export const CSS_COLOR_PATTERN =
83
+ /^(#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|rgba?\([^)]+\)|hsla?\([^)]+\))$/i;
84
+
85
+ export function isTextColorToken(value: unknown): value is TextColorToken {
86
+ return typeof value === 'string' && (TEXT_COLOR_TOKENS as readonly string[]).includes(value);
87
+ }
88
+
89
+ export function isCustomCssColor(value: unknown): value is string {
90
+ return typeof value === 'string' && CSS_COLOR_PATTERN.test(value.trim());
91
+ }
92
+
93
+ export interface ResolvedTextColor {
94
+ className?: string;
95
+ style?: CSSProperties;
96
+ }
97
+
98
+ /**
99
+ * Turn a stored colour into render props. Unknown values resolve to `{}` so a
100
+ * stale or hand-edited value degrades to the inherited colour rather than
101
+ * emitting a bogus Tailwind class.
102
+ */
103
+ export function resolveTextColor(value: string | null | undefined): ResolvedTextColor {
104
+ if (!value) return {};
105
+ const trimmed = value.trim();
106
+ if (isTextColorToken(trimmed)) {
107
+ return { className: TEXT_COLOR_CLASSES[trimmed] };
108
+ }
109
+ if (isCustomCssColor(trimmed)) {
110
+ return { style: { color: trimmed } };
111
+ }
112
+ return {};
113
+ }
114
+
115
+ /**
116
+ * Text alignment, also as literals. `text-justify` currently only survives
117
+ * because the string happens to appear in a regex in libs/editor/src/lib/kit.ts;
118
+ * pinning it here removes that accidental dependency.
119
+ */
120
+ export const TEXT_ALIGN_CLASSES = {
121
+ left: 'text-left',
122
+ center: 'text-center',
123
+ right: 'text-right',
124
+ justify: 'text-justify',
125
+ } as const;
126
+
127
+ export type TextAlign = keyof typeof TEXT_ALIGN_CLASSES;
128
+
129
+ export function resolveTextAlign(value: string | null | undefined): string | undefined {
130
+ if (!value) return undefined;
131
+ return TEXT_ALIGN_CLASSES[value as TextAlign];
132
+ }
@@ -2,6 +2,7 @@ import { z } from '../zod-config';
2
2
  import { TestimonialBlockConfig, TestimonialBlockContent } from '../../components/blocks/TestimonialBlock';
3
3
  import { ProductGridBlockSchema, ProductGridBlockContent, FeaturedProductBlockSchema, FeaturedProductBlockContent, CartBlockSchema, CartBlockContent, CheckoutBlockSchema, CheckoutBlockContent, ProductDetailsBlockSchema, ProductDetailsBlockContent } from './ecommerce-block-schemas';
4
4
  import { availableBlockTypes, type BlockType } from './blockTypes';
5
+ import { CSS_COLOR_PATTERN, TEXT_COLOR_TOKENS } from './blockColors';
5
6
  export { availableBlockTypes, type BlockType } from './blockTypes';
6
7
 
7
8
  /**
@@ -19,11 +20,26 @@ export const TextBlockSchema = z.object({
19
20
  });
20
21
  export type TextBlockContent = z.infer<typeof TextBlockSchema>;
21
22
 
23
+ /**
24
+ * A text colour is either a theme token (keeps following the theme and dark
25
+ * mode) or a literal CSS colour from the colour picker. Existing rows only ever
26
+ * hold tokens, so widening the union is backward compatible.
27
+ * See `./blockColors` for the token -> class mapping used at render time.
28
+ */
29
+ export const TextColorSchema = z
30
+ .union([
31
+ z.enum(TEXT_COLOR_TOKENS),
32
+ z.string().regex(CSS_COLOR_PATTERN, 'Must be a hex, rgb(a) or hsl(a) colour'),
33
+ ])
34
+ .describe(
35
+ `A theme token (${TEXT_COLOR_TOKENS.join(', ')}) or a custom CSS colour such as "#FF8800", "rgba(255,136,0,0.8)" or "hsl(28, 100%, 50%)"`,
36
+ );
37
+
22
38
  export const HeadingBlockSchema = z.object({
23
39
  level: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5), z.literal(6)]).describe('Heading level (1-6)'),
24
40
  text_content: z.string().describe('The text content of the heading'),
25
41
  textAlign: z.enum(['left', 'center', 'right', 'justify']).optional().describe('Text alignment'),
26
- textColor: z.enum(['primary', 'secondary', 'accent', 'muted', 'destructive', 'background']).optional().describe('Color of the heading text'),
42
+ textColor: TextColorSchema.optional().describe('Color of the heading text'),
27
43
  });
28
44
  export type HeadingBlockContent = z.infer<typeof HeadingBlockSchema>;
29
45
 
@@ -1,134 +1,134 @@
1
- // lib/botProtection/verify.ts
2
- //
3
- // Shared server-side bot-protection verification: the honeypot check plus
4
- // Cloudflare Turnstile / Google reCAPTCHA token verification. This is the single
5
- // source of truth used by BOTH the page contact-form handler
6
- // (app/actions/formActions.ts) and the account-signup action (app/actions.ts).
7
- //
8
- // Server-only: it reads the RLS-bypassing service-role client, which throws if
9
- // imported into a Client Component.
10
-
11
- import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
12
-
13
- export type BotProtectionProvider = 'none' | 'turnstile' | 'recaptcha';
14
-
15
- // Shared field names — the client widgets emit these, the verifier reads them.
16
- export const HONEYPOT_FIELD = 'verification_secondary_email';
17
- export const TURNSTILE_TOKEN_FIELD = 'cf-turnstile-response';
18
- export const RECAPTCHA_TOKEN_FIELD = 'g-recaptcha-response';
19
-
20
- export type BotProtectionResult =
21
- // Passed (or nothing configured beyond the honeypot).
22
- | { ok: true }
23
- // The honeypot was filled — almost certainly a bot. Callers should silently
24
- // discard the submission and fake a success so the bot learns nothing.
25
- | { ok: false; reason: 'honeypot' }
26
- // The captcha was missing, failed, or could not be checked. `message` is a
27
- // human-readable explanation safe to surface to the user.
28
- | { ok: false; reason: 'captcha'; message: string };
29
-
30
- type VerifyOptions = {
31
- // A block/form may pin a specific provider; otherwise the site-wide setting
32
- // (site_settings.bot_protection_public.provider) is used.
33
- botProtectionProvider?: BotProtectionProvider;
34
- };
35
-
36
- /**
37
- * Verify a submitted FormData against the configured bot protection.
38
- *
39
- * Phase 1 (honeypot) needs no network/DB and always runs — it is the always-on
40
- * baseline. Phase 2 (captcha) reads the global provider + secret from
41
- * `site_settings` and calls the provider's siteverify endpoint.
42
- */
43
- export async function verifyBotProtection(
44
- formData: FormData,
45
- options?: VerifyOptions
46
- ): Promise<BotProtectionResult> {
47
- // Phase 1: Honeypot validation
48
- const honeypot = formData.get(HONEYPOT_FIELD);
49
- if (honeypot && typeof honeypot === 'string' && honeypot.length > 0) {
50
- console.warn('[Bot Protection] Honeypot triggered. Discarding submission from bot.');
51
- return { ok: false, reason: 'honeypot' };
52
- }
53
-
54
- // Phase 2: Advanced captcha verification
55
- try {
56
- const supabase = getServiceRoleSupabaseClient();
57
-
58
- const { data: publicSetting } = await supabase
59
- .from('site_settings')
60
- .select('value')
61
- .eq('key', 'bot_protection_public')
62
- .maybeSingle();
63
-
64
- const { data: secretSetting } = await supabase
65
- .from('site_settings')
66
- .select('value')
67
- .eq('key', 'bot_protection_secret')
68
- .maybeSingle();
69
-
70
- const publicVal = (publicSetting?.value || {}) as Record<string, any>;
71
- const secretVal = (secretSetting?.value || {}) as Record<string, any>;
72
-
73
- const pinnedProvider =
74
- options?.botProtectionProvider === 'turnstile' || options?.botProtectionProvider === 'recaptcha'
75
- ? options.botProtectionProvider
76
- : undefined;
77
- const provider: BotProtectionProvider = pinnedProvider || publicVal.provider || 'none';
78
- const secretKey =
79
- secretVal.secretKey ||
80
- (provider === 'turnstile'
81
- ? process.env.TURNSTILE_SECRET_KEY
82
- : process.env.RECAPTCHA_SECRET_KEY) ||
83
- '';
84
-
85
- if (provider === 'turnstile') {
86
- const token = formData.get(TURNSTILE_TOKEN_FIELD) as string;
87
- if (!token) {
88
- return { ok: false, reason: 'captcha', message: 'Security verification token is missing. Please try again.' };
89
- }
90
- if (!secretKey) {
91
- console.error('[Bot Protection] Turnstile secret key is not configured.');
92
- return { ok: false, reason: 'captcha', message: 'Bot protection is misconfigured. Please contact support.' };
93
- }
94
-
95
- const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
96
- method: 'POST',
97
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
98
- body: `secret=${encodeURIComponent(secretKey)}&response=${encodeURIComponent(token)}`,
99
- });
100
-
101
- const outcome = await res.json();
102
- if (!outcome.success) {
103
- console.warn('[Bot Protection] Turnstile verification failed:', outcome);
104
- return { ok: false, reason: 'captcha', message: 'Security verification failed. Please try again.' };
105
- }
106
- } else if (provider === 'recaptcha') {
107
- const token = formData.get(RECAPTCHA_TOKEN_FIELD) as string;
108
- if (!token) {
109
- return { ok: false, reason: 'captcha', message: 'Security verification token is missing. Please try again.' };
110
- }
111
- if (!secretKey) {
112
- console.error('[Bot Protection] reCAPTCHA secret key is not configured.');
113
- return { ok: false, reason: 'captcha', message: 'Bot protection is misconfigured. Please contact support.' };
114
- }
115
-
116
- const res = await fetch('https://www.google.com/recaptcha/api/siteverify', {
117
- method: 'POST',
118
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
119
- body: `secret=${encodeURIComponent(secretKey)}&response=${encodeURIComponent(token)}`,
120
- });
121
-
122
- const outcome = await res.json();
123
- if (!outcome.success || outcome.score < 0.5) {
124
- console.warn('[Bot Protection] reCAPTCHA verification failed:', outcome);
125
- return { ok: false, reason: 'captcha', message: 'Security verification failed. Please try again.' };
126
- }
127
- }
128
- } catch (error) {
129
- console.error('[Bot Protection] Error during validation:', error);
130
- return { ok: false, reason: 'captcha', message: 'Sorry, security verification could not be completed at this time.' };
131
- }
132
-
133
- return { ok: true };
134
- }
1
+ // lib/botProtection/verify.ts
2
+ //
3
+ // Shared server-side bot-protection verification: the honeypot check plus
4
+ // Cloudflare Turnstile / Google reCAPTCHA token verification. This is the single
5
+ // source of truth used by BOTH the page contact-form handler
6
+ // (app/actions/formActions.ts) and the account-signup action (app/actions.ts).
7
+ //
8
+ // Server-only: it reads the RLS-bypassing service-role client, which throws if
9
+ // imported into a Client Component.
10
+
11
+ import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
12
+
13
+ export type BotProtectionProvider = 'none' | 'turnstile' | 'recaptcha';
14
+
15
+ // Shared field names — the client widgets emit these, the verifier reads them.
16
+ export const HONEYPOT_FIELD = 'verification_secondary_email';
17
+ export const TURNSTILE_TOKEN_FIELD = 'cf-turnstile-response';
18
+ export const RECAPTCHA_TOKEN_FIELD = 'g-recaptcha-response';
19
+
20
+ export type BotProtectionResult =
21
+ // Passed (or nothing configured beyond the honeypot).
22
+ | { ok: true }
23
+ // The honeypot was filled — almost certainly a bot. Callers should silently
24
+ // discard the submission and fake a success so the bot learns nothing.
25
+ | { ok: false; reason: 'honeypot' }
26
+ // The captcha was missing, failed, or could not be checked. `message` is a
27
+ // human-readable explanation safe to surface to the user.
28
+ | { ok: false; reason: 'captcha'; message: string };
29
+
30
+ type VerifyOptions = {
31
+ // A block/form may pin a specific provider; otherwise the site-wide setting
32
+ // (site_settings.bot_protection_public.provider) is used.
33
+ botProtectionProvider?: BotProtectionProvider;
34
+ };
35
+
36
+ /**
37
+ * Verify a submitted FormData against the configured bot protection.
38
+ *
39
+ * Phase 1 (honeypot) needs no network/DB and always runs — it is the always-on
40
+ * baseline. Phase 2 (captcha) reads the global provider + secret from
41
+ * `site_settings` and calls the provider's siteverify endpoint.
42
+ */
43
+ export async function verifyBotProtection(
44
+ formData: FormData,
45
+ options?: VerifyOptions
46
+ ): Promise<BotProtectionResult> {
47
+ // Phase 1: Honeypot validation
48
+ const honeypot = formData.get(HONEYPOT_FIELD);
49
+ if (honeypot && typeof honeypot === 'string' && honeypot.length > 0) {
50
+ console.warn('[Bot Protection] Honeypot triggered. Discarding submission from bot.');
51
+ return { ok: false, reason: 'honeypot' };
52
+ }
53
+
54
+ // Phase 2: Advanced captcha verification
55
+ try {
56
+ const supabase = getServiceRoleSupabaseClient();
57
+
58
+ const { data: publicSetting } = await supabase
59
+ .from('site_settings')
60
+ .select('value')
61
+ .eq('key', 'bot_protection_public')
62
+ .maybeSingle();
63
+
64
+ const { data: secretSetting } = await supabase
65
+ .from('site_settings')
66
+ .select('value')
67
+ .eq('key', 'bot_protection_secret')
68
+ .maybeSingle();
69
+
70
+ const publicVal = (publicSetting?.value || {}) as Record<string, any>;
71
+ const secretVal = (secretSetting?.value || {}) as Record<string, any>;
72
+
73
+ const pinnedProvider =
74
+ options?.botProtectionProvider === 'turnstile' || options?.botProtectionProvider === 'recaptcha'
75
+ ? options.botProtectionProvider
76
+ : undefined;
77
+ const provider: BotProtectionProvider = pinnedProvider || publicVal.provider || 'none';
78
+ const secretKey =
79
+ secretVal.secretKey ||
80
+ (provider === 'turnstile'
81
+ ? process.env.TURNSTILE_SECRET_KEY
82
+ : process.env.RECAPTCHA_SECRET_KEY) ||
83
+ '';
84
+
85
+ if (provider === 'turnstile') {
86
+ const token = formData.get(TURNSTILE_TOKEN_FIELD) as string;
87
+ if (!token) {
88
+ return { ok: false, reason: 'captcha', message: 'Security verification token is missing. Please try again.' };
89
+ }
90
+ if (!secretKey) {
91
+ console.error('[Bot Protection] Turnstile secret key is not configured.');
92
+ return { ok: false, reason: 'captcha', message: 'Bot protection is misconfigured. Please contact support.' };
93
+ }
94
+
95
+ const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
96
+ method: 'POST',
97
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
98
+ body: `secret=${encodeURIComponent(secretKey)}&response=${encodeURIComponent(token)}`,
99
+ });
100
+
101
+ const outcome = await res.json();
102
+ if (!outcome.success) {
103
+ console.warn('[Bot Protection] Turnstile verification failed:', outcome);
104
+ return { ok: false, reason: 'captcha', message: 'Security verification failed. Please try again.' };
105
+ }
106
+ } else if (provider === 'recaptcha') {
107
+ const token = formData.get(RECAPTCHA_TOKEN_FIELD) as string;
108
+ if (!token) {
109
+ return { ok: false, reason: 'captcha', message: 'Security verification token is missing. Please try again.' };
110
+ }
111
+ if (!secretKey) {
112
+ console.error('[Bot Protection] reCAPTCHA secret key is not configured.');
113
+ return { ok: false, reason: 'captcha', message: 'Bot protection is misconfigured. Please contact support.' };
114
+ }
115
+
116
+ const res = await fetch('https://www.google.com/recaptcha/api/siteverify', {
117
+ method: 'POST',
118
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
119
+ body: `secret=${encodeURIComponent(secretKey)}&response=${encodeURIComponent(token)}`,
120
+ });
121
+
122
+ const outcome = await res.json();
123
+ if (!outcome.success || outcome.score < 0.5) {
124
+ console.warn('[Bot Protection] reCAPTCHA verification failed:', outcome);
125
+ return { ok: false, reason: 'captcha', message: 'Security verification failed. Please try again.' };
126
+ }
127
+ }
128
+ } catch (error) {
129
+ console.error('[Bot Protection] Error during validation:', error);
130
+ return { ok: false, reason: 'captcha', message: 'Sorry, security verification could not be completed at this time.' };
131
+ }
132
+
133
+ return { ok: true };
134
+ }