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,661 +1,661 @@
1
- 'use client';
2
-
3
- import Link from 'next/link';
4
- import { useRouter } from 'next/navigation';
5
- import { useActionState, useEffect, useState, useTransition } from 'react';
6
- import { useFormStatus } from 'react-dom';
7
- import { Button } from '@nextblock-cms/ui';
8
- import { Input } from '@nextblock-cms/ui';
9
- import { Textarea } from '@nextblock-cms/ui';
10
- import {
11
- AlertTriangle,
12
- CheckCircle2,
13
- ExternalLink,
14
- Copy,
15
- Eye,
16
- EyeOff,
17
- Inbox,
18
- Link2Off,
19
- Loader2,
20
- Mail,
21
- MailWarning,
22
- RefreshCw,
23
- Send,
24
- Star,
25
- } from 'lucide-react';
26
- import toast from 'react-hot-toast';
27
-
28
- import { saveStoreContactEmail } from '../inquiries/actions';
29
- import {
30
- createVisitorLink,
31
- markThreadRead,
32
- replyToThread,
33
- retryThreadDelivery,
34
- revokeThreadLink,
35
- setThreadStatus,
36
- } from './actions';
37
- import { replyToInteraction, updateInteractionStatus } from '../../actions/interactions';
38
- import type { InboxItem, InboxPage } from './loadInbox';
39
- import type { ThreadDetail } from './loadInbox';
40
-
41
- const SOURCE_TABS: Array<{ key: string; label: string }> = [
42
- { key: '', label: 'All' },
43
- { key: 'product_inquiry', label: 'Product enquiries' },
44
- { key: 'contact_form', label: 'Contact forms' },
45
- { key: 'review', label: 'Reviews' },
46
- { key: 'comment', label: 'Comments' },
47
- ];
48
-
49
- const SOURCE_LABELS: Record<string, string> = {
50
- sandbox: 'the sandbox override',
51
- store_contact: 'the address set below',
52
- invoice: 'your invoice email',
53
- privacy: 'your support email',
54
- first_admin: "the first admin's login email",
55
- none: 'nowhere — no address could be resolved',
56
- };
57
-
58
- interface MessagesClientProps {
59
- inbox: InboxPage;
60
- openThread: ThreadDetail | null;
61
- isAdmin: boolean;
62
- activeSource: string;
63
- showHandled: boolean;
64
- storeContactEmail: string;
65
- resolvedFallback: { email: string | null; source: string };
66
- smtpConfigured: boolean;
67
- }
68
-
69
- /** Keeps the source filter and the handled toggle independent in the URL. */
70
- function buildHref({ source, handled }: { source: string; handled: boolean }): string {
71
- const query = new URLSearchParams();
72
- if (source) query.set('source', source);
73
- if (handled) query.set('handled', '1');
74
- const search = query.toString();
75
- return search ? `/cms/messages?${search}` : '/cms/messages';
76
- }
77
-
78
- function SaveButton({ label = 'Save' }: { label?: string }) {
79
- const { pending } = useFormStatus();
80
- return (
81
- <Button type="submit" disabled={pending}>
82
- {pending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
83
- {label}
84
- </Button>
85
- );
86
- }
87
-
88
- export default function MessagesClient({
89
- inbox,
90
- openThread,
91
- isAdmin,
92
- activeSource,
93
- showHandled,
94
- storeContactEmail,
95
- resolvedFallback,
96
- smtpConfigured,
97
- }: MessagesClientProps) {
98
- const router = useRouter();
99
- const [settingsState, settingsAction] = useActionState(saveStoreContactEmail, {
100
- success: false,
101
- message: '',
102
- });
103
- const [busyId, setBusyId] = useState<string | null>(null);
104
- const [replyBody, setReplyBody] = useState('');
105
- const [, startTransition] = useTransition();
106
-
107
- // Switching conversations must not carry the draft across. Without this, a half-typed
108
- // reply to one visitor stays in the box when the admin opens another thread — and the
109
- // next Send delivers it to the wrong person.
110
- useEffect(() => {
111
- setReplyBody('');
112
- }, [openThread?.id]);
113
-
114
- const run = (id: string, work: () => Promise<{ success: boolean; message: string }>) => {
115
- setBusyId(id);
116
- startTransition(async () => {
117
- const result = await work();
118
- setBusyId(null);
119
- if (result.success) {
120
- if (result.message) toast.success(result.message);
121
- router.refresh();
122
- } else {
123
- toast.error(result.message || 'Something went wrong.');
124
- }
125
- });
126
- };
127
-
128
- const openThreadPane = (item: InboxItem) => {
129
- const query = new URLSearchParams();
130
- if (activeSource) query.set('source', activeSource);
131
- if (showHandled) query.set('handled', '1');
132
- query.set('thread', item.id);
133
- router.push(`/cms/messages?${query.toString()}`);
134
- if (item.unread) void markThreadRead(item.id);
135
- };
136
-
137
- return (
138
- <div className="space-y-6">
139
- {/* Source tabs */}
140
- <div className="flex flex-wrap items-center gap-2">
141
- {SOURCE_TABS.map((tab) => {
142
- const count =
143
- tab.key && tab.key in inbox.counts
144
- ? inbox.counts[tab.key as keyof typeof inbox.counts]
145
- : 0;
146
- const isActive = activeSource === tab.key;
147
- return (
148
- <Link
149
- key={tab.key || 'all'}
150
- href={buildHref({ source: tab.key, handled: showHandled })}
151
- className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors ${
152
- isActive
153
- ? 'border-primary bg-primary/10 text-primary'
154
- : 'border-border text-muted-foreground hover:text-foreground'
155
- }`}
156
- >
157
- {tab.label}
158
- {count > 0 && (
159
- <span className="rounded-full bg-amber-500/15 px-1.5 text-[10px] font-bold text-amber-700 dark:text-amber-400">
160
- {count}
161
- </span>
162
- )}
163
- </Link>
164
- );
165
- })}
166
-
167
- {/* Handled items are hidden by default: the inbox is a to-do list, and a closed
168
- conversation has nothing left to do in it. */}
169
- <Link
170
- href={buildHref({ source: activeSource, handled: !showHandled })}
171
- className={`ml-auto inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors ${
172
- showHandled
173
- ? 'border-primary bg-primary/10 text-primary'
174
- : 'border-border text-muted-foreground hover:text-foreground'
175
- }`}
176
- >
177
- {showHandled ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
178
- {showHandled ? 'Hiding nothing' : 'Show handled'}
179
- {!showHandled && inbox.handledCount > 0 && (
180
- <span className="rounded-full bg-muted px-1.5 text-[10px] font-bold text-muted-foreground">
181
- {inbox.handledCount}
182
- </span>
183
- )}
184
- </Link>
185
- </div>
186
-
187
- {inbox.privateHidden && (
188
- <p className="rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground">
189
- Product enquiries and contact-form messages contain personal information and are
190
- visible to admins only. You are seeing reviews and comments.
191
- </p>
192
- )}
193
-
194
- {isAdmin && !smtpConfigured && (
195
- <div className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
196
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
197
- <span>
198
- Email sending isn&rsquo;t configured, so nobody is notified when a message
199
- arrives and replies can&rsquo;t reach the sender — but everything is still
200
- recorded here. Set up SMTP in CMS Settings → Configuration → Email.
201
- </span>
202
- </div>
203
- )}
204
-
205
- <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)]">
206
- {/* List */}
207
- <section className="space-y-3">
208
- {inbox.items.length === 0 ? (
209
- <div className="rounded-lg border border-dashed border-border p-10 text-center">
210
- <Inbox className="mx-auto h-8 w-8 text-muted-foreground" />
211
- <h3 className="mt-3 text-sm font-semibold">Nothing here yet</h3>
212
- <p className="mt-1 text-xs text-muted-foreground">
213
- {inbox.handledCount > 0 && !showHandled
214
- ? `Nothing needs your attention. ${inbox.handledCount} handled ${
215
- inbox.handledCount === 1 ? 'item is' : 'items are'
216
- } hidden.`
217
- : activeSource
218
- ? 'No messages in this category.'
219
- : 'Messages from your site will appear here.'}
220
- </p>
221
- </div>
222
- ) : (
223
- inbox.items.map((item) => (
224
- <article
225
- key={`${item.kind}-${item.id}`}
226
- className={`rounded-lg border bg-card p-4 ${
227
- openThread?.id === item.id ? 'border-primary' : 'border-border'
228
- } ${item.unread ? '' : 'opacity-80'}`}
229
- >
230
- <div className="flex flex-wrap items-start justify-between gap-3">
231
- <div className="min-w-0">
232
- <p className="flex items-center gap-2 text-sm font-semibold">
233
- {item.unread && (
234
- <span className="h-2 w-2 shrink-0 rounded-full bg-amber-500" aria-label="Unread" />
235
- )}
236
- {item.subjectLabel}
237
- </p>
238
- <p className="text-xs text-muted-foreground">
239
- {item.senderName || 'Anonymous'}
240
- {item.senderEmail ? ` · ${item.senderEmail}` : ''} ·{' '}
241
- <span suppressHydrationWarning>
242
- {new Date(item.lastActivityAt).toLocaleString()}
243
- </span>
244
- </p>
245
- </div>
246
- <div className="flex shrink-0 items-center gap-2">
247
- {item.rating !== null && (
248
- <span className="flex items-center gap-0.5 text-xs text-amber-500">
249
- <Star className="h-3.5 w-3.5 fill-amber-500" />
250
- {item.rating}
251
- </span>
252
- )}
253
- {item.emailDelivered === false && (
254
- <span
255
- className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400"
256
- title="No notification was sent — this page is the only record"
257
- >
258
- <MailWarning className="h-3.5 w-3.5" />
259
- Not emailed
260
- </span>
261
- )}
262
- {item.emailDelivered === true && (
263
- <span className="flex items-center gap-1 text-xs text-muted-foreground">
264
- <Mail className="h-3.5 w-3.5" />
265
- Emailed
266
- </span>
267
- )}
268
- {item.targetHref && (
269
- <a
270
- href={item.targetHref}
271
- target="_blank"
272
- rel="noreferrer"
273
- className="text-muted-foreground hover:text-foreground"
274
- title="View on the site"
275
- >
276
- <ExternalLink className="h-4 w-4" />
277
- </a>
278
- )}
279
- </div>
280
- </div>
281
-
282
- <p className="mt-3 whitespace-pre-wrap text-sm">{item.preview}</p>
283
-
284
- <div className="mt-3 flex flex-wrap items-center gap-2">
285
- {item.kind === 'thread' ? (
286
- <>
287
- <Button size="sm" variant="outline" onClick={() => openThreadPane(item)}>
288
- {openThread?.id === item.id ? 'Open' : 'Read & reply'}
289
- </Button>
290
- <Button
291
- size="sm"
292
- variant="ghost"
293
- disabled={busyId === item.id}
294
- onClick={() =>
295
- run(item.id, () =>
296
- setThreadStatus(item.id, item.status === 'closed' ? 'open' : 'closed')
297
- )
298
- }
299
- >
300
- {busyId === item.id ? (
301
- <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
302
- ) : (
303
- <CheckCircle2 className="mr-2 h-3.5 w-3.5" />
304
- )}
305
- {item.status === 'closed' ? 'Reopen' : 'Mark handled'}
306
- </Button>
307
- </>
308
- ) : (
309
- isAdmin && (
310
- <>
311
- {item.status !== 'approved' && (
312
- <Button
313
- size="sm"
314
- variant="outline"
315
- disabled={busyId === item.id}
316
- onClick={() =>
317
- run(item.id, async () => {
318
- const result = await updateInteractionStatus(item.id, 'approved');
319
- return result.error
320
- ? { success: false, message: result.error }
321
- : { success: true, message: 'Published.' };
322
- })
323
- }
324
- >
325
- Approve
326
- </Button>
327
- )}
328
- {item.status !== 'denied' && (
329
- <Button
330
- size="sm"
331
- variant="ghost"
332
- disabled={busyId === item.id}
333
- onClick={() =>
334
- run(item.id, async () => {
335
- const result = await updateInteractionStatus(item.id, 'denied');
336
- return result.error
337
- ? { success: false, message: result.error }
338
- : { success: true, message: 'Hidden from the site.' };
339
- })
340
- }
341
- >
342
- Deny
343
- </Button>
344
- )}
345
- <PublicReplyBox
346
- parentId={item.id}
347
- busy={busyId === item.id}
348
- onReply={(body) =>
349
- run(item.id, async () => {
350
- const result = await replyToInteraction(item.id, body);
351
- return result.error
352
- ? { success: false, message: result.error }
353
- : { success: true, message: 'Reply published.' };
354
- })
355
- }
356
- />
357
- </>
358
- )
359
- )}
360
- </div>
361
- </article>
362
- ))
363
- )}
364
-
365
- {inbox.hasMore && (
366
- <p className="text-center text-xs text-muted-foreground">
367
- Showing the most recent {inbox.items.length}. Older messages are still stored.
368
- </p>
369
- )}
370
- </section>
371
-
372
- {/* Detail pane */}
373
- <section className="space-y-4">
374
- {openThread ? (
375
- <div className="rounded-lg border border-border bg-card p-4">
376
- <header className="mb-3">
377
- <h2 className="text-base font-semibold">{openThread.subjectLabel}</h2>
378
- <p className="text-xs text-muted-foreground">
379
- {openThread.senderName || 'Anonymous'}
380
- {openThread.senderEmail ? ` · ${openThread.senderEmail}` : ' · no email address'}
381
- </p>
382
- </header>
383
-
384
- {openThread.fields.length > 0 && (
385
- <dl className="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-md border border-dashed p-3 text-xs">
386
- {openThread.fields.map((field) => (
387
- <div key={field.label} className="contents">
388
- <dt className="font-semibold text-muted-foreground">{field.label}</dt>
389
- <dd className="whitespace-pre-wrap break-words">{field.value}</dd>
390
- </div>
391
- ))}
392
- </dl>
393
- )}
394
-
395
- <ol className="space-y-3">
396
- {openThread.messages.map((message) => (
397
- <li
398
- key={message.id}
399
- className={`rounded-lg border p-3 ${
400
- message.direction === 'outbound'
401
- ? 'border-primary/30 bg-primary/5'
402
- : 'border-border bg-background'
403
- }`}
404
- >
405
- <div className="mb-1 flex items-baseline justify-between gap-2">
406
- <span className="text-xs font-semibold">
407
- {message.direction === 'outbound'
408
- ? message.author_name || 'You'
409
- : openThread.senderName || 'Visitor'}
410
- </span>
411
- <time className="text-[10px] text-muted-foreground" suppressHydrationWarning>
412
- {new Date(message.created_at).toLocaleString()}
413
- </time>
414
- </div>
415
- <p className="whitespace-pre-wrap text-sm">{message.body}</p>
416
- {/*
417
- An undelivered row means one of two very different things, and
418
- conflating them is what made a working reply look broken: with an
419
- error recorded the send genuinely failed, without one it simply has
420
- not been attempted yet (the visitor and form paths notify after the
421
- response). Only the first is a failure.
422
- */}
423
- {message.direction === 'outbound' &&
424
- !message.email_delivered &&
425
- !message.email_error && (
426
- <p className="mt-2 text-[11px] text-muted-foreground">Sending…</p>
427
- )}
428
-
429
- {message.direction === 'outbound' && !message.email_delivered && message.email_error && (
430
- <div className="mt-2 space-y-1">
431
- <p className="text-[11px] text-amber-600 dark:text-amber-400">
432
- Not delivered: {message.email_error}
433
- </p>
434
- {openThread.senderEmail && (
435
- <Button
436
- size="sm"
437
- variant="outline"
438
- className="h-7 text-[11px]"
439
- disabled={busyId === message.id}
440
- onClick={() => run(message.id, () => retryThreadDelivery(message.id))}
441
- >
442
- {busyId === message.id ? (
443
- <Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
444
- ) : (
445
- <RefreshCw className="mr-1.5 h-3 w-3" />
446
- )}
447
- Try sending again
448
- </Button>
449
- )}
450
- </div>
451
- )}
452
- </li>
453
- ))}
454
- </ol>
455
-
456
- {!openThread.senderEmail && (
457
- <p className="mt-3 rounded-md border border-dashed p-2 text-xs text-muted-foreground">
458
- This sender left no email address, so a reply will be recorded here but
459
- cannot be delivered to them.
460
- </p>
461
- )}
462
-
463
- <div className="mt-4 space-y-2">
464
- <Textarea
465
- value={replyBody}
466
- onChange={(event) => setReplyBody(event.target.value)}
467
- placeholder="Write your reply…"
468
- className="min-h-[110px]"
469
- maxLength={5000}
470
- />
471
- <div className="flex flex-wrap items-center justify-between gap-2">
472
- <p className="text-[11px] text-muted-foreground">
473
- They get an email with a private link to read and reply on your site.
474
- </p>
475
- <div className="flex items-center gap-2">
476
- <Button
477
- size="sm"
478
- variant="ghost"
479
- disabled={busyId === openThread.id}
480
- title="Copy a link to this conversation, to send however you like"
481
- onClick={() =>
482
- run(openThread.id, async () => {
483
- const result = await createVisitorLink(openThread.id);
484
- if (result.success && result.url) {
485
- try {
486
- await navigator.clipboard.writeText(result.url);
487
- } catch {
488
- // Clipboard blocked (insecure origin, denied permission):
489
- // show the link so it can still be copied by hand.
490
- window.prompt('Copy this link for the visitor:', result.url);
491
- }
492
- }
493
- return result;
494
- })
495
- }
496
- >
497
- <Copy className="mr-2 h-3.5 w-3.5" />
498
- Copy visitor link
499
- </Button>
500
- {openThread.hasLiveLink && (
501
- <Button
502
- size="sm"
503
- variant="ghost"
504
- disabled={busyId === openThread.id}
505
- onClick={() => run(openThread.id, () => revokeThreadLink(openThread.id))}
506
- title="Invalidate the link you already sent"
507
- >
508
- <Link2Off className="mr-2 h-3.5 w-3.5" />
509
- Revoke link
510
- </Button>
511
- )}
512
- <Button
513
- size="sm"
514
- disabled={busyId === openThread.id || !replyBody.trim()}
515
- onClick={() =>
516
- run(openThread.id, async () => {
517
- const result = await replyToThread(openThread.id, replyBody);
518
- if (result.success) setReplyBody('');
519
- return result;
520
- })
521
- }
522
- >
523
- {busyId === openThread.id ? (
524
- <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
525
- ) : (
526
- <Send className="mr-2 h-3.5 w-3.5" />
527
- )}
528
- Send reply
529
- </Button>
530
- </div>
531
- </div>
532
- </div>
533
- </div>
534
- ) : (
535
- isAdmin && (
536
- <div className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
537
- Choose a conversation to read the full history and reply.
538
- </div>
539
- )
540
- )}
541
-
542
- {/* Where enquiries go */}
543
- {isAdmin && (
544
- <div className="rounded-lg border border-border bg-card p-4">
545
- <h2 className="text-sm font-semibold">Where messages are sent</h2>
546
- <p className="mt-1 text-xs text-muted-foreground">
547
- <strong>Every form on your site sends here by default</strong> — product
548
- enquiries and contact forms alike. Leave it blank and notifications go to
549
- the first admin account. An individual form can still be pointed somewhere
550
- else in its block settings.
551
- </p>
552
- <p className="mt-1 text-xs text-muted-foreground">
553
- Visitors never see this address: forms post to your server, which looks up
554
- where to deliver. Messages are recorded here either way.
555
- </p>
556
-
557
- <form action={settingsAction} className="mt-3 flex flex-wrap items-end gap-3">
558
- <div className="min-w-[220px] flex-1 space-y-1.5">
559
- <label
560
- htmlFor="contact_email"
561
- className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground"
562
- >
563
- Contact email
564
- </label>
565
- <Input
566
- id="contact_email"
567
- name="contact_email"
568
- type="email"
569
- defaultValue={storeContactEmail}
570
- placeholder="sales@yourcompany.com"
571
- />
572
- </div>
573
- <SaveButton />
574
- </form>
575
-
576
- {settingsState.message && (
577
- <p
578
- className={`mt-2 text-xs ${
579
- settingsState.success ? 'text-emerald-600' : 'text-destructive'
580
- }`}
581
- >
582
- {settingsState.message}
583
- </p>
584
- )}
585
-
586
- {!storeContactEmail && (
587
- <p className="mt-2 text-xs text-muted-foreground">
588
- Nothing set, so messages currently go to{' '}
589
- <strong>
590
- {SOURCE_LABELS[resolvedFallback.source] ?? resolvedFallback.source}
591
- </strong>
592
- {resolvedFallback.source === 'none'
593
- ? '. Set an address so notifications reach someone.'
594
- : '. That works — set an address above to send them somewhere else.'}
595
- </p>
596
- )}
597
- </div>
598
- )}
599
- </section>
600
- </div>
601
- </div>
602
- );
603
- }
604
-
605
- /**
606
- * A staff answer published under a review or comment. Kept visually distinct from the
607
- * private reply box, and labelled, because this one appears on the public site.
608
- */
609
- function PublicReplyBox({
610
- parentId,
611
- busy,
612
- onReply,
613
- }: {
614
- parentId: string;
615
- busy: boolean;
616
- onReply: (body: string) => void;
617
- }) {
618
- const [open, setOpen] = useState(false);
619
- const [body, setBody] = useState('');
620
-
621
- if (!open) {
622
- return (
623
- <Button size="sm" variant="ghost" onClick={() => setOpen(true)}>
624
- Reply publicly
625
- </Button>
626
- );
627
- }
628
-
629
- return (
630
- <div className="mt-2 w-full space-y-2 rounded-md border border-dashed p-3">
631
- <p className="text-[11px] font-semibold text-amber-600 dark:text-amber-400">
632
- This reply will be published on your site, under the original.
633
- </p>
634
- <Textarea
635
- id={`reply-${parentId}`}
636
- value={body}
637
- onChange={(event) => setBody(event.target.value)}
638
- placeholder="Write a public reply…"
639
- className="min-h-[80px]"
640
- maxLength={5000}
641
- />
642
- <div className="flex justify-end gap-2">
643
- <Button size="sm" variant="ghost" onClick={() => setOpen(false)}>
644
- Cancel
645
- </Button>
646
- <Button
647
- size="sm"
648
- disabled={busy || !body.trim()}
649
- onClick={() => {
650
- onReply(body);
651
- setBody('');
652
- setOpen(false);
653
- }}
654
- >
655
- {busy && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
656
- Publish reply
657
- </Button>
658
- </div>
659
- </div>
660
- );
661
- }
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { useRouter } from 'next/navigation';
5
+ import { useActionState, useEffect, useState, useTransition } from 'react';
6
+ import { useFormStatus } from 'react-dom';
7
+ import { Button } from '@nextblock-cms/ui';
8
+ import { Input } from '@nextblock-cms/ui';
9
+ import { Textarea } from '@nextblock-cms/ui';
10
+ import {
11
+ AlertTriangle,
12
+ CheckCircle2,
13
+ ExternalLink,
14
+ Copy,
15
+ Eye,
16
+ EyeOff,
17
+ Inbox,
18
+ Link2Off,
19
+ Loader2,
20
+ Mail,
21
+ MailWarning,
22
+ RefreshCw,
23
+ Send,
24
+ Star,
25
+ } from 'lucide-react';
26
+ import toast from 'react-hot-toast';
27
+
28
+ import { saveStoreContactEmail } from '../inquiries/actions';
29
+ import {
30
+ createVisitorLink,
31
+ markThreadRead,
32
+ replyToThread,
33
+ retryThreadDelivery,
34
+ revokeThreadLink,
35
+ setThreadStatus,
36
+ } from './actions';
37
+ import { replyToInteraction, updateInteractionStatus } from '../../actions/interactions';
38
+ import type { InboxItem, InboxPage } from './loadInbox';
39
+ import type { ThreadDetail } from './loadInbox';
40
+
41
+ const SOURCE_TABS: Array<{ key: string; label: string }> = [
42
+ { key: '', label: 'All' },
43
+ { key: 'product_inquiry', label: 'Product enquiries' },
44
+ { key: 'contact_form', label: 'Contact forms' },
45
+ { key: 'review', label: 'Reviews' },
46
+ { key: 'comment', label: 'Comments' },
47
+ ];
48
+
49
+ const SOURCE_LABELS: Record<string, string> = {
50
+ sandbox: 'the sandbox override',
51
+ store_contact: 'the address set below',
52
+ invoice: 'your invoice email',
53
+ privacy: 'your support email',
54
+ first_admin: "the first admin's login email",
55
+ none: 'nowhere — no address could be resolved',
56
+ };
57
+
58
+ interface MessagesClientProps {
59
+ inbox: InboxPage;
60
+ openThread: ThreadDetail | null;
61
+ isAdmin: boolean;
62
+ activeSource: string;
63
+ showHandled: boolean;
64
+ storeContactEmail: string;
65
+ resolvedFallback: { email: string | null; source: string };
66
+ smtpConfigured: boolean;
67
+ }
68
+
69
+ /** Keeps the source filter and the handled toggle independent in the URL. */
70
+ function buildHref({ source, handled }: { source: string; handled: boolean }): string {
71
+ const query = new URLSearchParams();
72
+ if (source) query.set('source', source);
73
+ if (handled) query.set('handled', '1');
74
+ const search = query.toString();
75
+ return search ? `/cms/messages?${search}` : '/cms/messages';
76
+ }
77
+
78
+ function SaveButton({ label = 'Save' }: { label?: string }) {
79
+ const { pending } = useFormStatus();
80
+ return (
81
+ <Button type="submit" disabled={pending}>
82
+ {pending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
83
+ {label}
84
+ </Button>
85
+ );
86
+ }
87
+
88
+ export default function MessagesClient({
89
+ inbox,
90
+ openThread,
91
+ isAdmin,
92
+ activeSource,
93
+ showHandled,
94
+ storeContactEmail,
95
+ resolvedFallback,
96
+ smtpConfigured,
97
+ }: MessagesClientProps) {
98
+ const router = useRouter();
99
+ const [settingsState, settingsAction] = useActionState(saveStoreContactEmail, {
100
+ success: false,
101
+ message: '',
102
+ });
103
+ const [busyId, setBusyId] = useState<string | null>(null);
104
+ const [replyBody, setReplyBody] = useState('');
105
+ const [, startTransition] = useTransition();
106
+
107
+ // Switching conversations must not carry the draft across. Without this, a half-typed
108
+ // reply to one visitor stays in the box when the admin opens another thread — and the
109
+ // next Send delivers it to the wrong person.
110
+ useEffect(() => {
111
+ setReplyBody('');
112
+ }, [openThread?.id]);
113
+
114
+ const run = (id: string, work: () => Promise<{ success: boolean; message: string }>) => {
115
+ setBusyId(id);
116
+ startTransition(async () => {
117
+ const result = await work();
118
+ setBusyId(null);
119
+ if (result.success) {
120
+ if (result.message) toast.success(result.message);
121
+ router.refresh();
122
+ } else {
123
+ toast.error(result.message || 'Something went wrong.');
124
+ }
125
+ });
126
+ };
127
+
128
+ const openThreadPane = (item: InboxItem) => {
129
+ const query = new URLSearchParams();
130
+ if (activeSource) query.set('source', activeSource);
131
+ if (showHandled) query.set('handled', '1');
132
+ query.set('thread', item.id);
133
+ router.push(`/cms/messages?${query.toString()}`);
134
+ if (item.unread) void markThreadRead(item.id);
135
+ };
136
+
137
+ return (
138
+ <div className="space-y-6">
139
+ {/* Source tabs */}
140
+ <div className="flex flex-wrap items-center gap-2">
141
+ {SOURCE_TABS.map((tab) => {
142
+ const count =
143
+ tab.key && tab.key in inbox.counts
144
+ ? inbox.counts[tab.key as keyof typeof inbox.counts]
145
+ : 0;
146
+ const isActive = activeSource === tab.key;
147
+ return (
148
+ <Link
149
+ key={tab.key || 'all'}
150
+ href={buildHref({ source: tab.key, handled: showHandled })}
151
+ className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors ${
152
+ isActive
153
+ ? 'border-primary bg-primary/10 text-primary'
154
+ : 'border-border text-muted-foreground hover:text-foreground'
155
+ }`}
156
+ >
157
+ {tab.label}
158
+ {count > 0 && (
159
+ <span className="rounded-full bg-amber-500/15 px-1.5 text-[10px] font-bold text-amber-700 dark:text-amber-400">
160
+ {count}
161
+ </span>
162
+ )}
163
+ </Link>
164
+ );
165
+ })}
166
+
167
+ {/* Handled items are hidden by default: the inbox is a to-do list, and a closed
168
+ conversation has nothing left to do in it. */}
169
+ <Link
170
+ href={buildHref({ source: activeSource, handled: !showHandled })}
171
+ className={`ml-auto inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors ${
172
+ showHandled
173
+ ? 'border-primary bg-primary/10 text-primary'
174
+ : 'border-border text-muted-foreground hover:text-foreground'
175
+ }`}
176
+ >
177
+ {showHandled ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
178
+ {showHandled ? 'Hiding nothing' : 'Show handled'}
179
+ {!showHandled && inbox.handledCount > 0 && (
180
+ <span className="rounded-full bg-muted px-1.5 text-[10px] font-bold text-muted-foreground">
181
+ {inbox.handledCount}
182
+ </span>
183
+ )}
184
+ </Link>
185
+ </div>
186
+
187
+ {inbox.privateHidden && (
188
+ <p className="rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground">
189
+ Product enquiries and contact-form messages contain personal information and are
190
+ visible to admins only. You are seeing reviews and comments.
191
+ </p>
192
+ )}
193
+
194
+ {isAdmin && !smtpConfigured && (
195
+ <div className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
196
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
197
+ <span>
198
+ Email sending isn&rsquo;t configured, so nobody is notified when a message
199
+ arrives and replies can&rsquo;t reach the sender — but everything is still
200
+ recorded here. Set up SMTP in CMS Settings → Configuration → Email.
201
+ </span>
202
+ </div>
203
+ )}
204
+
205
+ <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)]">
206
+ {/* List */}
207
+ <section className="space-y-3">
208
+ {inbox.items.length === 0 ? (
209
+ <div className="rounded-lg border border-dashed border-border p-10 text-center">
210
+ <Inbox className="mx-auto h-8 w-8 text-muted-foreground" />
211
+ <h3 className="mt-3 text-sm font-semibold">Nothing here yet</h3>
212
+ <p className="mt-1 text-xs text-muted-foreground">
213
+ {inbox.handledCount > 0 && !showHandled
214
+ ? `Nothing needs your attention. ${inbox.handledCount} handled ${
215
+ inbox.handledCount === 1 ? 'item is' : 'items are'
216
+ } hidden.`
217
+ : activeSource
218
+ ? 'No messages in this category.'
219
+ : 'Messages from your site will appear here.'}
220
+ </p>
221
+ </div>
222
+ ) : (
223
+ inbox.items.map((item) => (
224
+ <article
225
+ key={`${item.kind}-${item.id}`}
226
+ className={`rounded-lg border bg-card p-4 ${
227
+ openThread?.id === item.id ? 'border-primary' : 'border-border'
228
+ } ${item.unread ? '' : 'opacity-80'}`}
229
+ >
230
+ <div className="flex flex-wrap items-start justify-between gap-3">
231
+ <div className="min-w-0">
232
+ <p className="flex items-center gap-2 text-sm font-semibold">
233
+ {item.unread && (
234
+ <span className="h-2 w-2 shrink-0 rounded-full bg-amber-500" aria-label="Unread" />
235
+ )}
236
+ {item.subjectLabel}
237
+ </p>
238
+ <p className="text-xs text-muted-foreground">
239
+ {item.senderName || 'Anonymous'}
240
+ {item.senderEmail ? ` · ${item.senderEmail}` : ''} ·{' '}
241
+ <span suppressHydrationWarning>
242
+ {new Date(item.lastActivityAt).toLocaleString()}
243
+ </span>
244
+ </p>
245
+ </div>
246
+ <div className="flex shrink-0 items-center gap-2">
247
+ {item.rating !== null && (
248
+ <span className="flex items-center gap-0.5 text-xs text-amber-500">
249
+ <Star className="h-3.5 w-3.5 fill-amber-500" />
250
+ {item.rating}
251
+ </span>
252
+ )}
253
+ {item.emailDelivered === false && (
254
+ <span
255
+ className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400"
256
+ title="No notification was sent — this page is the only record"
257
+ >
258
+ <MailWarning className="h-3.5 w-3.5" />
259
+ Not emailed
260
+ </span>
261
+ )}
262
+ {item.emailDelivered === true && (
263
+ <span className="flex items-center gap-1 text-xs text-muted-foreground">
264
+ <Mail className="h-3.5 w-3.5" />
265
+ Emailed
266
+ </span>
267
+ )}
268
+ {item.targetHref && (
269
+ <a
270
+ href={item.targetHref}
271
+ target="_blank"
272
+ rel="noreferrer"
273
+ className="text-muted-foreground hover:text-foreground"
274
+ title="View on the site"
275
+ >
276
+ <ExternalLink className="h-4 w-4" />
277
+ </a>
278
+ )}
279
+ </div>
280
+ </div>
281
+
282
+ <p className="mt-3 whitespace-pre-wrap text-sm">{item.preview}</p>
283
+
284
+ <div className="mt-3 flex flex-wrap items-center gap-2">
285
+ {item.kind === 'thread' ? (
286
+ <>
287
+ <Button size="sm" variant="outline" onClick={() => openThreadPane(item)}>
288
+ {openThread?.id === item.id ? 'Open' : 'Read & reply'}
289
+ </Button>
290
+ <Button
291
+ size="sm"
292
+ variant="ghost"
293
+ disabled={busyId === item.id}
294
+ onClick={() =>
295
+ run(item.id, () =>
296
+ setThreadStatus(item.id, item.status === 'closed' ? 'open' : 'closed')
297
+ )
298
+ }
299
+ >
300
+ {busyId === item.id ? (
301
+ <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
302
+ ) : (
303
+ <CheckCircle2 className="mr-2 h-3.5 w-3.5" />
304
+ )}
305
+ {item.status === 'closed' ? 'Reopen' : 'Mark handled'}
306
+ </Button>
307
+ </>
308
+ ) : (
309
+ isAdmin && (
310
+ <>
311
+ {item.status !== 'approved' && (
312
+ <Button
313
+ size="sm"
314
+ variant="outline"
315
+ disabled={busyId === item.id}
316
+ onClick={() =>
317
+ run(item.id, async () => {
318
+ const result = await updateInteractionStatus(item.id, 'approved');
319
+ return result.error
320
+ ? { success: false, message: result.error }
321
+ : { success: true, message: 'Published.' };
322
+ })
323
+ }
324
+ >
325
+ Approve
326
+ </Button>
327
+ )}
328
+ {item.status !== 'denied' && (
329
+ <Button
330
+ size="sm"
331
+ variant="ghost"
332
+ disabled={busyId === item.id}
333
+ onClick={() =>
334
+ run(item.id, async () => {
335
+ const result = await updateInteractionStatus(item.id, 'denied');
336
+ return result.error
337
+ ? { success: false, message: result.error }
338
+ : { success: true, message: 'Hidden from the site.' };
339
+ })
340
+ }
341
+ >
342
+ Deny
343
+ </Button>
344
+ )}
345
+ <PublicReplyBox
346
+ parentId={item.id}
347
+ busy={busyId === item.id}
348
+ onReply={(body) =>
349
+ run(item.id, async () => {
350
+ const result = await replyToInteraction(item.id, body);
351
+ return result.error
352
+ ? { success: false, message: result.error }
353
+ : { success: true, message: 'Reply published.' };
354
+ })
355
+ }
356
+ />
357
+ </>
358
+ )
359
+ )}
360
+ </div>
361
+ </article>
362
+ ))
363
+ )}
364
+
365
+ {inbox.hasMore && (
366
+ <p className="text-center text-xs text-muted-foreground">
367
+ Showing the most recent {inbox.items.length}. Older messages are still stored.
368
+ </p>
369
+ )}
370
+ </section>
371
+
372
+ {/* Detail pane */}
373
+ <section className="space-y-4">
374
+ {openThread ? (
375
+ <div className="rounded-lg border border-border bg-card p-4">
376
+ <header className="mb-3">
377
+ <h2 className="text-base font-semibold">{openThread.subjectLabel}</h2>
378
+ <p className="text-xs text-muted-foreground">
379
+ {openThread.senderName || 'Anonymous'}
380
+ {openThread.senderEmail ? ` · ${openThread.senderEmail}` : ' · no email address'}
381
+ </p>
382
+ </header>
383
+
384
+ {openThread.fields.length > 0 && (
385
+ <dl className="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-md border border-dashed p-3 text-xs">
386
+ {openThread.fields.map((field) => (
387
+ <div key={field.label} className="contents">
388
+ <dt className="font-semibold text-muted-foreground">{field.label}</dt>
389
+ <dd className="whitespace-pre-wrap break-words">{field.value}</dd>
390
+ </div>
391
+ ))}
392
+ </dl>
393
+ )}
394
+
395
+ <ol className="space-y-3">
396
+ {openThread.messages.map((message) => (
397
+ <li
398
+ key={message.id}
399
+ className={`rounded-lg border p-3 ${
400
+ message.direction === 'outbound'
401
+ ? 'border-primary/30 bg-primary/5'
402
+ : 'border-border bg-background'
403
+ }`}
404
+ >
405
+ <div className="mb-1 flex items-baseline justify-between gap-2">
406
+ <span className="text-xs font-semibold">
407
+ {message.direction === 'outbound'
408
+ ? message.author_name || 'You'
409
+ : openThread.senderName || 'Visitor'}
410
+ </span>
411
+ <time className="text-[10px] text-muted-foreground" suppressHydrationWarning>
412
+ {new Date(message.created_at).toLocaleString()}
413
+ </time>
414
+ </div>
415
+ <p className="whitespace-pre-wrap text-sm">{message.body}</p>
416
+ {/*
417
+ An undelivered row means one of two very different things, and
418
+ conflating them is what made a working reply look broken: with an
419
+ error recorded the send genuinely failed, without one it simply has
420
+ not been attempted yet (the visitor and form paths notify after the
421
+ response). Only the first is a failure.
422
+ */}
423
+ {message.direction === 'outbound' &&
424
+ !message.email_delivered &&
425
+ !message.email_error && (
426
+ <p className="mt-2 text-[11px] text-muted-foreground">Sending…</p>
427
+ )}
428
+
429
+ {message.direction === 'outbound' && !message.email_delivered && message.email_error && (
430
+ <div className="mt-2 space-y-1">
431
+ <p className="text-[11px] text-amber-600 dark:text-amber-400">
432
+ Not delivered: {message.email_error}
433
+ </p>
434
+ {openThread.senderEmail && (
435
+ <Button
436
+ size="sm"
437
+ variant="outline"
438
+ className="h-7 text-[11px]"
439
+ disabled={busyId === message.id}
440
+ onClick={() => run(message.id, () => retryThreadDelivery(message.id))}
441
+ >
442
+ {busyId === message.id ? (
443
+ <Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
444
+ ) : (
445
+ <RefreshCw className="mr-1.5 h-3 w-3" />
446
+ )}
447
+ Try sending again
448
+ </Button>
449
+ )}
450
+ </div>
451
+ )}
452
+ </li>
453
+ ))}
454
+ </ol>
455
+
456
+ {!openThread.senderEmail && (
457
+ <p className="mt-3 rounded-md border border-dashed p-2 text-xs text-muted-foreground">
458
+ This sender left no email address, so a reply will be recorded here but
459
+ cannot be delivered to them.
460
+ </p>
461
+ )}
462
+
463
+ <div className="mt-4 space-y-2">
464
+ <Textarea
465
+ value={replyBody}
466
+ onChange={(event) => setReplyBody(event.target.value)}
467
+ placeholder="Write your reply…"
468
+ className="min-h-[110px]"
469
+ maxLength={5000}
470
+ />
471
+ <div className="flex flex-wrap items-center justify-between gap-2">
472
+ <p className="text-[11px] text-muted-foreground">
473
+ They get an email with a private link to read and reply on your site.
474
+ </p>
475
+ <div className="flex items-center gap-2">
476
+ <Button
477
+ size="sm"
478
+ variant="ghost"
479
+ disabled={busyId === openThread.id}
480
+ title="Copy a link to this conversation, to send however you like"
481
+ onClick={() =>
482
+ run(openThread.id, async () => {
483
+ const result = await createVisitorLink(openThread.id);
484
+ if (result.success && result.url) {
485
+ try {
486
+ await navigator.clipboard.writeText(result.url);
487
+ } catch {
488
+ // Clipboard blocked (insecure origin, denied permission):
489
+ // show the link so it can still be copied by hand.
490
+ window.prompt('Copy this link for the visitor:', result.url);
491
+ }
492
+ }
493
+ return result;
494
+ })
495
+ }
496
+ >
497
+ <Copy className="mr-2 h-3.5 w-3.5" />
498
+ Copy visitor link
499
+ </Button>
500
+ {openThread.hasLiveLink && (
501
+ <Button
502
+ size="sm"
503
+ variant="ghost"
504
+ disabled={busyId === openThread.id}
505
+ onClick={() => run(openThread.id, () => revokeThreadLink(openThread.id))}
506
+ title="Invalidate the link you already sent"
507
+ >
508
+ <Link2Off className="mr-2 h-3.5 w-3.5" />
509
+ Revoke link
510
+ </Button>
511
+ )}
512
+ <Button
513
+ size="sm"
514
+ disabled={busyId === openThread.id || !replyBody.trim()}
515
+ onClick={() =>
516
+ run(openThread.id, async () => {
517
+ const result = await replyToThread(openThread.id, replyBody);
518
+ if (result.success) setReplyBody('');
519
+ return result;
520
+ })
521
+ }
522
+ >
523
+ {busyId === openThread.id ? (
524
+ <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
525
+ ) : (
526
+ <Send className="mr-2 h-3.5 w-3.5" />
527
+ )}
528
+ Send reply
529
+ </Button>
530
+ </div>
531
+ </div>
532
+ </div>
533
+ </div>
534
+ ) : (
535
+ isAdmin && (
536
+ <div className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
537
+ Choose a conversation to read the full history and reply.
538
+ </div>
539
+ )
540
+ )}
541
+
542
+ {/* Where enquiries go */}
543
+ {isAdmin && (
544
+ <div className="rounded-lg border border-border bg-card p-4">
545
+ <h2 className="text-sm font-semibold">Where messages are sent</h2>
546
+ <p className="mt-1 text-xs text-muted-foreground">
547
+ <strong>Every form on your site sends here by default</strong> — product
548
+ enquiries and contact forms alike. Leave it blank and notifications go to
549
+ the first admin account. An individual form can still be pointed somewhere
550
+ else in its block settings.
551
+ </p>
552
+ <p className="mt-1 text-xs text-muted-foreground">
553
+ Visitors never see this address: forms post to your server, which looks up
554
+ where to deliver. Messages are recorded here either way.
555
+ </p>
556
+
557
+ <form action={settingsAction} className="mt-3 flex flex-wrap items-end gap-3">
558
+ <div className="min-w-[220px] flex-1 space-y-1.5">
559
+ <label
560
+ htmlFor="contact_email"
561
+ className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground"
562
+ >
563
+ Contact email
564
+ </label>
565
+ <Input
566
+ id="contact_email"
567
+ name="contact_email"
568
+ type="email"
569
+ defaultValue={storeContactEmail}
570
+ placeholder="sales@yourcompany.com"
571
+ />
572
+ </div>
573
+ <SaveButton />
574
+ </form>
575
+
576
+ {settingsState.message && (
577
+ <p
578
+ className={`mt-2 text-xs ${
579
+ settingsState.success ? 'text-emerald-600' : 'text-destructive'
580
+ }`}
581
+ >
582
+ {settingsState.message}
583
+ </p>
584
+ )}
585
+
586
+ {!storeContactEmail && (
587
+ <p className="mt-2 text-xs text-muted-foreground">
588
+ Nothing set, so messages currently go to{' '}
589
+ <strong>
590
+ {SOURCE_LABELS[resolvedFallback.source] ?? resolvedFallback.source}
591
+ </strong>
592
+ {resolvedFallback.source === 'none'
593
+ ? '. Set an address so notifications reach someone.'
594
+ : '. That works — set an address above to send them somewhere else.'}
595
+ </p>
596
+ )}
597
+ </div>
598
+ )}
599
+ </section>
600
+ </div>
601
+ </div>
602
+ );
603
+ }
604
+
605
+ /**
606
+ * A staff answer published under a review or comment. Kept visually distinct from the
607
+ * private reply box, and labelled, because this one appears on the public site.
608
+ */
609
+ function PublicReplyBox({
610
+ parentId,
611
+ busy,
612
+ onReply,
613
+ }: {
614
+ parentId: string;
615
+ busy: boolean;
616
+ onReply: (body: string) => void;
617
+ }) {
618
+ const [open, setOpen] = useState(false);
619
+ const [body, setBody] = useState('');
620
+
621
+ if (!open) {
622
+ return (
623
+ <Button size="sm" variant="ghost" onClick={() => setOpen(true)}>
624
+ Reply publicly
625
+ </Button>
626
+ );
627
+ }
628
+
629
+ return (
630
+ <div className="mt-2 w-full space-y-2 rounded-md border border-dashed p-3">
631
+ <p className="text-[11px] font-semibold text-amber-600 dark:text-amber-400">
632
+ This reply will be published on your site, under the original.
633
+ </p>
634
+ <Textarea
635
+ id={`reply-${parentId}`}
636
+ value={body}
637
+ onChange={(event) => setBody(event.target.value)}
638
+ placeholder="Write a public reply…"
639
+ className="min-h-[80px]"
640
+ maxLength={5000}
641
+ />
642
+ <div className="flex justify-end gap-2">
643
+ <Button size="sm" variant="ghost" onClick={() => setOpen(false)}>
644
+ Cancel
645
+ </Button>
646
+ <Button
647
+ size="sm"
648
+ disabled={busy || !body.trim()}
649
+ onClick={() => {
650
+ onReply(body);
651
+ setBody('');
652
+ setOpen(false);
653
+ }}
654
+ >
655
+ {busy && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
656
+ Publish reply
657
+ </Button>
658
+ </div>
659
+ </div>
660
+ );
661
+ }