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,7 +1,7 @@
1
1
  // app/cms/blocks/editors/ImageBlockEditor.tsx
2
2
  "use client";
3
3
 
4
- import React, { useState, useTransition } from 'react';
4
+ import React, { useRef, useState, useTransition } from 'react';
5
5
  import Image from 'next/image';
6
6
  import { Label } from "@nextblock-cms/ui";
7
7
  import { Input } from "@nextblock-cms/ui";
@@ -19,11 +19,25 @@ export type ImageBlockContent = {
19
19
  height: number | null;
20
20
  blur_data_url: string | null;
21
21
  };
22
- import { ImageIcon, X as XIcon, Link as LinkIcon, DownloadCloud } from 'lucide-react';
22
+ import { ImageIcon, X as XIcon, Link as LinkIcon, DownloadCloud, Loader2, Sparkles } from 'lucide-react';
23
+ import { toast } from 'sonner';
23
24
  import MediaPickerDialog from "../../media/components/MediaPickerDialog";
24
25
  import { BlockEditorProps } from '../components/BlockEditorModal';
25
26
  import { resolveMediaUrl } from '../../../../lib/media/resolveMediaUrl';
26
27
  import { importExternalImageToMedia } from "../../media/import-external-image";
28
+ import { useCortexAiActive } from '../../components/CortexAiActiveContext';
29
+ import { useCortexAiPageContext } from '../../components/CortexAiPageContext';
30
+ import { buildCortexAiRequestHeaders } from '../../../../lib/cortex-ai/sandbox-headers';
31
+ import {
32
+ buildAltTextContext,
33
+ toAbsoluteImageUrl,
34
+ UNRESOLVABLE_IMAGE_URL_MESSAGE,
35
+ } from '../../../../lib/cortex-ai/alt-text-request';
36
+ import {
37
+ altTextImageIdentity,
38
+ resolveAltTextWriteBack,
39
+ STALE_ALT_TEXT_MESSAGE,
40
+ } from '../../../../lib/seo/alt-text-write-back';
27
41
 
28
42
  const deriveAltFromFilename = (name: string) => {
29
43
  const lastDot = name.lastIndexOf('.');
@@ -38,6 +52,30 @@ export default function ImageBlockEditor({ content, onChange }: BlockEditorProps
38
52
  const [importError, setImportError] = useState<string | null>(null);
39
53
  const [isImporting, startImport] = useTransition();
40
54
 
55
+ // Cortex AI is a premium package. Gating the affordance on activation (rather than
56
+ // letting the button 403) is the difference between a feature the operator has not
57
+ // bought and a feature that looks broken.
58
+ const isCortexAiActive = useCortexAiActive();
59
+ const cortexAiPageContext = useCortexAiPageContext();
60
+ const [isGeneratingAltText, setIsGeneratingAltText] = useState(false);
61
+ // Kept separate from `importError` so an alt-text failure never clears — or is cleared
62
+ // by — an unrelated "save to library" failure sitting a few lines above it.
63
+ const [altTextError, setAltTextError] = useState<string | null>(null);
64
+
65
+ /**
66
+ * Live mirrors of the two values that describe which image this block holds.
67
+ *
68
+ * The alt-text generation below is the only async write-back in this editor, and it is
69
+ * the only place that must read these AFTER an await. Refs updated during render (the
70
+ * same pattern `RobotsCard` uses for its Ctrl+S handler) give that continuation the
71
+ * current values instead of the ones its closure captured when the button was clicked,
72
+ * which is what stops a slow vision response from overwriting a newer edit.
73
+ */
74
+ const contentRef = useRef(content);
75
+ contentRef.current = content;
76
+ const selectedMediaObjectKeyRef = useRef(selectedMediaObjectKey);
77
+ selectedMediaObjectKeyRef.current = selectedMediaObjectKey;
78
+
41
79
  const handleSelectMediaFromLibrary = (mediaItem: Media) => {
42
80
  const newAlt = mediaItem.description && mediaItem.description.trim().length > 0
43
81
  ? mediaItem.description
@@ -122,6 +160,122 @@ export default function ImageBlockEditor({ content, onChange }: BlockEditorProps
122
160
  const displayImageUrl = resolveMediaUrl(displayObjectKey);
123
161
  const hasImage = Boolean(displayObjectKey || content.external_url);
124
162
 
163
+ /**
164
+ * Ask Cortex AI to describe the image currently in this block.
165
+ *
166
+ * Three things about this deserve stating. First, the URL: `resolveMediaUrl()` hands back
167
+ * a RELATIVE `/${objectKey}` whenever `NEXT_PUBLIC_R2_BASE_URL` is unset — which is the
168
+ * default on the native Supabase-storage backend — and the alt-text route cannot use
169
+ * that, because the AI SDK downloads the image server-side and a relative path has no
170
+ * base there. `toAbsoluteImageUrl()` resolves it against the origin the CMS is being
171
+ * served from; if the result still is not http(s) (a blob: preview of an unsaved file,
172
+ * say) we say so inline instead of firing a request guaranteed to fail.
173
+ *
174
+ * Second, the write-back: the generated string goes through the exact same
175
+ * `onChange({ ...content, object_key, alt_text })` call the text input uses. Feature 2
176
+ * requires the value to land in the block's JSONB attributes, and the surest way to
177
+ * guarantee that is to make the AI path indistinguishable from typing — same shape,
178
+ * same key set, same downstream save. A bespoke write here would be a second code path
179
+ * to keep in sync forever, for no gain.
180
+ *
181
+ * Third, staleness. This handler used to close over `content` and finish with
182
+ * `onChange({ ...capturedContent, ... })`, on the assumption that the block it started
183
+ * on would still be the block it finished on. It is not: the vision call takes seconds
184
+ * and the editor stays live throughout, so an author who removes the image or picks a
185
+ * different one mid-flight would have had the deleted image restored, or a new image
186
+ * labelled with the previous image's description — alt text that is confidently wrong
187
+ * and undetectable by the screen-reader user it is written for. The image's identity is
188
+ * therefore captured instead of its content, and the response is discarded unless the
189
+ * block still holds that same image. `resolveAltTextWriteBack` owns that rule so it can
190
+ * be unit-tested; this component cannot be, as the workspace has no DOM test env.
191
+ */
192
+ const handleGenerateAltText = async () => {
193
+ if (!hasImage || isGeneratingAltText) {
194
+ return;
195
+ }
196
+
197
+ const sourceUrl = content.external_url || displayImageUrl;
198
+ const imageUrl = toAbsoluteImageUrl(sourceUrl);
199
+
200
+ if (!imageUrl) {
201
+ setAltTextError(UNRESOLVABLE_IMAGE_URL_MESSAGE);
202
+ return;
203
+ }
204
+
205
+ // Taken before the await, from the same two values the request itself is built from,
206
+ // so "is this still the image we asked about?" is answerable when the reply lands.
207
+ const capturedIdentity = altTextImageIdentity(content, selectedMediaObjectKey);
208
+
209
+ setAltTextError(null);
210
+ setIsGeneratingAltText(true);
211
+
212
+ try {
213
+ // Context materially improves alt text — "Nicolas at the 2026 harvest" beats "a man
214
+ // in a field" — so we pass whatever is already in hand for free: this block's own
215
+ // caption, and the title of the page or post being edited, which the CMS layout
216
+ // already registers in the Cortex AI page context. Nothing is fetched for this.
217
+ const context = buildAltTextContext(
218
+ content.caption ? `Image caption: ${content.caption}` : null,
219
+ cortexAiPageContext?.pageContext?.title
220
+ ? `Appears on the ${cortexAiPageContext.pageContext.contentType} “${cortexAiPageContext.pageContext.title}”.`
221
+ : null
222
+ );
223
+
224
+ const response = await fetch('/api/ai/seo/alt-text', {
225
+ // The route's body schema is a `z.strictObject` of optionals, so an absent field
226
+ // must be omitted rather than sent as null — a null would fail validation outright.
227
+ body: JSON.stringify({
228
+ ...(context ? { context } : {}),
229
+ imageUrl,
230
+ }),
231
+ headers: buildCortexAiRequestHeaders(),
232
+ method: 'POST',
233
+ });
234
+
235
+ const payload = (await response.json().catch(() => null)) as Record<string, unknown> | null;
236
+
237
+ if (!response.ok || !payload) {
238
+ const message =
239
+ payload && typeof payload['error'] === 'string'
240
+ ? (payload['error'] as string)
241
+ : 'Cortex AI could not describe this image.';
242
+ throw new Error(message);
243
+ }
244
+
245
+ const generated = typeof payload['altText'] === 'string' ? payload['altText'].trim() : '';
246
+ if (!generated) {
247
+ throw new Error('Cortex AI returned an empty description.');
248
+ }
249
+
250
+ const currentContent = contentRef.current;
251
+ const currentObjectKey = selectedMediaObjectKeyRef.current;
252
+ const writeBack = resolveAltTextWriteBack({
253
+ capturedIdentity,
254
+ currentContent,
255
+ currentIdentity: altTextImageIdentity(currentContent, currentObjectKey),
256
+ generatedAltText: generated,
257
+ objectKey: currentObjectKey,
258
+ });
259
+
260
+ if (!writeBack) {
261
+ // Not thrown: nothing failed. The model answered, the answer is simply about an
262
+ // image this block no longer shows, so it is reported inline and dropped. A toast
263
+ // is skipped for the same reason — this is not an error the operator caused.
264
+ setAltTextError(STALE_ALT_TEXT_MESSAGE);
265
+ return;
266
+ }
267
+
268
+ onChange(writeBack);
269
+ } catch (error) {
270
+ const message =
271
+ error instanceof Error ? error.message : 'Cortex AI could not describe this image.';
272
+ setAltTextError(message);
273
+ toast.error(message);
274
+ } finally {
275
+ setIsGeneratingAltText(false);
276
+ }
277
+ };
278
+
125
279
  return (
126
280
  <div className="space-y-3 p-3 border-t mt-2">
127
281
  <Label>Image</Label>
@@ -217,8 +371,31 @@ export default function ImageBlockEditor({ content, onChange }: BlockEditorProps
217
371
  </div>
218
372
 
219
373
  <div>
220
- <Label htmlFor={`image-alt-${content.media_id || 'new'}`}>Alt Text</Label>
374
+ <div className="flex items-center justify-between gap-2">
375
+ <Label htmlFor={`image-alt-${content.media_id || 'new'}`}>Alt Text</Label>
376
+ {/* Rendered only when the premium package is active, so a non-premium install
377
+ never shows a button that cannot work. */}
378
+ {isCortexAiActive && (
379
+ <Button
380
+ type="button"
381
+ variant="outline"
382
+ size="sm"
383
+ className="h-7 gap-1.5 px-2 text-[11px]"
384
+ onClick={handleGenerateAltText}
385
+ disabled={!hasImage || isGeneratingAltText}
386
+ title={hasImage ? "Describe this image with Cortex AI" : "Select an image first"}
387
+ >
388
+ {isGeneratingAltText ? (
389
+ <Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
390
+ ) : (
391
+ <Sparkles aria-hidden="true" className="h-3.5 w-3.5 text-amber-500" />
392
+ )}
393
+ {isGeneratingAltText ? "Generating…" : "Generate with AI"}
394
+ </Button>
395
+ )}
396
+ </div>
221
397
  <Input id={`image-alt-${content.media_id || 'new'}`} value={content.alt_text || ""} onChange={handleAltTextChange} className="mt-1" disabled={!hasImage} />
398
+ {altTextError && <p className="text-xs text-red-500 mt-1">{altTextError}</p>}
222
399
  </div>
223
400
  <div>
224
401
  <Label htmlFor={`image-caption-${content.media_id || 'new'}`}>Caption</Label>
@@ -3,27 +3,50 @@
3
3
 
4
4
  import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
5
5
  import dynamic from 'next/dynamic';
6
- import type { Extensions } from '@tiptap/core';
6
+ import type { Editor, Extensions } from '@tiptap/core';
7
+ import { PanelRightClose, PanelRightOpen } from 'lucide-react';
7
8
  import MediaPickerDialog from '../../media/components/MediaPickerDialog';
8
- import { Label } from '@nextblock-cms/ui';
9
+ import { Button, Label } from '@nextblock-cms/ui';
9
10
  import { BlockEditorProps } from '../components/BlockEditorModal';
10
11
  import { resolveMediaUrl } from '../../../../lib/media/resolveMediaUrl';
11
12
  import { useCortexAiActive } from '../../components/CortexAiActiveContext';
13
+ import { SeoAuditPanel } from '../../../../components/seo/SeoAuditPanel';
14
+ import { usePageSeo } from '../../../../lib/seo/page-audit-context';
12
15
  import {
13
16
  createDynamicCustomBlockExtensions,
14
17
  type DynamicCustomBlockEditorDefinition,
15
18
  } from '../../../../lib/editor/dynamic-extensions';
16
19
 
17
- // Props expected by NotionEditor
20
+ /**
21
+ * A hand-maintained copy of the real `NotionEditorProps`.
22
+ *
23
+ * `next/dynamic` needs a concrete type argument at the call site, and the real
24
+ * interface lives behind a lazy `import()` that TypeScript will not unwrap for
25
+ * us here. That makes this a second, independent declaration of the same
26
+ * contract: a prop added to `libs/editor/src/lib/NotionEditor.tsx` and not added
27
+ * here is invisible to this file and fails to type-check the moment it is
28
+ * passed. Both `onEditorReady` and `sidePanel` below exist for that reason.
29
+ */
18
30
  type NotionEditorProps = {
19
31
  content: string;
20
32
  onChange: (html: string) => void;
33
+ onEditorReady?: (editor: Editor | null) => void;
21
34
  openImagePicker?: () => Promise<{ src: string; alt?: string; width?: number | null; height?: number | null; blurDataURL?: string | null } | null>;
22
35
  className?: string;
23
36
  showAiPrompt?: boolean;
37
+ sidePanel?: React.ReactNode;
24
38
  dynamicExtensions?: Extensions;
25
39
  };
26
40
 
41
+ /**
42
+ * Where the "is the SEO panel open?" choice is remembered.
43
+ *
44
+ * Per-browser rather than per-user or per-block: it is a workspace preference in
45
+ * the same family as a collapsed sidebar, not content, and round-tripping it
46
+ * through the database would mean a write on every toggle.
47
+ */
48
+ const SEO_PANEL_STORAGE_KEY = 'nextblock_seo_audit_panel_open';
49
+
27
50
  // Use the alias that resolves in your repo; if you mapped @nextblock-cms/editor, swap it here.
28
51
  const NotionEditor = dynamic<NotionEditorProps>(
29
52
  () => import('@nextblock-cms/editor').then((m) => m.NotionEditor),
@@ -44,7 +67,88 @@ export default function TextBlockEditor({
44
67
  const [dynamicDefinitions, setDynamicDefinitions] = useState<DynamicCustomBlockEditorDefinition[]>([]);
45
68
  const [dynamicDefinitionsLoaded, setDynamicDefinitionsLoaded] = useState(false);
46
69
  const isCortexAiActive = useCortexAiActive();
70
+
71
+ /**
72
+ * The live Tiptap instance, published by `NotionEditor` through
73
+ * `onEditorReady`. This is what lets the SEO panel write a Cortex AI fix back
74
+ * into the document; without it the panel still grades the content but shows
75
+ * no fix buttons. Deliberately not `window.__nextblockEditor`, which is a
76
+ * single global slot and therefore wrong as soon as a second editor mounts.
77
+ */
78
+ const [editor, setEditor] = useState<Editor | null>(null);
79
+
80
+ /**
81
+ * The focus keyphrase, preferring the page's copy when there is one.
82
+ *
83
+ * A keyphrase is a property of the page, not of a paragraph, so when this
84
+ * editor is opened from a page or post edit screen it reads and writes the
85
+ * shared `PageSeoProvider` value: type it once on the page panel and every
86
+ * block editor opened afterwards is already grading against it, and typing it
87
+ * in a block sends it back the other way. The local state below is the
88
+ * fallback for surfaces with no page-level panel — the product editor — where
89
+ * an ephemeral per-block keyphrase is still better than a dead field.
90
+ */
91
+ const pageSeo = usePageSeo();
92
+ const [localSeoKeyword, setLocalSeoKeyword] = useState('');
93
+ const seoKeyword = pageSeo ? pageSeo.keyword : localSeoKeyword;
94
+ const handleSeoKeywordChange = useCallback(
95
+ (next: string) => {
96
+ if (pageSeo) {
97
+ pageSeo.setKeyword(next);
98
+ } else {
99
+ setLocalSeoKeyword(next);
100
+ }
101
+ },
102
+ [pageSeo]
103
+ );
104
+
105
+ // Starts closed for the very first render on the server and on a cold client,
106
+ // then adopts the stored preference in the effect below. Reading storage in
107
+ // the initialiser instead would make the server's HTML and the client's first
108
+ // render disagree for anyone who had opened the panel before.
109
+ const [isSeoPanelOpen, setIsSeoPanelOpen] = useState(false);
110
+
47
111
  const resolverRef = useRef<null | ((v: any) => void)>(null);
112
+
113
+ /**
114
+ * Adopt the remembered preference once, after mount.
115
+ *
116
+ * Every access is wrapped because `localStorage` is not merely empty in a
117
+ * private window or with site data blocked — the property access itself
118
+ * throws, and an uncaught throw here would take the whole block editor down
119
+ * over a panel toggle. Defaulting to open on a first visit is deliberate: the
120
+ * feature is invisible otherwise, and one click hides it for good afterwards.
121
+ */
122
+ useEffect(() => {
123
+ try {
124
+ const stored = window.localStorage.getItem(SEO_PANEL_STORAGE_KEY);
125
+ setIsSeoPanelOpen(stored === null ? true : stored === 'true');
126
+ } catch {
127
+ setIsSeoPanelOpen(true);
128
+ }
129
+ }, []);
130
+
131
+ const toggleSeoPanel = useCallback(() => {
132
+ setIsSeoPanelOpen((previous) => {
133
+ const next = !previous;
134
+ try {
135
+ window.localStorage.setItem(SEO_PANEL_STORAGE_KEY, String(next));
136
+ } catch {
137
+ // The preference is a convenience; losing it is not worth an error.
138
+ }
139
+ return next;
140
+ });
141
+ }, []);
142
+
143
+ /**
144
+ * Wrapped rather than passing `setEditor` straight in. A state setter treats a
145
+ * function argument as an updater callback, and while a Tiptap `Editor` is an
146
+ * object today, routing it through an explicit assignment means this cannot
147
+ * quietly break if that ever stops being true.
148
+ */
149
+ const handleEditorReady = useCallback((instance: Editor | null) => {
150
+ setEditor(instance);
151
+ }, []);
48
152
  const openImagePicker = useCallback(() => {
49
153
  setPickerOpen(true);
50
154
  return new Promise<{ src: string; alt?: string; width?: number | null; height?: number | null; blurDataURL?: string | null } | null>((resolve) => {
@@ -103,26 +207,87 @@ export default function TextBlockEditor({
103
207
  };
104
208
  }, []);
105
209
 
210
+ // The one string the editor and the audit both read. Deriving it once keeps
211
+ // the panel from ever grading something other than what is on screen.
212
+ const htmlContent = content?.html_content ?? '';
213
+
106
214
  return (
107
215
  <div className="h-full flex flex-col">
108
216
  <Label htmlFor={labelId} className="sr-only">
109
217
  Text Content
110
218
  </Label>
111
219
 
220
+ <div className="mb-2 flex items-center justify-end">
221
+ <Button
222
+ aria-pressed={isSeoPanelOpen}
223
+ className="h-7 text-xs"
224
+ disabled={!dynamicDefinitionsLoaded}
225
+ onClick={toggleSeoPanel}
226
+ size="sm"
227
+ type="button"
228
+ variant="outline"
229
+ >
230
+ {isSeoPanelOpen ? (
231
+ <PanelRightClose className="mr-1.5 h-4 w-4" />
232
+ ) : (
233
+ <PanelRightOpen className="mr-1.5 h-4 w-4" />
234
+ )}
235
+ {isSeoPanelOpen ? 'Hide SEO analysis' : 'Show SEO analysis'}
236
+ </Button>
237
+ </div>
238
+
112
239
  <div id={labelId} role="group" aria-labelledby={labelId} className="flex-1 min-h-0 flex flex-col">
113
240
  {dynamicDefinitionsLoaded ? (
114
241
  <NotionEditor
115
242
  key={dynamicDefinitions.map((definition) => definition.id).join('|') || 'static'}
116
- content={content?.html_content ?? ''}
243
+ content={htmlContent}
117
244
  dynamicExtensions={dynamicExtensions}
118
245
  onChange={(html) => onChange({ html_content: html })}
246
+ onEditorReady={handleEditorReady}
119
247
  openImagePicker={openImagePicker}
120
248
  className={className}
121
249
  showAiPrompt={isCortexAiActive}
250
+ /*
251
+ * Passing `undefined` rather than a hidden panel is what keeps the
252
+ * collapsed state honest: `NotionEditor` renders its original
253
+ * single-column tree when `sidePanel` is absent, so hiding the panel
254
+ * gives the prose the full width back instead of leaving an empty
255
+ * column behind it.
256
+ */
257
+ sidePanel={
258
+ isSeoPanelOpen ? (
259
+ /*
260
+ * Block scope, and this is the fix for the complaint that
261
+ * started all of this: mounted here the panel can only see one
262
+ * block, so grading it as a page reported "no H1" and "fewer
263
+ * than 300 words" for a single paragraph — neither of which a
264
+ * block can be responsible for. The page-wide checks now live on
265
+ * the page editor's own panel; what stays here are the checks a
266
+ * block genuinely owns, plus the one thing this mount has and
267
+ * the page mount never will: a live editor instance, which is
268
+ * what makes "Fix with Cortex AI" able to write anything at all.
269
+ */
270
+ <SeoAuditPanel
271
+ content={htmlContent}
272
+ editor={editor}
273
+ isCortexAiActive={isCortexAiActive}
274
+ keyword={seoKeyword}
275
+ onKeywordChange={handleSeoKeywordChange}
276
+ scope="block"
277
+ />
278
+ ) : undefined
279
+ }
122
280
  />
123
281
  ) : (
124
- <div className="flex min-h-[500px] flex-1 items-center justify-center rounded-lg border bg-background text-sm text-muted-foreground">
125
- Loading editor...
282
+ /*
283
+ * The editor is `ssr: false` and gated behind the custom-block
284
+ * definition fetch, so neither it nor the SEO panel exists on first
285
+ * paint. Saying which pieces are still coming makes the gap read as a
286
+ * load rather than as a panel that failed to appear.
287
+ */
288
+ <div className="flex min-h-[500px] flex-1 flex-col items-center justify-center gap-1 rounded-lg border bg-background text-sm text-muted-foreground">
289
+ <span>Loading editor...</span>
290
+ <span className="text-xs">Custom blocks and the SEO analysis appear once it is ready.</span>
126
291
  </div>
127
292
  )}
128
293
 
@@ -1,75 +1,75 @@
1
- 'use client';
2
-
3
- import Link from 'next/link';
4
- import { useState } from 'react';
5
- import { Button } from '@nextblock-cms/ui';
6
- import { MailWarning, X } from 'lucide-react';
7
- import { useAuth } from '../../../context/AuthContext';
8
- import type { ContactReminder } from '../../../lib/cms/contact-reminder';
9
-
10
- /**
11
- * Shown when a contact form is addressed to a placeholder, or when nothing resolves to a
12
- * deliverable address at all.
13
- *
14
- * Worth interrupting for, because this failure is completely silent: the visitor is
15
- * thanked, the mail server accepts the message, and it goes to a reserved domain that can
16
- * never receive it. The messages are safely stored either way — the problem is that
17
- * nobody is being *told* they arrived.
18
- */
19
- export default function ContactReminderBanner({ reminder }: { reminder: ContactReminder }) {
20
- const { isAdmin } = useAuth();
21
- const [dismissed, setDismissed] = useState(false);
22
-
23
- if (!isAdmin || dismissed) return null;
24
-
25
- const { placeholderForms, noFallback } = reminder;
26
- const first = placeholderForms[0];
27
-
28
- return (
29
- <div className="mb-6 flex items-start gap-3 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-amber-900 dark:border-amber-700/60 dark:bg-amber-900/20 dark:text-amber-200">
30
- <MailWarning className="mt-0.5 h-5 w-5 shrink-0" />
31
- <div className="min-w-0 flex-1">
32
- <p className="text-sm font-medium">
33
- {placeholderForms.length > 0
34
- ? 'Your contact form is still using the example address.'
35
- : 'Nobody is being notified when someone contacts you.'}
36
- </p>
37
- <p className="text-xs opacity-90">
38
- {placeholderForms.length > 0 ? (
39
- <>
40
- &ldquo;{first?.label}&rdquo; sends to <strong>{first?.recipient}</strong>, a
41
- reserved address that can never receive mail
42
- {placeholderForms.length > 1
43
- ? ` (and ${placeholderForms.length - 1} other form${
44
- placeholderForms.length > 2 ? 's' : ''
45
- } like it)`
46
- : ''}
47
- . {noFallback ? 'No fallback address is set either. ' : ''}
48
- Messages are still saved under Messages — but no one is told they arrived.
49
- </>
50
- ) : (
51
- <>
52
- No contact address is configured anywhere, so notifications have nowhere to
53
- go. Messages are still saved under Messages — but no one is told they
54
- arrived.
55
- </>
56
- )}
57
- </p>
58
- </div>
59
- <div className="flex shrink-0 items-center gap-2">
60
- <Button asChild size="sm" variant="outline" className="border-amber-400">
61
- <Link href="/cms/messages">Set an address</Link>
62
- </Button>
63
- <Button
64
- variant="ghost"
65
- size="icon"
66
- onClick={() => setDismissed(true)}
67
- aria-label="Dismiss for now"
68
- className="h-8 w-8"
69
- >
70
- <X className="h-4 w-4" />
71
- </Button>
72
- </div>
73
- </div>
74
- );
75
- }
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { useState } from 'react';
5
+ import { Button } from '@nextblock-cms/ui';
6
+ import { MailWarning, X } from 'lucide-react';
7
+ import { useAuth } from '../../../context/AuthContext';
8
+ import type { ContactReminder } from '../../../lib/cms/contact-reminder';
9
+
10
+ /**
11
+ * Shown when a contact form is addressed to a placeholder, or when nothing resolves to a
12
+ * deliverable address at all.
13
+ *
14
+ * Worth interrupting for, because this failure is completely silent: the visitor is
15
+ * thanked, the mail server accepts the message, and it goes to a reserved domain that can
16
+ * never receive it. The messages are safely stored either way — the problem is that
17
+ * nobody is being *told* they arrived.
18
+ */
19
+ export default function ContactReminderBanner({ reminder }: { reminder: ContactReminder }) {
20
+ const { isAdmin } = useAuth();
21
+ const [dismissed, setDismissed] = useState(false);
22
+
23
+ if (!isAdmin || dismissed) return null;
24
+
25
+ const { placeholderForms, noFallback } = reminder;
26
+ const first = placeholderForms[0];
27
+
28
+ return (
29
+ <div className="mb-6 flex items-start gap-3 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-amber-900 dark:border-amber-700/60 dark:bg-amber-900/20 dark:text-amber-200">
30
+ <MailWarning className="mt-0.5 h-5 w-5 shrink-0" />
31
+ <div className="min-w-0 flex-1">
32
+ <p className="text-sm font-medium">
33
+ {placeholderForms.length > 0
34
+ ? 'Your contact form is still using the example address.'
35
+ : 'Nobody is being notified when someone contacts you.'}
36
+ </p>
37
+ <p className="text-xs opacity-90">
38
+ {placeholderForms.length > 0 ? (
39
+ <>
40
+ &ldquo;{first?.label}&rdquo; sends to <strong>{first?.recipient}</strong>, a
41
+ reserved address that can never receive mail
42
+ {placeholderForms.length > 1
43
+ ? ` (and ${placeholderForms.length - 1} other form${
44
+ placeholderForms.length > 2 ? 's' : ''
45
+ } like it)`
46
+ : ''}
47
+ . {noFallback ? 'No fallback address is set either. ' : ''}
48
+ Messages are still saved under Messages — but no one is told they arrived.
49
+ </>
50
+ ) : (
51
+ <>
52
+ No contact address is configured anywhere, so notifications have nowhere to
53
+ go. Messages are still saved under Messages — but no one is told they
54
+ arrived.
55
+ </>
56
+ )}
57
+ </p>
58
+ </div>
59
+ <div className="flex shrink-0 items-center gap-2">
60
+ <Button asChild size="sm" variant="outline" className="border-amber-400">
61
+ <Link href="/cms/messages">Set an address</Link>
62
+ </Button>
63
+ <Button
64
+ variant="ghost"
65
+ size="icon"
66
+ onClick={() => setDismissed(true)}
67
+ aria-label="Dismiss for now"
68
+ className="h-8 w-8"
69
+ >
70
+ <X className="h-4 w-4" />
71
+ </Button>
72
+ </div>
73
+ </div>
74
+ );
75
+ }
@@ -1,23 +1,23 @@
1
- "use client";
2
-
3
- import React, { createContext, useContext } from "react";
4
-
5
- const CortexAiActiveContext = createContext<boolean>(false);
6
-
7
- export function CortexAiActiveProvider({
8
- children,
9
- isActive,
10
- }: {
11
- children: React.ReactNode;
12
- isActive: boolean;
13
- }) {
14
- return (
15
- <CortexAiActiveContext.Provider value={isActive}>
16
- {children}
17
- </CortexAiActiveContext.Provider>
18
- );
19
- }
20
-
21
- export function useCortexAiActive() {
22
- return useContext(CortexAiActiveContext);
23
- }
1
+ "use client";
2
+
3
+ import React, { createContext, useContext } from "react";
4
+
5
+ const CortexAiActiveContext = createContext<boolean>(false);
6
+
7
+ export function CortexAiActiveProvider({
8
+ children,
9
+ isActive,
10
+ }: {
11
+ children: React.ReactNode;
12
+ isActive: boolean;
13
+ }) {
14
+ return (
15
+ <CortexAiActiveContext.Provider value={isActive}>
16
+ {children}
17
+ </CortexAiActiveContext.Provider>
18
+ );
19
+ }
20
+
21
+ export function useCortexAiActive() {
22
+ return useContext(CortexAiActiveContext);
23
+ }