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,87 +1,87 @@
1
- import { redirect } from 'next/navigation';
2
- import { createClient } from '@nextblock-cms/db/server';
3
-
4
- import { isEmailConfigured } from '../../../lib/config/email-settings';
5
- import {
6
- getStoreContactEmail,
7
- resolveSellerContactEmail,
8
- } from '../../../lib/commerce/seller-contact';
9
- import { loadInbox, loadThreadDetail, type ThreadDetail } from './loadInbox';
10
- import MessagesClient from './MessagesClient';
11
-
12
- export const metadata = {
13
- title: 'Messages | NextBlock™ CMS',
14
- };
15
-
16
- // Messages arrive from the public site at any moment; a cached page would show an admin
17
- // an empty inbox that is not.
18
- export const dynamic = 'force-dynamic';
19
-
20
- export default async function MessagesPage({
21
- searchParams,
22
- }: {
23
- searchParams: Promise<{ source?: string; page?: string; thread?: string; handled?: string }>;
24
- }) {
25
- const params = await searchParams;
26
-
27
- const supabase = createClient();
28
- const {
29
- data: { user },
30
- } = await supabase.auth.getUser();
31
- if (!user) redirect('/sign-in');
32
-
33
- const { data: profile } = await supabase
34
- .from('profiles')
35
- .select('role')
36
- .eq('id', user.id)
37
- .maybeSingle();
38
-
39
- if (profile?.role !== 'ADMIN' && profile?.role !== 'WRITER') {
40
- redirect('/unauthorized');
41
- }
42
-
43
- const isAdmin = profile?.role === 'ADMIN';
44
-
45
- const [inbox, storeContactEmail, resolvedFallback, smtpConfigured] = await Promise.all([
46
- loadInbox({
47
- source: params.source,
48
- page: params.page ? Number(params.page) : 0,
49
- isAdmin,
50
- showHandled: params.handled === '1',
51
- }),
52
- isAdmin ? getStoreContactEmail() : Promise.resolve(''),
53
- isAdmin
54
- ? resolveSellerContactEmail()
55
- : Promise.resolve({ email: null, source: 'none' as const }),
56
- isEmailConfigured().catch(() => false),
57
- ]);
58
-
59
- // Only the open conversation's history is loaded, not every thread's.
60
- let openThread: ThreadDetail | null = null;
61
- if (isAdmin && params.thread) {
62
- openThread = await loadThreadDetail(params.thread);
63
- }
64
-
65
- return (
66
- <div className="space-y-6">
67
- <div>
68
- <h1 className="text-2xl font-bold">Messages</h1>
69
- <p className="text-sm text-muted-foreground">
70
- Everything people send you — product enquiries, contact forms, reviews and
71
- comments — in one place.
72
- </p>
73
- </div>
74
-
75
- <MessagesClient
76
- inbox={inbox}
77
- openThread={openThread}
78
- isAdmin={isAdmin}
79
- activeSource={params.source ?? ''}
80
- showHandled={params.handled === '1'}
81
- storeContactEmail={storeContactEmail}
82
- resolvedFallback={{ email: resolvedFallback.email, source: resolvedFallback.source }}
83
- smtpConfigured={smtpConfigured}
84
- />
85
- </div>
86
- );
87
- }
1
+ import { redirect } from 'next/navigation';
2
+ import { createClient } from '@nextblock-cms/db/server';
3
+
4
+ import { isEmailConfigured } from '../../../lib/config/email-settings';
5
+ import {
6
+ getStoreContactEmail,
7
+ resolveSellerContactEmail,
8
+ } from '../../../lib/commerce/seller-contact';
9
+ import { loadInbox, loadThreadDetail, type ThreadDetail } from './loadInbox';
10
+ import MessagesClient from './MessagesClient';
11
+
12
+ export const metadata = {
13
+ title: 'Messages | NextBlock™ CMS',
14
+ };
15
+
16
+ // Messages arrive from the public site at any moment; a cached page would show an admin
17
+ // an empty inbox that is not.
18
+ export const dynamic = 'force-dynamic';
19
+
20
+ export default async function MessagesPage({
21
+ searchParams,
22
+ }: {
23
+ searchParams: Promise<{ source?: string; page?: string; thread?: string; handled?: string }>;
24
+ }) {
25
+ const params = await searchParams;
26
+
27
+ const supabase = createClient();
28
+ const {
29
+ data: { user },
30
+ } = await supabase.auth.getUser();
31
+ if (!user) redirect('/sign-in');
32
+
33
+ const { data: profile } = await supabase
34
+ .from('profiles')
35
+ .select('role')
36
+ .eq('id', user.id)
37
+ .maybeSingle();
38
+
39
+ if (profile?.role !== 'ADMIN' && profile?.role !== 'WRITER') {
40
+ redirect('/unauthorized');
41
+ }
42
+
43
+ const isAdmin = profile?.role === 'ADMIN';
44
+
45
+ const [inbox, storeContactEmail, resolvedFallback, smtpConfigured] = await Promise.all([
46
+ loadInbox({
47
+ source: params.source,
48
+ page: params.page ? Number(params.page) : 0,
49
+ isAdmin,
50
+ showHandled: params.handled === '1',
51
+ }),
52
+ isAdmin ? getStoreContactEmail() : Promise.resolve(''),
53
+ isAdmin
54
+ ? resolveSellerContactEmail()
55
+ : Promise.resolve({ email: null, source: 'none' as const }),
56
+ isEmailConfigured().catch(() => false),
57
+ ]);
58
+
59
+ // Only the open conversation's history is loaded, not every thread's.
60
+ let openThread: ThreadDetail | null = null;
61
+ if (isAdmin && params.thread) {
62
+ openThread = await loadThreadDetail(params.thread);
63
+ }
64
+
65
+ return (
66
+ <div className="space-y-6">
67
+ <div>
68
+ <h1 className="text-2xl font-bold">Messages</h1>
69
+ <p className="text-sm text-muted-foreground">
70
+ Everything people send you — product enquiries, contact forms, reviews and
71
+ comments — in one place.
72
+ </p>
73
+ </div>
74
+
75
+ <MessagesClient
76
+ inbox={inbox}
77
+ openThread={openThread}
78
+ isAdmin={isAdmin}
79
+ activeSource={params.source ?? ''}
80
+ showHandled={params.handled === '1'}
81
+ storeContactEmail={storeContactEmail}
82
+ resolvedFallback={{ email: resolvedFallback.email, source: resolvedFallback.source }}
83
+ smtpConfigured={smtpConfigured}
84
+ />
85
+ </div>
86
+ );
87
+ }
@@ -1,37 +1,37 @@
1
- import { createClient } from '@nextblock-cms/db/server';
2
-
3
- /**
4
- * Admin gate shared by the Messages server actions.
5
- *
6
- * Lives outside `actions.ts` because that file is `'use server'`, where every export
7
- * must itself be a valid server action — a helper returning a Supabase client cannot be
8
- * exported from there.
9
- *
10
- * This is the check standing between a WRITER and every private conversation on the
11
- * site: visitor names, addresses, and free text. RLS enforces the same rule
12
- * independently, so a bug here fails closed rather than open, but the redundancy is the
13
- * point.
14
- */
15
- export async function requireAdminSupabaseClient() {
16
- const supabase = createClient();
17
- const {
18
- data: { user },
19
- error: userError,
20
- } = await supabase.auth.getUser();
21
-
22
- if (userError || !user) {
23
- throw new Error('You must be logged in to manage messages.');
24
- }
25
-
26
- const { data: profile, error: profileError } = await supabase
27
- .from('profiles')
28
- .select('role, full_name')
29
- .eq('id', user.id)
30
- .single();
31
-
32
- if (profileError || !profile || profile.role !== 'ADMIN') {
33
- throw new Error('You do not have permission to manage messages.');
34
- }
35
-
36
- return { supabase, userId: user.id, fullName: profile.full_name as string | null };
37
- }
1
+ import { createClient } from '@nextblock-cms/db/server';
2
+
3
+ /**
4
+ * Admin gate shared by the Messages server actions.
5
+ *
6
+ * Lives outside `actions.ts` because that file is `'use server'`, where every export
7
+ * must itself be a valid server action — a helper returning a Supabase client cannot be
8
+ * exported from there.
9
+ *
10
+ * This is the check standing between a WRITER and every private conversation on the
11
+ * site: visitor names, addresses, and free text. RLS enforces the same rule
12
+ * independently, so a bug here fails closed rather than open, but the redundancy is the
13
+ * point.
14
+ */
15
+ export async function requireAdminSupabaseClient() {
16
+ const supabase = createClient();
17
+ const {
18
+ data: { user },
19
+ error: userError,
20
+ } = await supabase.auth.getUser();
21
+
22
+ if (userError || !user) {
23
+ throw new Error('You must be logged in to manage messages.');
24
+ }
25
+
26
+ const { data: profile, error: profileError } = await supabase
27
+ .from('profiles')
28
+ .select('role, full_name')
29
+ .eq('id', user.id)
30
+ .single();
31
+
32
+ if (profileError || !profile || profile.role !== 'ADMIN') {
33
+ throw new Error('You do not have permission to manage messages.');
34
+ }
35
+
36
+ return { supabase, userId: user.id, fullName: profile.full_name as string | null };
37
+ }
@@ -176,9 +176,9 @@ interface NavigationMenuDndProps {
176
176
  initialItems: NavigationItem[];
177
177
  }
178
178
 
179
- export default function NavigationMenuDnd({ menuKey, languageCode, initialItems }: NavigationMenuDndProps) {
180
- void menuKey;
181
- // Prevent SSR/hydration mismatches from dnd-kit by rendering on client only
179
+ export default function NavigationMenuDnd({ menuKey, languageCode, initialItems }: NavigationMenuDndProps) {
180
+ void menuKey;
181
+ // Prevent SSR/hydration mismatches from dnd-kit by rendering on client only
182
182
  // Keep hooks order stable; move early return after hooks
183
183
  const [isMounted, setIsMounted] = useState(false);
184
184
  useEffect(() => { setIsMounted(true); }, []);
@@ -14,6 +14,8 @@ import CopyContentFromLanguage from "../../../components/CopyContentFromLanguage
14
14
  import RevisionHistoryButton from "../../../revisions/RevisionHistoryButton";
15
15
  import { UploadFolderProvider } from '../../../media/UploadFolderContext';
16
16
  import { CortexAiPageContextRegistrar } from "../../../components/CortexAiPageContext";
17
+ import { PageSeoAuditSection } from "../../../../../components/seo/PageSeoAuditSection";
18
+ import { PageSeoProvider } from "../../../../../lib/seo/page-audit-context";
17
19
  import type { Database } from "@nextblock-cms/db";
18
20
  import DraftStatusActions from "../../../components/DraftStatusActions";
19
21
 
@@ -76,6 +78,20 @@ export default function EditPageClient({
76
78
  translationGroupId: page.translation_group_id,
77
79
  }}
78
80
  />
81
+ {/*
82
+ The provider has to enclose both `PageForm` and `BlockEditorArea`,
83
+ because the page-level SEO audit is assembled from halves they each own:
84
+ the form publishes the meta title and description, the block editor
85
+ publishes the live block array, and neither knows the other exists. It is
86
+ seeded from the server-rendered row so the panel has a real document to
87
+ grade on first paint rather than an empty one that scores zero until the
88
+ block editor finishes mounting.
89
+ */}
90
+ <PageSeoProvider
91
+ initialBlocks={page.blocks}
92
+ initialMetaDescription={page.meta_description}
93
+ initialMetaTitle={page.meta_title}
94
+ >
79
95
  <div className="space-y-8 w-full mx-auto px-6">
80
96
  <div className="flex justify-between items-center flex-wrap gap-4 w-full">
81
97
  <div className="flex items-center gap-3">
@@ -151,6 +167,9 @@ export default function EditPageClient({
151
167
  <PageForm
152
168
  page={page}
153
169
  formAction={updatePageAction}
170
+ // Read-only: the form never writes blocks, it only lets Cortex AI read them so it
171
+ // can summarize the page when drafting meta title and description.
172
+ contentBlocks={page.blocks}
154
173
  actionButtonText="Update Page Metadata"
155
174
  isEditing={true}
156
175
  availableLanguagesProp={allSiteLanguages}
@@ -158,6 +177,14 @@ export default function EditPageClient({
158
177
  initialFeatureImageId={initialFeatureImageId}
159
178
  />
160
179
 
180
+ {/*
181
+ Sits between the metadata form and the blocks because that is what it
182
+ grades: everything above it and everything below it, together. It
183
+ collapses to a single summary row so the block editor keeps its place
184
+ on the screen.
185
+ */}
186
+ <PageSeoAuditSection />
187
+
161
188
  <Separator className="my-8" />
162
189
 
163
190
  <div className="w-full mx-auto px-6">
@@ -170,6 +197,7 @@ export default function EditPageClient({
170
197
  />
171
198
  </div>
172
199
  </div>
200
+ </PageSeoProvider>
173
201
  </UploadFolderProvider>
174
202
  );
175
203
  }
@@ -1,7 +1,7 @@
1
1
  // app/cms/pages/components/PageForm.tsx
2
2
  "use client";
3
3
 
4
- import { useEffect, useRef, useState, useTransition } from "react";
4
+ import { useEffect, useMemo, useRef, useState, useTransition } from "react";
5
5
  import { useRouter, useSearchParams } from "next/navigation";
6
6
  import { Button } from "@nextblock-cms/ui";
7
7
  import { Spinner, Alert, AlertDescription } from "@nextblock-cms/ui";
@@ -19,11 +19,27 @@ import type { Database } from "@nextblock-cms/db";
19
19
  import { useAuth } from '../../../../context/AuthContext';
20
20
  import { useHotkeys } from '../../../../hooks/use-hotkeys';
21
21
  import FeatureImageField from "../../components/FeatureImageField";
22
+ import { useCortexAiActive } from "../../components/CortexAiActiveContext";
23
+ import GenerateMetaButton, {
24
+ type GeneratedSeoMetadata,
25
+ } from "../../../../components/seo/GenerateMetaButton";
26
+ import SocialPreviewDialog from "../../../../components/seo/SocialPreviewDialog";
27
+ import { buildSeoContentForGeneration } from "../../../../lib/seo/block-content";
28
+ import { usePageSeo } from "../../../../lib/seo/page-audit-context";
29
+ import { resolveSiteUrl } from "../../../../lib/site-url";
22
30
 
23
31
  type Page = Database['public']['Tables']['pages']['Row'];
24
32
  type Language = Database['public']['Tables']['languages']['Row'];
25
33
  // Remove: import { getActiveLanguagesClientSide } from "@nextblock-cms/db";
26
34
 
35
+ /**
36
+ * Google truncates a title around 60 characters and a description around 160. These are
37
+ * soft advice, not validation — the same numbers `SiteSeoSettingsForm` shows, and the same
38
+ * amber-past-the-limit treatment, so the counter means one thing everywhere in the CMS.
39
+ */
40
+ const META_TITLE_RECOMMENDED_MAX = 60;
41
+ const META_DESCRIPTION_RECOMMENDED_MAX = 160;
42
+
27
43
  interface PageFormProps {
28
44
  page?: (Page & { feature_image_id?: string | null }) | null;
29
45
  formAction: (formData: FormData) => Promise<{ error?: string } | void>;
@@ -34,6 +50,16 @@ interface PageFormProps {
34
50
  target_lang_id?: string;
35
51
  initialFeatureImageUrl?: string | null;
36
52
  initialFeatureImageId?: string | null;
53
+ /**
54
+ * The page's block rows, read-only, purely so Cortex AI can be asked to summarize the
55
+ * page it is writing metadata for.
56
+ *
57
+ * Deliberately NOT a form field and NOT persisted by this form: nothing here is added to
58
+ * the FormData, to `hasChanges`, or to the autosave dependency list. It flows one way,
59
+ * parent → form → AI request. Blocks have their own editor and their own save path
60
+ * (`BlockEditorArea`); this form must never become a second writer of them.
61
+ */
62
+ contentBlocks?: unknown;
37
63
  }
38
64
 
39
65
  export default function PageForm({
@@ -46,6 +72,7 @@ export default function PageForm({
46
72
  target_lang_id,
47
73
  initialFeatureImageUrl,
48
74
  initialFeatureImageId,
75
+ contentBlocks,
49
76
  }: PageFormProps) {
50
77
  const router = useRouter();
51
78
  const searchParams = useSearchParams();
@@ -83,6 +110,58 @@ export default function PageForm({
83
110
  const [featureImageId, setFeatureImageId] = useState<string | null>(
84
111
  initialFeatureImageId || page?.feature_image_id || null
85
112
  );
113
+ /**
114
+ * The resolved URL of the feature image, for the share preview only.
115
+ *
116
+ * This one is exempt from everything the autosave path looks at, and the exemption is
117
+ * deliberate rather than an oversight: it is absent from the FormData, from `hasChanges`,
118
+ * and from the autosave effect's dependency array. `feature_image_id` is the persisted
119
+ * field — `FeatureImageField` renders it as the hidden input and the diff above already
120
+ * watches it — and the URL is nothing but that id resolved against the `media` row, which
121
+ * the server redoes on the next render. Persisting it would store a duplicate that can go
122
+ * stale, and adding it to the autosave deps would only schedule a second save for the
123
+ * change `featureImageId` already covers.
124
+ *
125
+ * It exists because the preview was previously bound to `initialFeatureImageUrl`, which
126
+ * is fixed for the life of the render — so picking an image left the share card showing
127
+ * the old one (or a placeholder) until a full reload, which defeats a live preview.
128
+ */
129
+ const [featureImageUrl, setFeatureImageUrl] = useState<string | null>(
130
+ initialFeatureImageUrl || null
131
+ );
132
+
133
+ const isCortexAiActive = useCortexAiActive();
134
+ /**
135
+ * The social-specific copy Cortex AI returns alongside the meta fields.
136
+ *
137
+ * This is held in component state and never written anywhere. There are no
138
+ * `og_title` / `og_description` columns on `pages`, and adding one is not a one-line
139
+ * change — a new persisted field needs the useState, the sync-from-prop effect AND its
140
+ * dependency array, `hasChanges`, the autosave deps, the FormData fallback list, the JSX
141
+ * `name`, the server action's rawFormData/updateData/payload type, and the draft overlay
142
+ * in the edit RSC, and missing any one of those makes the field silently never save.
143
+ * So the OG copy exists to inform the meta fields, which DO persist, and the operator can
144
+ * fold it in with one click. The live share card is built from the meta title and
145
+ * description below plus the feature image.
146
+ */
147
+ const [socialSuggestion, setSocialSuggestion] = useState<{
148
+ ogDescription: string;
149
+ ogTitle: string;
150
+ } | null>(null);
151
+
152
+ /**
153
+ * Prose for the metadata call. Memoized because this form re-renders on every keystroke
154
+ * (each one re-arms the autosave timer) and flattening a page's whole block tree on each
155
+ * of those would be real, pointless work — the blocks themselves cannot change while
156
+ * this form is mounted, since they are edited by `BlockEditorArea` below.
157
+ *
158
+ * On a brand-new page with no blocks the title is a thin but genuine brief, which beats
159
+ * a permanently disabled button on day one.
160
+ */
161
+ const seoContent = useMemo(
162
+ () => buildSeoContentForGeneration(contentBlocks, title),
163
+ [contentBlocks, title]
164
+ );
86
165
 
87
166
  // Use the passed-in languages
88
167
  const [availableLanguages] = useState<Language[]>(availableLanguagesProp);
@@ -120,8 +199,13 @@ export default function PageForm({
120
199
  setMetaDescription(page.meta_description || "");
121
200
  setCustomCanonical(page.custom_canonical || "");
122
201
  setFeatureImageId(initialFeatureImageId || page.feature_image_id || null);
202
+ // Re-seeded in lockstep with the id above. Both come from the same server-resolved
203
+ // media row, so letting the id snap back to the props while the URL kept a newer value
204
+ // would put the preview and the field it previews out of step.
205
+ setFeatureImageUrl(initialFeatureImageUrl || null);
123
206
  }, [
124
207
  initialFeatureImageId,
208
+ initialFeatureImageUrl,
125
209
  page?.id,
126
210
  page?.custom_canonical,
127
211
  page?.language_id,
@@ -225,6 +309,32 @@ export default function PageForm({
225
309
  // meant editing only the canonical URL never scheduled a save.
226
310
  }, [title, slug, languageId, metaTitle, metaDescription, customCanonical, featureImageId, page, isEditing]);
227
311
 
312
+ /**
313
+ * Hand the live meta fields to the page-level SEO audit.
314
+ *
315
+ * The panel that grades a page cannot read this form's state directly — it renders as a
316
+ * sibling, not a child — so the title and description have to be published outward. This
317
+ * is a one-way broadcast of values this form ALREADY owns and already persists; it adds
318
+ * no field, changes nothing about what `hasChanges` above compares, and puts nothing in
319
+ * the FormData. The autosave diff is deliberately untouched, because publishing a value
320
+ * is not the same event as changing one: were `setMeta` wired into that diff, merely
321
+ * mounting the audit panel would look like an edit and schedule a draft write.
322
+ *
323
+ * `usePageSeo()` returns null when no provider is above this form, which is a supported
324
+ * arrangement rather than a bug — some surfaces embed these fields without a page-level
325
+ * panel — so the call is optional all the way through.
326
+ *
327
+ * The dependency is `setPageSeoMeta`, not the whole context value: the setter is a stable
328
+ * `useCallback`, whereas the context object is rebuilt whenever the blocks array changes,
329
+ * which would re-run this effect on every keystroke elsewhere in the editor. The setter
330
+ * also bails when the values are unchanged, so there is no path from this effect back into
331
+ * a render of this form and therefore no loop.
332
+ */
333
+ const setPageSeoMeta = usePageSeo()?.setMeta;
334
+ useEffect(() => {
335
+ setPageSeoMeta?.({ metaDescription, metaTitle });
336
+ }, [metaDescription, metaTitle, setPageSeoMeta]);
337
+
228
338
  // Removed languagesLoading from this condition
229
339
  if (authLoading) {
230
340
  return <div>Loading form...</div>;
@@ -234,6 +344,46 @@ export default function PageForm({
234
344
  return <div>Please log in to manage pages.</div>;
235
345
  }
236
346
 
347
+ const selectedLanguageCode =
348
+ availableLanguages.find((lang) => lang.id.toString() === languageId)?.code ?? null;
349
+ // What the share card will actually show. Meta title wins when set, because that is what
350
+ // `buildSocialMetadata` uses; otherwise the page title, exactly as the public site does.
351
+ const previewTitle = metaTitle.trim() || title.trim();
352
+ const previewUrl = `${resolveSiteUrl()}/${(slug || "").replace(/^\/+/, "")}`;
353
+
354
+ /**
355
+ * Fold a generation into the form.
356
+ *
357
+ * Setting state is the whole persistence story here: the autosave effect above diffs
358
+ * this state against the `page` prop and calls `formAction` a second later, writing into
359
+ * `content_drafts`. A direct row UPDATE from this handler would bypass the draft and
360
+ * mutate the live page — precisely the thing draft mode exists to prevent.
361
+ */
362
+ /**
363
+ * Track the picked image for the preview as well as for the save.
364
+ *
365
+ * The id half feeds `hasChanges` and therefore the autosave; the URL half is purely
366
+ * presentational (see the state declaration above) and stops here.
367
+ */
368
+ const handleFeatureImageChange = (imageId: string | null, imageUrl: string | null) => {
369
+ setFeatureImageId(imageId);
370
+ setFeatureImageUrl(imageUrl);
371
+ };
372
+
373
+ const handleGeneratedMetadata = (result: GeneratedSeoMetadata) => {
374
+ if (result.metaTitle) {
375
+ setMetaTitle(result.metaTitle);
376
+ }
377
+ if (result.metaDescription) {
378
+ setMetaDescription(result.metaDescription);
379
+ }
380
+ setSocialSuggestion(
381
+ result.ogTitle || result.ogDescription
382
+ ? { ogDescription: result.ogDescription, ogTitle: result.ogTitle }
383
+ : null
384
+ );
385
+ };
386
+
237
387
  return (
238
388
  <form ref={formRef} onSubmit={handleSubmit} className="space-y-4 w-full mx-auto px-6">
239
389
  {isEditing && (
@@ -330,11 +480,52 @@ export default function PageForm({
330
480
  </div>
331
481
  </div>
332
482
 
333
- {/* Row 2: SEO Settings. Canonical override (optional): blank = self-referencing canonical. */}
483
+ {/* Row 2: SEO Settings. Canonical override (optional): blank = self-referencing canonical.
484
+
485
+ This header is no longer gated on `isCortexAiActive`. It now carries the share-card
486
+ trigger, which has nothing to do with the AI package and must be reachable on every
487
+ install; only the generate button remains premium-gated, inside the row. */}
488
+ <div className="flex flex-wrap items-center justify-between gap-2 pt-1">
489
+ <span className="text-[11px] uppercase tracking-wider text-muted-foreground/80 font-semibold">
490
+ Search &amp; social
491
+ </span>
492
+ <div className="flex flex-wrap items-center gap-2">
493
+ {isCortexAiActive && (
494
+ <GenerateMetaButton
495
+ content={seoContent}
496
+ locale={selectedLanguageCode}
497
+ onGenerated={handleGeneratedMetadata}
498
+ title={title}
499
+ />
500
+ )}
501
+ {/* The share-card rehearsal lives in a modal rather than inline: it is a glance,
502
+ not a field, and inline it added roughly four hundred pixels to a form that
503
+ already scrolls. Sitting here it is adjacent to the two inputs it previews.
504
+ Every prop is live form state, so the card repaints while the dialog is open,
505
+ and none of it is written anywhere — see `SocialPreviewDialog`. */}
506
+ <SocialPreviewDialog
507
+ description={metaDescription}
508
+ imageUrl={featureImageUrl}
509
+ title={previewTitle}
510
+ url={previewUrl}
511
+ />
512
+ </div>
513
+ </div>
334
514
  <div className="grid grid-cols-1 md:grid-cols-12 gap-4">
335
515
  {/* Meta Title */}
336
516
  <div className="md:col-span-4 flex flex-col gap-1">
337
- <Label htmlFor="meta_title" className="text-xs font-medium">Meta Title (SEO)</Label>
517
+ <div className="flex items-center justify-between gap-2">
518
+ <Label htmlFor="meta_title" className="text-xs font-medium">Meta Title (SEO)</Label>
519
+ <span
520
+ className={`text-[10px] ${
521
+ metaTitle.length > META_TITLE_RECOMMENDED_MAX
522
+ ? 'text-amber-600'
523
+ : 'text-muted-foreground'
524
+ }`}
525
+ >
526
+ {metaTitle.length}/{META_TITLE_RECOMMENDED_MAX}
527
+ </span>
528
+ </div>
338
529
  <Input
339
530
  id="meta_title"
340
531
  name="meta_title"
@@ -346,7 +537,18 @@ export default function PageForm({
346
537
 
347
538
  {/* Meta Description */}
348
539
  <div className="md:col-span-4 flex flex-col gap-1">
349
- <Label htmlFor="meta_description" className="text-xs font-medium">Meta Description (SEO)</Label>
540
+ <div className="flex items-center justify-between gap-2">
541
+ <Label htmlFor="meta_description" className="text-xs font-medium">Meta Description (SEO)</Label>
542
+ <span
543
+ className={`text-[10px] ${
544
+ metaDescription.length > META_DESCRIPTION_RECOMMENDED_MAX
545
+ ? 'text-amber-600'
546
+ : 'text-muted-foreground'
547
+ }`}
548
+ >
549
+ {metaDescription.length}/{META_DESCRIPTION_RECOMMENDED_MAX}
550
+ </span>
551
+ </div>
350
552
  <Textarea
351
553
  id="meta_description"
352
554
  name="meta_description"
@@ -372,10 +574,51 @@ export default function PageForm({
372
574
  </div>
373
575
  </div>
374
576
 
577
+ {/* Social-specific copy Cortex AI suggested. It is shown, not stored: pages have no
578
+ Open Graph columns, so the only way it can survive is by being folded into the
579
+ meta fields — hence the one-click apply rather than a read-only curiosity. */}
580
+ {socialSuggestion && (
581
+ <div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
582
+ <p className="text-[11px] font-medium text-muted-foreground">
583
+ Cortex AI also drafted social-specific copy. Pages have no separate Open Graph
584
+ fields, so this is only a suggestion for the meta fields above.
585
+ </p>
586
+ {socialSuggestion.ogTitle && (
587
+ <p className="text-xs text-foreground"><strong>Social title:</strong> {socialSuggestion.ogTitle}</p>
588
+ )}
589
+ {socialSuggestion.ogDescription && (
590
+ <p className="text-xs text-foreground"><strong>Social description:</strong> {socialSuggestion.ogDescription}</p>
591
+ )}
592
+ <div className="flex gap-2">
593
+ <Button
594
+ type="button"
595
+ variant="outline"
596
+ size="sm"
597
+ className="h-7 px-2 text-[11px]"
598
+ onClick={() => {
599
+ if (socialSuggestion.ogTitle) setMetaTitle(socialSuggestion.ogTitle);
600
+ if (socialSuggestion.ogDescription) setMetaDescription(socialSuggestion.ogDescription);
601
+ }}
602
+ >
603
+ Use for meta fields
604
+ </Button>
605
+ <Button
606
+ type="button"
607
+ variant="ghost"
608
+ size="sm"
609
+ className="h-7 px-2 text-[11px]"
610
+ onClick={() => setSocialSuggestion(null)}
611
+ >
612
+ Dismiss
613
+ </Button>
614
+ </div>
615
+ </div>
616
+ )}
617
+
375
618
  <FeatureImageField
376
619
  initialImageId={initialFeatureImageId || page?.feature_image_id || null}
377
620
  initialImageUrl={initialFeatureImageUrl || null}
378
- onImageIdChange={setFeatureImageId}
621
+ onImageIdChange={handleFeatureImageChange}
379
622
  uploadFolder={`pages/${(slug || 'untitled').toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-_]/g, '')}/`}
380
623
  />
381
624