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
@@ -0,0 +1,195 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * The findings half of the SEO panel: every issue the audit raised, grouped
5
+ * under the part of the page it belongs to, each with the one-click Cortex AI
6
+ * repair where the engine says a repair is possible.
7
+ *
8
+ * Grouping by category rather than listing findings in severity order is a
9
+ * deliberate trade. Severity order answers "what is worst?", which the score
10
+ * dial and the summary counts already answer; category order answers "what do I
11
+ * have to go and change?", which is the question someone with the document open
12
+ * in front of them is actually asking. Within a category the audit's own
13
+ * severity ordering is preserved, so the worst thing in each section still
14
+ * comes first.
15
+ *
16
+ * Severity is never carried by colour alone. Each finding gets an icon and a
17
+ * spelled-out word ("Error", "Warning", "Suggestion") next to the tint, so the
18
+ * list reads identically to someone who cannot distinguish red from amber, and
19
+ * survives being printed or screenshotted in greyscale.
20
+ */
21
+
22
+ import * as React from 'react';
23
+
24
+ import { Button } from '@nextblock-cms/ui';
25
+ import { cn } from '@nextblock-cms/utils';
26
+ import type { SeoIssue, SeoIssueCategory, SeoIssueSeverity } from '@nextblock-cms/utils/seo';
27
+ import { AlertTriangle, Info, Loader2, Sparkles, XCircle } from 'lucide-react';
28
+
29
+ import { canFixIssueWithCortexAi } from '../../lib/seo/fix-prompts';
30
+
31
+ /**
32
+ * Category order, chosen to walk the document the way an author does: fix the
33
+ * skeleton first (headings), then how it reads, then what it is about, then how
34
+ * much of it there is, then its assets, and finally the metadata that wraps it.
35
+ */
36
+ const CATEGORY_ORDER: SeoIssueCategory[] = [
37
+ 'headings',
38
+ 'readability',
39
+ 'keyword',
40
+ 'content',
41
+ 'images',
42
+ 'meta',
43
+ ];
44
+
45
+ const CATEGORY_LABEL: Record<SeoIssueCategory, string> = {
46
+ content: 'Content',
47
+ headings: 'Headings',
48
+ images: 'Images',
49
+ keyword: 'Keyphrase',
50
+ meta: 'Meta',
51
+ readability: 'Readability',
52
+ };
53
+
54
+ /**
55
+ * Severity styling, following the same raw-palette-with-dark-variant pattern as
56
+ * `VisibilityControl`: red for something materially broken, amber for something
57
+ * measurably worse than it should be, and the neutral muted tokens for advice
58
+ * the author is free to ignore.
59
+ */
60
+ const SEVERITY_STYLE: Record<SeoIssueSeverity, string> = {
61
+ error: 'border-red-500/40 bg-red-500/10',
62
+ info: 'border-border bg-muted/40',
63
+ warning: 'border-amber-500/40 bg-amber-500/10',
64
+ };
65
+
66
+ const SEVERITY_ICON_STYLE: Record<SeoIssueSeverity, string> = {
67
+ error: 'text-red-600 dark:text-red-400',
68
+ info: 'text-muted-foreground',
69
+ warning: 'text-amber-600 dark:text-amber-400',
70
+ };
71
+
72
+ const SEVERITY_ICON: Record<SeoIssueSeverity, React.ComponentType<{ className?: string }>> = {
73
+ error: XCircle,
74
+ info: Info,
75
+ warning: AlertTriangle,
76
+ };
77
+
78
+ /** "Suggestion" rather than "Info": it tells the reader what to do with it. */
79
+ const SEVERITY_LABEL: Record<SeoIssueSeverity, string> = {
80
+ error: 'Error',
81
+ info: 'Suggestion',
82
+ warning: 'Warning',
83
+ };
84
+
85
+ export interface SeoIssueListProps {
86
+ /** The issue whose fix is in flight, if any; disables every other button. */
87
+ busyIssueId?: string | null;
88
+ className?: string;
89
+ /** Failure text from the last fix attempt, keyed by issue id. */
90
+ errors?: Record<string, string>;
91
+ issues: SeoIssue[];
92
+ /**
93
+ * Runs the Cortex AI repair. Leaving this undefined removes every Fix button
94
+ * from the list — which is how the panel expresses both "Cortex AI is not
95
+ * activated on this install" and "no editor instance was handed to us", two
96
+ * situations where offering the button would only produce a dead click.
97
+ */
98
+ onFix?: (issue: SeoIssue) => void;
99
+ }
100
+
101
+ export function SeoIssueList({ busyIssueId, className, errors, issues, onFix }: SeoIssueListProps) {
102
+ const grouped = React.useMemo(() => {
103
+ return CATEGORY_ORDER.map((category) => ({
104
+ category,
105
+ issues: issues.filter((issue) => issue.category === category),
106
+ })).filter((group) => group.issues.length > 0);
107
+ }, [issues]);
108
+
109
+ if (grouped.length === 0) {
110
+ return null;
111
+ }
112
+
113
+ return (
114
+ <div className={cn('space-y-4', className)}>
115
+ {grouped.map((group) => (
116
+ <section key={group.category} className="space-y-2">
117
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
118
+ {CATEGORY_LABEL[group.category]}
119
+ </h4>
120
+
121
+ <ul className="space-y-2">
122
+ {group.issues.map((issue) => {
123
+ const Icon = SEVERITY_ICON[issue.severity];
124
+ const isBusy = busyIssueId === issue.id;
125
+ const error = errors?.[issue.id];
126
+ const showFix = onFix !== undefined && canFixIssueWithCortexAi(issue);
127
+
128
+ return (
129
+ <li
130
+ key={issue.id}
131
+ className={cn('rounded-md border p-2.5 text-sm', SEVERITY_STYLE[issue.severity])}
132
+ >
133
+ <div className="flex items-start gap-2">
134
+ <Icon
135
+ aria-hidden="true"
136
+ className={cn('mt-0.5 h-4 w-4 shrink-0', SEVERITY_ICON_STYLE[issue.severity])}
137
+ />
138
+ <div className="min-w-0 flex-1 space-y-1">
139
+ <p className="font-medium leading-snug">
140
+ <span className="sr-only">{SEVERITY_LABEL[issue.severity]}: </span>
141
+ {issue.message}
142
+ </p>
143
+ {issue.detail && (
144
+ <p className="text-xs leading-relaxed text-muted-foreground">
145
+ {issue.detail}
146
+ </p>
147
+ )}
148
+ <p
149
+ aria-hidden="true"
150
+ className={cn(
151
+ 'text-[11px] font-semibold uppercase tracking-wide',
152
+ SEVERITY_ICON_STYLE[issue.severity]
153
+ )}
154
+ >
155
+ {SEVERITY_LABEL[issue.severity]}
156
+ </p>
157
+ </div>
158
+ </div>
159
+
160
+ {showFix && (
161
+ <div className="mt-2 flex justify-end">
162
+ <Button
163
+ className="h-7 text-xs"
164
+ disabled={busyIssueId !== null && busyIssueId !== undefined}
165
+ onClick={() => onFix?.(issue)}
166
+ size="sm"
167
+ type="button"
168
+ variant="outline"
169
+ >
170
+ {isBusy ? (
171
+ <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
172
+ ) : (
173
+ <Sparkles className="mr-1.5 h-3.5 w-3.5" />
174
+ )}
175
+ {isBusy ? 'Fixing…' : 'Fix with Cortex AI'}
176
+ </Button>
177
+ </div>
178
+ )}
179
+
180
+ {error && (
181
+ <p className="mt-2 text-xs text-destructive" role="alert">
182
+ {error}
183
+ </p>
184
+ )}
185
+ </li>
186
+ );
187
+ })}
188
+ </ul>
189
+ </section>
190
+ ))}
191
+ </div>
192
+ );
193
+ }
194
+
195
+ export default SeoIssueList;
@@ -0,0 +1,144 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * The 0-100 SEO score, drawn as a ring.
5
+ *
6
+ * Built from inline SVG rather than the shared `Progress` primitive because the
7
+ * primitive hard-codes `bg-primary` on its indicator, and the whole point of
8
+ * this control is that its colour carries the verdict: emerald when the page is
9
+ * in good shape, amber when it needs attention, red when it is failing. A bar
10
+ * that is always brand-purple would force the reader to parse the number before
11
+ * they learn anything, which defeats having a meter at all.
12
+ *
13
+ * Accessibility is the other reason for hand-drawing it. The ring is exposed as
14
+ * an ARIA `meter` with a real name, a real value and a spoken `aria-valuetext`
15
+ * that includes the band, so a screen reader announces "SEO score: 82 out of
16
+ * 100, good" instead of reading an unlabelled graphic. The band is also printed
17
+ * as text under the ring, so nothing here is communicated by colour alone.
18
+ */
19
+
20
+ import * as React from 'react';
21
+
22
+ import { cn } from '@nextblock-cms/utils';
23
+ import type { SeoAuditScope, SeoScoreBand } from '@nextblock-cms/utils/seo';
24
+
25
+ /**
26
+ * Band to colour, following the CMS convention set by `VisibilityControl`:
27
+ * emerald reads as fine, amber as a caution, red as a failure.
28
+ *
29
+ * `excellent` and `good` deliberately share emerald. Splitting them across two
30
+ * hues would introduce a fourth colour with no established meaning in this UI;
31
+ * the difference between the two bands is carried by the label instead, which
32
+ * is the part a reader can actually act on.
33
+ */
34
+ const BAND_STROKE: Record<SeoScoreBand, string> = {
35
+ excellent: 'text-emerald-500',
36
+ fair: 'text-amber-500',
37
+ good: 'text-emerald-500',
38
+ poor: 'text-red-500',
39
+ };
40
+
41
+ const BAND_TEXT: Record<SeoScoreBand, string> = {
42
+ excellent: 'text-emerald-700 dark:text-emerald-400',
43
+ fair: 'text-amber-700 dark:text-amber-400',
44
+ good: 'text-emerald-700 dark:text-emerald-400',
45
+ poor: 'text-red-700 dark:text-red-400',
46
+ };
47
+
48
+ /** Reader-facing wording for each band; `poor` is phrased as work, not failure. */
49
+ const BAND_LABEL: Record<SeoScoreBand, string> = {
50
+ excellent: 'Excellent',
51
+ fair: 'Fair',
52
+ good: 'Good',
53
+ poor: 'Needs work',
54
+ };
55
+
56
+ /**
57
+ * What the number under the ring is a score *of*.
58
+ *
59
+ * This caption is the one line that tells a reader whether they are looking at a
60
+ * verdict on the whole page or on the paragraph in front of them, and confusing
61
+ * the two is the exact bug the page-level audit was introduced to end. It is
62
+ * therefore keyed off the scope rather than written once and left to drift.
63
+ */
64
+ const SCOPE_CAPTION: Record<SeoAuditScope, string> = {
65
+ block: 'Weighted across the checks a single block can pass or fail.',
66
+ page: 'Weighted across every check that applies to this page.',
67
+ };
68
+
69
+ export interface SeoScoreDialProps {
70
+ band: SeoScoreBand;
71
+ className?: string;
72
+ /** 0-100. Values outside that range are clamped rather than drawn wrong. */
73
+ score: number;
74
+ /** Defaults to `'page'`, matching the audit engine's own default scope. */
75
+ scope?: SeoAuditScope;
76
+ }
77
+
78
+ /**
79
+ * Geometry constants. The ring is drawn in a fixed 120x120 user-space viewBox
80
+ * and scaled by CSS, so the stroke stays circular at any rendered size and the
81
+ * dash maths below never has to know how big the element ended up on screen.
82
+ */
83
+ const RADIUS = 52;
84
+ const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
85
+
86
+ export function SeoScoreDial({ band, className, score, scope = 'page' }: SeoScoreDialProps) {
87
+ const clamped = Math.max(0, Math.min(100, Math.round(Number.isFinite(score) ? score : 0)));
88
+ // A stroke-dashoffset of the full circumference is an empty ring and an
89
+ // offset of zero is a full one, so the filled fraction is the complement.
90
+ const dashOffset = CIRCUMFERENCE * (1 - clamped / 100);
91
+
92
+ return (
93
+ <div className={cn('flex items-center gap-4', className)}>
94
+ <div
95
+ aria-label="SEO score"
96
+ aria-valuemax={100}
97
+ aria-valuemin={0}
98
+ aria-valuenow={clamped}
99
+ aria-valuetext={`${clamped} out of 100, ${BAND_LABEL[band].toLowerCase()}`}
100
+ className="relative h-20 w-20 shrink-0"
101
+ role="meter"
102
+ >
103
+ <svg className="h-full w-full -rotate-90" viewBox="0 0 120 120">
104
+ <circle
105
+ className="text-muted"
106
+ cx="60"
107
+ cy="60"
108
+ fill="none"
109
+ r={RADIUS}
110
+ stroke="currentColor"
111
+ strokeWidth="10"
112
+ />
113
+ <circle
114
+ className={cn('transition-[stroke-dashoffset] duration-500', BAND_STROKE[band])}
115
+ cx="60"
116
+ cy="60"
117
+ fill="none"
118
+ r={RADIUS}
119
+ stroke="currentColor"
120
+ strokeDasharray={CIRCUMFERENCE}
121
+ strokeDashoffset={dashOffset}
122
+ strokeLinecap="round"
123
+ strokeWidth="10"
124
+ />
125
+ </svg>
126
+ {/* The number inside the ring duplicates `aria-valuetext`, so it is
127
+ hidden from assistive technology to avoid announcing it twice. */}
128
+ <span
129
+ aria-hidden="true"
130
+ className="absolute inset-0 flex items-center justify-center text-xl font-semibold tabular-nums"
131
+ >
132
+ {clamped}
133
+ </span>
134
+ </div>
135
+
136
+ <div className="min-w-0">
137
+ <p className={cn('text-sm font-semibold', BAND_TEXT[band])}>{BAND_LABEL[band]}</p>
138
+ <p className="text-xs text-muted-foreground">{SCOPE_CAPTION[scope]}</p>
139
+ </div>
140
+ </div>
141
+ );
142
+ }
143
+
144
+ export default SeoScoreDial;
@@ -0,0 +1,243 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useState } from 'react';
4
+ import { ImageIcon, MessageCircle, Repeat2, Heart, ThumbsUp, Share2 } from 'lucide-react';
5
+ import { cn } from '@nextblock-cms/utils';
6
+
7
+ /**
8
+ * A read-only rehearsal of the share card a page will produce.
9
+ *
10
+ * The value here is not decoration. Meta title and description are the two fields an
11
+ * operator is most likely to get wrong in a way nothing else in the CMS will catch:
12
+ * they are invisible on the page itself, and the consequence — a headline chopped
13
+ * mid-word in someone's feed — only shows up after the link has already been shared.
14
+ * Rendering the truncation locally turns that into a visible, immediate edit.
15
+ *
16
+ * The three tabs are genuinely different, not one box repainted, because the platforms
17
+ * genuinely differ. Facebook shows a wide image above a grey caption bar with the domain
18
+ * in small caps and a hard title clamp. X's summary_large_image card has no caption bar
19
+ * and stamps the domain UNDER the text. LinkedIn keeps the image and gives the headline
20
+ * more room but drops the description entirely — which is exactly the surprise worth
21
+ * showing someone who has just spent five minutes wording a description. If all three
22
+ * looked the same, the component would be teaching a falsehood.
23
+ *
24
+ * The numbers below approximate observed rendering rather than a published spec — every
25
+ * platform reserves the right to reflow — so they are tuned to be slightly pessimistic.
26
+ * A preview that clips a little early nudges toward safer copy; one that clips late gives
27
+ * false confidence, which is the failure that actually costs something.
28
+ */
29
+ interface SocialPreviewProps {
30
+ description: string;
31
+ imageUrl?: string | null;
32
+ siteName?: string | null;
33
+ title: string;
34
+ url: string;
35
+ }
36
+
37
+ type SocialPlatform = 'facebook' | 'linkedin' | 'x';
38
+
39
+ const PLATFORM_TABS: ReadonlyArray<{ id: SocialPlatform; label: string }> = [
40
+ { id: 'facebook', label: 'Facebook' },
41
+ { id: 'x', label: 'X' },
42
+ { id: 'linkedin', label: 'LinkedIn' },
43
+ ];
44
+
45
+ /**
46
+ * Per-platform clamps, in characters. `description: 0` means the platform does not render
47
+ * a description at all — a meaningfully different statement from "renders a short one",
48
+ * and drawn differently below.
49
+ */
50
+ const PLATFORM_LIMITS: Record<SocialPlatform, { description: number; title: number }> = {
51
+ facebook: { description: 110, title: 65 },
52
+ linkedin: { description: 0, title: 90 },
53
+ x: { description: 125, title: 70 },
54
+ };
55
+
56
+ /**
57
+ * Clip to a character budget the way a feed does — at a word boundary, with an ellipsis —
58
+ * rather than mid-word, so the preview shows the shape of the real damage.
59
+ */
60
+ function clampToLength(value: string, maxLength: number): string {
61
+ const normalized = value.replace(/\s+/g, ' ').trim();
62
+ if (maxLength <= 0 || normalized.length <= maxLength) {
63
+ return normalized;
64
+ }
65
+
66
+ const sliced = normalized.slice(0, maxLength);
67
+ const lastSpace = sliced.lastIndexOf(' ');
68
+ const kept = lastSpace > maxLength * 0.6 ? sliced.slice(0, lastSpace) : sliced;
69
+ return `${kept.trimEnd()}…`;
70
+ }
71
+
72
+ /**
73
+ * The domain each platform stamps on the card. Falls back to the raw string when the URL
74
+ * is not parseable — while a slug is being typed, `url` is briefly not a valid URL.
75
+ */
76
+ function toDisplayDomain(url: string): string {
77
+ try {
78
+ return new URL(url).hostname.replace(/^www\./, '');
79
+ } catch {
80
+ return url.replace(/^https?:\/\//i, '').split('/')[0] || url;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Remote share images render through a plain `<img>`, deliberately.
86
+ *
87
+ * `next/image` would require every host an operator might point a feature image at to be
88
+ * pre-declared in `next.config` `images.remotePatterns`, and an undeclared host does not
89
+ * degrade — it throws. The CMS already makes this call the same way: the external-URL
90
+ * branch of `ImageBlockEditor` drops to a plain `<img>` for precisely this reason. A
91
+ * preview thumbnail gains nothing from the optimizer in exchange for that fragility.
92
+ */
93
+ function PreviewImage({
94
+ aspectClassName,
95
+ imageUrl,
96
+ title,
97
+ }: {
98
+ aspectClassName: string;
99
+ imageUrl?: string | null;
100
+ title: string;
101
+ }) {
102
+ if (!imageUrl) {
103
+ return (
104
+ <div
105
+ className={cn(
106
+ 'flex w-full flex-col items-center justify-center gap-1 bg-muted text-muted-foreground',
107
+ aspectClassName
108
+ )}
109
+ >
110
+ <ImageIcon aria-hidden="true" className="h-6 w-6" />
111
+ <span className="text-[10px] font-medium uppercase tracking-wide">No share image</span>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ return (
117
+ // eslint-disable-next-line @next/next/no-img-element
118
+ <img
119
+ alt={`Share preview for ${title}`}
120
+ className={cn('w-full bg-muted object-cover', aspectClassName)}
121
+ loading="lazy"
122
+ src={imageUrl}
123
+ />
124
+ );
125
+ }
126
+
127
+ export default function SocialPreview({
128
+ description,
129
+ imageUrl,
130
+ siteName,
131
+ title,
132
+ url,
133
+ }: SocialPreviewProps) {
134
+ const [platform, setPlatform] = useState<SocialPlatform>('facebook');
135
+
136
+ const domain = useMemo(() => toDisplayDomain(url), [url]);
137
+ const limits = PLATFORM_LIMITS[platform];
138
+ const clampedTitle = clampToLength(title || 'Untitled', limits.title);
139
+ const clampedDescription = clampToLength(description, limits.description);
140
+ // LinkedIn labels the card with the publisher name when one is known and quietly falls
141
+ // back to the bare domain when it is not; Facebook and X only ever show the domain.
142
+ const publisher = (siteName ?? '').trim() || domain;
143
+
144
+ return (
145
+ <div className="space-y-2">
146
+ {/* Hand-rolled tabs: this design system ships no Tabs primitive, and the house
147
+ pattern for a small switcher is buttons plus useState. The ARIA roles restore
148
+ the semantics a real tab component would have provided for free. */}
149
+ <div
150
+ aria-label="Social platform preview"
151
+ className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5"
152
+ role="tablist"
153
+ >
154
+ {PLATFORM_TABS.map((tab) => (
155
+ <button
156
+ aria-selected={platform === tab.id}
157
+ className={cn(
158
+ 'rounded px-2.5 py-1 text-[11px] font-medium transition-colors',
159
+ platform === tab.id
160
+ ? 'bg-background text-foreground shadow-sm'
161
+ : 'text-muted-foreground hover:text-foreground'
162
+ )}
163
+ key={tab.id}
164
+ onClick={() => setPlatform(tab.id)}
165
+ role="tab"
166
+ type="button"
167
+ >
168
+ {tab.label}
169
+ </button>
170
+ ))}
171
+ </div>
172
+
173
+ {platform === 'facebook' && (
174
+ <div className="max-w-[500px] overflow-hidden rounded-lg border border-border bg-card">
175
+ <PreviewImage aspectClassName="aspect-[1.91/1]" imageUrl={imageUrl} title={title} />
176
+ {/* Facebook's grey caption bar: domain in small caps above a bold headline. */}
177
+ <div className="space-y-0.5 bg-muted/60 px-3 py-2">
178
+ <p className="text-[11px] uppercase tracking-wide text-muted-foreground">{domain}</p>
179
+ <p className="text-sm font-semibold leading-snug text-foreground">{clampedTitle}</p>
180
+ {clampedDescription && (
181
+ <p className="text-xs leading-snug text-muted-foreground">{clampedDescription}</p>
182
+ )}
183
+ </div>
184
+ <div className="flex items-center gap-4 border-t border-border px-3 py-1.5 text-[11px] text-muted-foreground">
185
+ <span className="flex items-center gap-1">
186
+ <ThumbsUp aria-hidden="true" className="h-3 w-3" /> Like
187
+ </span>
188
+ <span className="flex items-center gap-1">
189
+ <MessageCircle aria-hidden="true" className="h-3 w-3" /> Comment
190
+ </span>
191
+ <span className="flex items-center gap-1">
192
+ <Share2 aria-hidden="true" className="h-3 w-3" /> Share
193
+ </span>
194
+ </div>
195
+ </div>
196
+ )}
197
+
198
+ {platform === 'x' && (
199
+ // X rounds the card hard, drops the caption bar, and puts the domain BELOW the
200
+ // text instead of above the headline.
201
+ <div className="max-w-[500px] overflow-hidden rounded-2xl border border-border bg-card">
202
+ <PreviewImage aspectClassName="aspect-[1.91/1]" imageUrl={imageUrl} title={title} />
203
+ <div className="space-y-0.5 px-3 py-2">
204
+ <p className="text-sm font-medium leading-snug text-foreground">{clampedTitle}</p>
205
+ {clampedDescription && (
206
+ <p className="text-xs leading-snug text-muted-foreground">{clampedDescription}</p>
207
+ )}
208
+ <p className="text-xs text-muted-foreground">{domain}</p>
209
+ </div>
210
+ <div className="flex items-center gap-5 border-t border-border px-3 py-1.5 text-[11px] text-muted-foreground">
211
+ <span className="flex items-center gap-1">
212
+ <MessageCircle aria-hidden="true" className="h-3 w-3" /> Reply
213
+ </span>
214
+ <span className="flex items-center gap-1">
215
+ <Repeat2 aria-hidden="true" className="h-3 w-3" /> Repost
216
+ </span>
217
+ <span className="flex items-center gap-1">
218
+ <Heart aria-hidden="true" className="h-3 w-3" /> Like
219
+ </span>
220
+ </div>
221
+ </div>
222
+ )}
223
+
224
+ {platform === 'linkedin' && (
225
+ // LinkedIn is the outlier worth showing: square-ish chrome, a roomier headline,
226
+ // and NO description at all — the meta description simply never appears here.
227
+ <div className="max-w-[520px] overflow-hidden rounded-sm border border-border bg-card">
228
+ <PreviewImage aspectClassName="aspect-[1.91/1]" imageUrl={imageUrl} title={title} />
229
+ <div className="space-y-1 bg-muted/40 px-3 py-2.5">
230
+ <p className="text-sm font-semibold leading-snug text-foreground">{clampedTitle}</p>
231
+ <p className="text-[11px] text-muted-foreground">
232
+ {publisher}
233
+ {publisher === domain ? '' : ` • ${domain}`}
234
+ </p>
235
+ <p className="text-[10px] italic text-muted-foreground/70">
236
+ LinkedIn does not display the meta description on link previews.
237
+ </p>
238
+ </div>
239
+ </div>
240
+ )}
241
+ </div>
242
+ );
243
+ }