create-nextblock 0.16.1 → 0.16.3

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 (264) 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 -221
  26. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -186
  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/route.ts +4 -4
  30. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +4542 -2904
  31. package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
  32. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  33. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  34. package/templates/nextblock-template/app/api/view/route.ts +114 -114
  35. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  36. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  37. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  38. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  39. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  40. package/templates/nextblock-template/app/checkout/page.tsx +57 -57
  41. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -558
  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 +406 -406
  46. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  47. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  48. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  49. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -254
  50. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  51. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  52. package/templates/nextblock-template/app/cms/components/SeoScoreBadge.tsx +40 -0
  53. package/templates/nextblock-template/app/cms/components/TablePagination.tsx +136 -0
  54. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  55. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  56. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  57. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  58. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  59. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  60. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  61. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  62. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  63. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  64. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  65. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  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/components/PageForm.tsx +649 -649
  74. package/templates/nextblock-template/app/cms/pages/page.tsx +273 -227
  75. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +2 -0
  76. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +624 -618
  77. package/templates/nextblock-template/app/cms/posts/page.tsx +251 -194
  78. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  79. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  80. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  81. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  82. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  83. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  84. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  85. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  86. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  87. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  88. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  90. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  91. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  92. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  93. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  95. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  96. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  97. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  98. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  99. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  100. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  101. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  102. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  103. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  104. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -514
  105. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -529
  106. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -57
  107. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -448
  108. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -93
  109. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -46
  110. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -47
  111. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  112. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  113. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  114. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  115. package/templates/nextblock-template/app/layout.tsx +671 -671
  116. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  117. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  118. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  119. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  120. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  121. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  122. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  123. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  124. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  125. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  126. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  127. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  128. package/templates/nextblock-template/app/providers.tsx +96 -96
  129. package/templates/nextblock-template/app/robots.ts +123 -123
  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 -137
  181. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +251 -244
  182. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -749
  183. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -195
  184. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -144
  185. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -243
  186. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -110
  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 -86
  214. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -60
  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 -154
  225. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -109
  226. package/templates/nextblock-template/lib/seo/block-content.ts +123 -123
  227. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -242
  228. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -204
  229. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +157 -140
  230. package/templates/nextblock-template/lib/seo/page-document.test.ts +384 -350
  231. package/templates/nextblock-template/lib/seo/page-document.ts +463 -412
  232. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -479
  233. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -466
  234. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -102
  235. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -41
  236. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -370
  237. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -510
  238. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +37 -22
  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 +1 -2
  253. package/templates/nextblock-template/package.json +1 -1
  254. package/templates/nextblock-template/postcss.config.js +6 -6
  255. package/templates/nextblock-template/scripts/backup.js +115 -115
  256. package/templates/nextblock-template/scripts/restore.js +385 -385
  257. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  258. package/templates/nextblock-template/tailwind.config.js +25 -25
  259. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  260. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  261. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  262. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  263. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  264. package/tsconfig.base.json +3 -3
@@ -1,542 +1,542 @@
1
- "use client";
2
-
3
- /**
4
- * Top-bar visibility control for pages, posts and products.
5
- *
6
- * This is the only place in the CMS that can change what the public sees. It is
7
- * deliberately NOT part of the content form: editing settings autosaves into the
8
- * Live Draft, while publishing writes straight to the row. Two decisions, two
9
- * controls — see `app/actions/visibilityActions.ts`.
10
- *
11
- * The pill is read-only on purpose. A <select> implies every option is equivalent
12
- * and instantly applied, which is exactly the behaviour this replaces; naming the
13
- * transitions as verbs in a menu lets each one carry its own confirmation.
14
- */
15
-
16
- import * as React from "react";
17
- import { useRouter } from "next/navigation";
18
- import { toast } from "react-hot-toast";
19
- import {
20
- Button,
21
- Checkbox,
22
- Dialog,
23
- DialogContent,
24
- DialogDescription,
25
- DialogFooter,
26
- DialogHeader,
27
- DialogTitle,
28
- DropdownMenu,
29
- DropdownMenuContent,
30
- DropdownMenuItem,
31
- DropdownMenuLabel,
32
- DropdownMenuSeparator,
33
- DropdownMenuTrigger,
34
- Input,
35
- Label,
36
- } from "@nextblock-cms/ui";
37
- import {
38
- LIVE_STATUS,
39
- resolveVisibilityState,
40
- type PublishableType,
41
- type VisibilityState,
42
- } from "@nextblock-cms/utils";
43
- import {
44
- AlertTriangle,
45
- CalendarClock,
46
- ChevronDown,
47
- Link2,
48
- Loader2,
49
- Archive,
50
- EyeOff,
51
- Globe,
52
- } from "lucide-react";
53
- import {
54
- getSiblingVisibility,
55
- setContentVisibility,
56
- type SiblingVisibility,
57
- type VisibilityIntent,
58
- } from "../../actions/visibilityActions";
59
- import { publishVisualEditingDraft, publishVisualEditingProductDraft } from "../../actions/visualEditingActions";
60
-
61
- interface LanguageOption {
62
- id: number;
63
- name: string;
64
- code: string;
65
- }
66
-
67
- export interface VisibilityControlProps {
68
- type: PublishableType;
69
- id: number | string;
70
- /** Live row status — never the draft overlay. */
71
- status: string;
72
- /** Live row published_at; a future value means "scheduled". */
73
- publishedAt: string | null;
74
- /** Root-relative public URL, e.g. "/about" or "/article/hello". */
75
- publicPath: string;
76
- /** Display name of the language this row is written in. */
77
- languageName?: string;
78
- translationGroupId?: string | null;
79
- languages?: LanguageOption[];
80
- /** True when unpublished content edits exist in the Live Draft. */
81
- hasDraft?: boolean;
82
- /**
83
- * A caution to show before this content goes public — e.g. a product whose payment
84
- * provider isn't configured, which publishes fine but cannot be bought yet. Shown
85
- * verbatim as an amber callout in the publish and schedule dialogs; it never blocks
86
- * the action, so write it as a consequence the editor can weigh, not a refusal.
87
- */
88
- publishWarning?: string | null;
89
- }
90
-
91
- const STATE_LABEL: Record<VisibilityState, string> = {
92
- draft: "Draft",
93
- scheduled: "Scheduled",
94
- published: "Published",
95
- archived: "Archived",
96
- };
97
-
98
- const STATE_STYLE: Record<VisibilityState, string> = {
99
- draft:
100
- "border-border bg-muted text-muted-foreground",
101
- scheduled:
102
- "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
103
- published:
104
- "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
105
- archived:
106
- "border-dashed border-border bg-transparent text-muted-foreground",
107
- };
108
-
109
- const DOT_STYLE: Record<VisibilityState, string> = {
110
- draft: "bg-muted-foreground/70",
111
- scheduled: "bg-amber-500",
112
- published: "bg-emerald-500",
113
- archived: "bg-muted-foreground/50",
114
- };
115
-
116
- /** `datetime-local` wants "YYYY-MM-DDTHH:mm" in LOCAL time, not an ISO string. */
117
- function toDateTimeLocal(value: string | null): string {
118
- if (!value) return "";
119
- const date = new Date(value);
120
- if (Number.isNaN(date.getTime())) return "";
121
- const pad = (n: number) => String(n).padStart(2, "0");
122
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
123
- date.getHours()
124
- )}:${pad(date.getMinutes())}`;
125
- }
126
-
127
- function defaultScheduleValue(): string {
128
- const soon = new Date();
129
- soon.setDate(soon.getDate() + 1);
130
- soon.setMinutes(0, 0, 0);
131
- return toDateTimeLocal(soon.toISOString());
132
- }
133
-
134
- export default function VisibilityControl({
135
- type,
136
- id,
137
- status,
138
- publishedAt,
139
- publicPath,
140
- languageName,
141
- translationGroupId,
142
- languages = [],
143
- hasDraft = false,
144
- publishWarning = null,
145
- }: VisibilityControlProps) {
146
- const router = useRouter();
147
- const liveStatus = LIVE_STATUS[type];
148
- const state = resolveVisibilityState({ status, publishedAt, liveStatus });
149
-
150
- const [dialog, setDialog] = React.useState<null | "publish" | "schedule" | "unpublish" | "archive">(null);
151
- const [isBusy, setIsBusy] = React.useState(false);
152
- const [error, setError] = React.useState<string | null>(null);
153
- const [alsoPublishDraft, setAlsoPublishDraft] = React.useState(true);
154
- const [scheduleValue, setScheduleValue] = React.useState(() => toDateTimeLocal(publishedAt) || defaultScheduleValue());
155
- const [siblings, setSiblings] = React.useState<SiblingVisibility[] | null>(null);
156
-
157
- // The schedule date is formatted in the viewer's timezone, which the server
158
- // doesn't know — render it only after mount so the two never disagree.
159
- const [mounted, setMounted] = React.useState(false);
160
- React.useEffect(() => setMounted(true), []);
161
-
162
- const scheduleLabel = React.useMemo(() => {
163
- if (!mounted || !publishedAt) return null;
164
- const date = new Date(publishedAt);
165
- if (Number.isNaN(date.getTime())) return null;
166
- return new Intl.DateTimeFormat(undefined, {
167
- month: "short",
168
- day: "numeric",
169
- hour: "numeric",
170
- minute: "2-digit",
171
- timeZoneName: "short",
172
- }).format(date);
173
- }, [mounted, publishedAt]);
174
-
175
- const label = state === "scheduled" && scheduleLabel ? `Goes live ${scheduleLabel}` : STATE_LABEL[state];
176
-
177
- const languageNameById = React.useMemo(
178
- () => new Map(languages.map((language) => [language.id, `${language.name} (${language.code.toUpperCase()})`])),
179
- [languages]
180
- );
181
-
182
- const openPublishDialog = React.useCallback(() => {
183
- // A warning is not an error: seeding it into `error` renders it destructive-red and
184
- // `run()` clears it on the next attempt. It belongs in the dialog body instead.
185
- setError(null);
186
- setAlsoPublishDraft(true);
187
- setSiblings(null);
188
- setDialog("publish");
189
-
190
- if (translationGroupId) {
191
- void getSiblingVisibility(type, translationGroupId, id).then(setSiblings);
192
- }
193
- }, [id, translationGroupId, type]);
194
-
195
- const run = async (intent: VisibilityIntent, opts?: { publishDraft?: boolean }) => {
196
- setIsBusy(true);
197
- setError(null);
198
- try {
199
- const result = await setContentVisibility(type, id, intent);
200
- if (result.error) {
201
- setError(result.error);
202
- return;
203
- }
204
-
205
- // Publishing the row and publishing pending edits are separate operations;
206
- // the dialog offers to do both so a never-live page doesn't go public
207
- // showing content the editor has already replaced.
208
- if (opts?.publishDraft && hasDraft) {
209
- const draftResult =
210
- type === "product"
211
- ? await publishVisualEditingProductDraft(String(id))
212
- : await publishVisualEditingDraft(type, Number(id));
213
-
214
- if (draftResult && "error" in draftResult && draftResult.error) {
215
- toast.error(`Visibility updated, but the edits didn't publish: ${draftResult.error}`);
216
- setDialog(null);
217
- router.refresh();
218
- return;
219
- }
220
-
221
- // Partial success: the edits went live but the revision didn't record.
222
- if (draftResult && "success" in draftResult && draftResult.warning) {
223
- toast.error(draftResult.warning, { duration: 8000 });
224
- }
225
- }
226
-
227
- toast.success(
228
- intent.action === "publish"
229
- ? "Published."
230
- : intent.action === "schedule"
231
- ? "Scheduled."
232
- : intent.action === "unpublish"
233
- ? "Unpublished."
234
- : "Archived."
235
- );
236
- setDialog(null);
237
- router.refresh();
238
- } catch (err) {
239
- setError(err instanceof Error ? err.message : "Something went wrong.");
240
- } finally {
241
- setIsBusy(false);
242
- }
243
- };
244
-
245
- const copyPublicUrl = async () => {
246
- const url = typeof window === "undefined" ? publicPath : new URL(publicPath, window.location.origin).toString();
247
- try {
248
- await navigator.clipboard.writeText(url);
249
- toast.success("Public URL copied.");
250
- } catch {
251
- toast.error("Couldn't copy the URL.");
252
- }
253
- };
254
-
255
- const typeLabel = type === "product" ? "product" : type;
256
- const busySpinner = <Loader2 className="mr-2 h-4 w-4 animate-spin" />;
257
-
258
- // Amber, not destructive: publishing is still allowed. Same treatment as the
259
- // "publish your unpublished edits too" opt-in below, which is the other caution
260
- // shown in this dialog.
261
- const publishWarningCallout = (
262
- <div className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
263
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
264
- <span>{publishWarning}</span>
265
- </div>
266
- );
267
-
268
- return (
269
- <div className="flex items-center gap-2">
270
- <span
271
- className={`inline-flex items-center gap-2 whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-semibold ${STATE_STYLE[state]}`}
272
- title={
273
- state === "published"
274
- ? `Live at ${publicPath}`
275
- : state === "scheduled"
276
- ? "Not public yet — goes live automatically"
277
- : "Not visible to the public"
278
- }
279
- >
280
- <span className={`h-1.5 w-1.5 rounded-full ${DOT_STYLE[state]}`} aria-hidden="true" />
281
- {label}
282
- </span>
283
-
284
- {hasDraft && (
285
- <span className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border border-dashed border-amber-500/50 px-2.5 py-1 text-xs font-medium text-amber-700 dark:text-amber-300">
286
- <span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden="true" />
287
- Unpublished edits
288
- </span>
289
- )}
290
-
291
- <div className="flex items-center">
292
- {state !== "published" && (
293
- <Button
294
- size="sm"
295
- className="rounded-r-none"
296
- onClick={openPublishDialog}
297
- disabled={isBusy}
298
- >
299
- <Globe className="mr-2 h-4 w-4" />
300
- {state === "scheduled" ? "Publish now" : "Publish…"}
301
- </Button>
302
- )}
303
- <DropdownMenu>
304
- <DropdownMenuTrigger asChild>
305
- <Button
306
- size="sm"
307
- variant={state === "published" ? "outline" : "default"}
308
- className={state === "published" ? "" : "-ml-px rounded-l-none px-2"}
309
- aria-label="Visibility options"
310
- disabled={isBusy}
311
- >
312
- {state === "published" && <span className="mr-1">Visibility</span>}
313
- <ChevronDown className="h-4 w-4" />
314
- </Button>
315
- </DropdownMenuTrigger>
316
- <DropdownMenuContent align="end" className="w-64">
317
- <DropdownMenuLabel>Visibility</DropdownMenuLabel>
318
- <DropdownMenuSeparator />
319
- <DropdownMenuItem
320
- onSelect={() => {
321
- setError(null);
322
- setScheduleValue(toDateTimeLocal(publishedAt) || defaultScheduleValue());
323
- setDialog("schedule");
324
- }}
325
- >
326
- <CalendarClock className="mr-2 h-4 w-4" />
327
- {state === "scheduled" ? "Reschedule…" : "Schedule…"}
328
- </DropdownMenuItem>
329
- <DropdownMenuItem onSelect={() => void copyPublicUrl()}>
330
- <Link2 className="mr-2 h-4 w-4" />
331
- Copy public URL
332
- </DropdownMenuItem>
333
- {(state === "published" || state === "scheduled") && (
334
- <>
335
- <DropdownMenuSeparator />
336
- <DropdownMenuItem
337
- className="text-destructive focus:text-destructive"
338
- onSelect={() => {
339
- setError(null);
340
- setDialog("unpublish");
341
- }}
342
- >
343
- <EyeOff className="mr-2 h-4 w-4" />
344
- Unpublish
345
- </DropdownMenuItem>
346
- </>
347
- )}
348
- {state !== "archived" && (
349
- <DropdownMenuItem
350
- className="text-destructive focus:text-destructive"
351
- onSelect={() => {
352
- setError(null);
353
- setDialog("archive");
354
- }}
355
- >
356
- <Archive className="mr-2 h-4 w-4" />
357
- Archive
358
- </DropdownMenuItem>
359
- )}
360
- </DropdownMenuContent>
361
- </DropdownMenu>
362
- </div>
363
-
364
- {/* ---- Publish ---- */}
365
- <Dialog open={dialog === "publish"} onOpenChange={(open) => !open && setDialog(null)}>
366
- <DialogContent>
367
- <DialogHeader>
368
- <DialogTitle>Make this {typeLabel} public?</DialogTitle>
369
- <DialogDescription>
370
- Anyone with the link will be able to see it, and search engines can index it.
371
- </DialogDescription>
372
- </DialogHeader>
373
-
374
- <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-md border bg-muted/40 p-3 text-sm">
375
- <dt className="text-xs uppercase tracking-wide text-muted-foreground">URL</dt>
376
- <dd className="font-mono text-xs break-all">{publicPath}</dd>
377
- {languageName && (
378
- <>
379
- <dt className="text-xs uppercase tracking-wide text-muted-foreground">Language</dt>
380
- <dd>{languageName}</dd>
381
- </>
382
- )}
383
- <dt className="text-xs uppercase tracking-wide text-muted-foreground">Goes live</dt>
384
- <dd>Immediately</dd>
385
- </dl>
386
-
387
- {hasDraft && (
388
- <label className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
389
- <Checkbox
390
- checked={alsoPublishDraft}
391
- onCheckedChange={(checked) => setAlsoPublishDraft(checked === true)}
392
- className="mt-0.5"
393
- />
394
- <span>
395
- Publish your unpublished edits at the same time — otherwise the older live version
396
- is what goes public.
397
- </span>
398
- </label>
399
- )}
400
-
401
- {siblings && siblings.some((sibling) => sibling.state !== "published") && (
402
- <div className="space-y-1.5 text-sm text-muted-foreground">
403
- {siblings
404
- .filter((sibling) => sibling.state !== "published")
405
- .map((sibling) => (
406
- <p key={String(sibling.id)}>
407
- <span className="font-semibold text-amber-600 dark:text-amber-400">!</span>{" "}
408
- The {languageNameById.get(sibling.languageId) ?? "other language"} version is
409
- still {STATE_LABEL[sibling.state].toLowerCase()}.
410
- </p>
411
- ))}
412
- </div>
413
- )}
414
-
415
- {publishWarning && publishWarningCallout}
416
-
417
- {error && <p className="text-sm text-destructive">{error}</p>}
418
-
419
- <DialogFooter className="mt-2 gap-2">
420
- <Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
421
- Cancel
422
- </Button>
423
- <Button
424
- onClick={() => void run({ action: "publish" }, { publishDraft: alsoPublishDraft })}
425
- disabled={isBusy}
426
- >
427
- {isBusy && busySpinner}
428
- {publishWarning ? `Publish anyway` : `Publish ${typeLabel}`}
429
- </Button>
430
- </DialogFooter>
431
- </DialogContent>
432
- </Dialog>
433
-
434
- {/* ---- Schedule ---- */}
435
- <Dialog open={dialog === "schedule"} onOpenChange={(open) => !open && setDialog(null)}>
436
- <DialogContent>
437
- <DialogHeader>
438
- <DialogTitle>Schedule this {typeLabel}</DialogTitle>
439
- <DialogDescription>
440
- It stays private until the moment you pick, then goes live on its own. Times are in
441
- your own timezone.
442
- </DialogDescription>
443
- </DialogHeader>
444
-
445
- <div className="space-y-2">
446
- <Label htmlFor="visibility-schedule">Go live on</Label>
447
- <Input
448
- id="visibility-schedule"
449
- type="datetime-local"
450
- value={scheduleValue}
451
- onChange={(event) => setScheduleValue(event.target.value)}
452
- />
453
- </div>
454
-
455
- {publishWarning && publishWarningCallout}
456
-
457
- {error && <p className="text-sm text-destructive">{error}</p>}
458
-
459
- <DialogFooter className="mt-2 gap-2">
460
- <Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
461
- Cancel
462
- </Button>
463
- <Button
464
- onClick={() => {
465
- const local = new Date(scheduleValue);
466
- if (Number.isNaN(local.getTime())) {
467
- setError("That isn't a valid date and time.");
468
- return;
469
- }
470
- void run({ action: "schedule", publishedAt: local.toISOString() });
471
- }}
472
- disabled={isBusy || !scheduleValue}
473
- >
474
- {isBusy && busySpinner}
475
- {publishWarning ? "Schedule anyway" : "Schedule"}
476
- </Button>
477
- </DialogFooter>
478
- </DialogContent>
479
- </Dialog>
480
-
481
- {/* ---- Unpublish ---- */}
482
- <Dialog open={dialog === "unpublish"} onOpenChange={(open) => !open && setDialog(null)}>
483
- <DialogContent>
484
- <DialogHeader>
485
- <DialogTitle>Take this {typeLabel} off the live site?</DialogTitle>
486
- <DialogDescription>
487
- Visitors will get a 404 at{" "}
488
- <span className="font-mono text-xs">{publicPath}</span>. Your content and any
489
- unpublished edits are kept — you can publish it again at any time. Check your
490
- navigation menus for links pointing here.
491
- </DialogDescription>
492
- </DialogHeader>
493
-
494
- {error && <p className="text-sm text-destructive">{error}</p>}
495
-
496
- <DialogFooter className="mt-2 gap-2">
497
- <Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
498
- Cancel
499
- </Button>
500
- <Button
501
- variant="destructive"
502
- onClick={() => void run({ action: "unpublish" })}
503
- disabled={isBusy}
504
- >
505
- {isBusy && busySpinner}
506
- Unpublish
507
- </Button>
508
- </DialogFooter>
509
- </DialogContent>
510
- </Dialog>
511
-
512
- {/* ---- Archive ---- */}
513
- <Dialog open={dialog === "archive"} onOpenChange={(open) => !open && setDialog(null)}>
514
- <DialogContent>
515
- <DialogHeader>
516
- <DialogTitle>Archive this {typeLabel}?</DialogTitle>
517
- <DialogDescription>
518
- It comes off the live site and drops out of the default {typeLabel} list. Nothing is
519
- deleted, and you can publish it again later.
520
- </DialogDescription>
521
- </DialogHeader>
522
-
523
- {error && <p className="text-sm text-destructive">{error}</p>}
524
-
525
- <DialogFooter className="mt-2 gap-2">
526
- <Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
527
- Cancel
528
- </Button>
529
- <Button
530
- variant="destructive"
531
- onClick={() => void run({ action: "archive" })}
532
- disabled={isBusy}
533
- >
534
- {isBusy && busySpinner}
535
- Archive
536
- </Button>
537
- </DialogFooter>
538
- </DialogContent>
539
- </Dialog>
540
- </div>
541
- );
542
- }
1
+ "use client";
2
+
3
+ /**
4
+ * Top-bar visibility control for pages, posts and products.
5
+ *
6
+ * This is the only place in the CMS that can change what the public sees. It is
7
+ * deliberately NOT part of the content form: editing settings autosaves into the
8
+ * Live Draft, while publishing writes straight to the row. Two decisions, two
9
+ * controls — see `app/actions/visibilityActions.ts`.
10
+ *
11
+ * The pill is read-only on purpose. A <select> implies every option is equivalent
12
+ * and instantly applied, which is exactly the behaviour this replaces; naming the
13
+ * transitions as verbs in a menu lets each one carry its own confirmation.
14
+ */
15
+
16
+ import * as React from "react";
17
+ import { useRouter } from "next/navigation";
18
+ import { toast } from "react-hot-toast";
19
+ import {
20
+ Button,
21
+ Checkbox,
22
+ Dialog,
23
+ DialogContent,
24
+ DialogDescription,
25
+ DialogFooter,
26
+ DialogHeader,
27
+ DialogTitle,
28
+ DropdownMenu,
29
+ DropdownMenuContent,
30
+ DropdownMenuItem,
31
+ DropdownMenuLabel,
32
+ DropdownMenuSeparator,
33
+ DropdownMenuTrigger,
34
+ Input,
35
+ Label,
36
+ } from "@nextblock-cms/ui";
37
+ import {
38
+ LIVE_STATUS,
39
+ resolveVisibilityState,
40
+ type PublishableType,
41
+ type VisibilityState,
42
+ } from "@nextblock-cms/utils";
43
+ import {
44
+ AlertTriangle,
45
+ CalendarClock,
46
+ ChevronDown,
47
+ Link2,
48
+ Loader2,
49
+ Archive,
50
+ EyeOff,
51
+ Globe,
52
+ } from "lucide-react";
53
+ import {
54
+ getSiblingVisibility,
55
+ setContentVisibility,
56
+ type SiblingVisibility,
57
+ type VisibilityIntent,
58
+ } from "../../actions/visibilityActions";
59
+ import { publishVisualEditingDraft, publishVisualEditingProductDraft } from "../../actions/visualEditingActions";
60
+
61
+ interface LanguageOption {
62
+ id: number;
63
+ name: string;
64
+ code: string;
65
+ }
66
+
67
+ export interface VisibilityControlProps {
68
+ type: PublishableType;
69
+ id: number | string;
70
+ /** Live row status — never the draft overlay. */
71
+ status: string;
72
+ /** Live row published_at; a future value means "scheduled". */
73
+ publishedAt: string | null;
74
+ /** Root-relative public URL, e.g. "/about" or "/article/hello". */
75
+ publicPath: string;
76
+ /** Display name of the language this row is written in. */
77
+ languageName?: string;
78
+ translationGroupId?: string | null;
79
+ languages?: LanguageOption[];
80
+ /** True when unpublished content edits exist in the Live Draft. */
81
+ hasDraft?: boolean;
82
+ /**
83
+ * A caution to show before this content goes public — e.g. a product whose payment
84
+ * provider isn't configured, which publishes fine but cannot be bought yet. Shown
85
+ * verbatim as an amber callout in the publish and schedule dialogs; it never blocks
86
+ * the action, so write it as a consequence the editor can weigh, not a refusal.
87
+ */
88
+ publishWarning?: string | null;
89
+ }
90
+
91
+ const STATE_LABEL: Record<VisibilityState, string> = {
92
+ draft: "Draft",
93
+ scheduled: "Scheduled",
94
+ published: "Published",
95
+ archived: "Archived",
96
+ };
97
+
98
+ const STATE_STYLE: Record<VisibilityState, string> = {
99
+ draft:
100
+ "border-border bg-muted text-muted-foreground",
101
+ scheduled:
102
+ "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
103
+ published:
104
+ "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
105
+ archived:
106
+ "border-dashed border-border bg-transparent text-muted-foreground",
107
+ };
108
+
109
+ const DOT_STYLE: Record<VisibilityState, string> = {
110
+ draft: "bg-muted-foreground/70",
111
+ scheduled: "bg-amber-500",
112
+ published: "bg-emerald-500",
113
+ archived: "bg-muted-foreground/50",
114
+ };
115
+
116
+ /** `datetime-local` wants "YYYY-MM-DDTHH:mm" in LOCAL time, not an ISO string. */
117
+ function toDateTimeLocal(value: string | null): string {
118
+ if (!value) return "";
119
+ const date = new Date(value);
120
+ if (Number.isNaN(date.getTime())) return "";
121
+ const pad = (n: number) => String(n).padStart(2, "0");
122
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
123
+ date.getHours()
124
+ )}:${pad(date.getMinutes())}`;
125
+ }
126
+
127
+ function defaultScheduleValue(): string {
128
+ const soon = new Date();
129
+ soon.setDate(soon.getDate() + 1);
130
+ soon.setMinutes(0, 0, 0);
131
+ return toDateTimeLocal(soon.toISOString());
132
+ }
133
+
134
+ export default function VisibilityControl({
135
+ type,
136
+ id,
137
+ status,
138
+ publishedAt,
139
+ publicPath,
140
+ languageName,
141
+ translationGroupId,
142
+ languages = [],
143
+ hasDraft = false,
144
+ publishWarning = null,
145
+ }: VisibilityControlProps) {
146
+ const router = useRouter();
147
+ const liveStatus = LIVE_STATUS[type];
148
+ const state = resolveVisibilityState({ status, publishedAt, liveStatus });
149
+
150
+ const [dialog, setDialog] = React.useState<null | "publish" | "schedule" | "unpublish" | "archive">(null);
151
+ const [isBusy, setIsBusy] = React.useState(false);
152
+ const [error, setError] = React.useState<string | null>(null);
153
+ const [alsoPublishDraft, setAlsoPublishDraft] = React.useState(true);
154
+ const [scheduleValue, setScheduleValue] = React.useState(() => toDateTimeLocal(publishedAt) || defaultScheduleValue());
155
+ const [siblings, setSiblings] = React.useState<SiblingVisibility[] | null>(null);
156
+
157
+ // The schedule date is formatted in the viewer's timezone, which the server
158
+ // doesn't know — render it only after mount so the two never disagree.
159
+ const [mounted, setMounted] = React.useState(false);
160
+ React.useEffect(() => setMounted(true), []);
161
+
162
+ const scheduleLabel = React.useMemo(() => {
163
+ if (!mounted || !publishedAt) return null;
164
+ const date = new Date(publishedAt);
165
+ if (Number.isNaN(date.getTime())) return null;
166
+ return new Intl.DateTimeFormat(undefined, {
167
+ month: "short",
168
+ day: "numeric",
169
+ hour: "numeric",
170
+ minute: "2-digit",
171
+ timeZoneName: "short",
172
+ }).format(date);
173
+ }, [mounted, publishedAt]);
174
+
175
+ const label = state === "scheduled" && scheduleLabel ? `Goes live ${scheduleLabel}` : STATE_LABEL[state];
176
+
177
+ const languageNameById = React.useMemo(
178
+ () => new Map(languages.map((language) => [language.id, `${language.name} (${language.code.toUpperCase()})`])),
179
+ [languages]
180
+ );
181
+
182
+ const openPublishDialog = React.useCallback(() => {
183
+ // A warning is not an error: seeding it into `error` renders it destructive-red and
184
+ // `run()` clears it on the next attempt. It belongs in the dialog body instead.
185
+ setError(null);
186
+ setAlsoPublishDraft(true);
187
+ setSiblings(null);
188
+ setDialog("publish");
189
+
190
+ if (translationGroupId) {
191
+ void getSiblingVisibility(type, translationGroupId, id).then(setSiblings);
192
+ }
193
+ }, [id, translationGroupId, type]);
194
+
195
+ const run = async (intent: VisibilityIntent, opts?: { publishDraft?: boolean }) => {
196
+ setIsBusy(true);
197
+ setError(null);
198
+ try {
199
+ const result = await setContentVisibility(type, id, intent);
200
+ if (result.error) {
201
+ setError(result.error);
202
+ return;
203
+ }
204
+
205
+ // Publishing the row and publishing pending edits are separate operations;
206
+ // the dialog offers to do both so a never-live page doesn't go public
207
+ // showing content the editor has already replaced.
208
+ if (opts?.publishDraft && hasDraft) {
209
+ const draftResult =
210
+ type === "product"
211
+ ? await publishVisualEditingProductDraft(String(id))
212
+ : await publishVisualEditingDraft(type, Number(id));
213
+
214
+ if (draftResult && "error" in draftResult && draftResult.error) {
215
+ toast.error(`Visibility updated, but the edits didn't publish: ${draftResult.error}`);
216
+ setDialog(null);
217
+ router.refresh();
218
+ return;
219
+ }
220
+
221
+ // Partial success: the edits went live but the revision didn't record.
222
+ if (draftResult && "success" in draftResult && draftResult.warning) {
223
+ toast.error(draftResult.warning, { duration: 8000 });
224
+ }
225
+ }
226
+
227
+ toast.success(
228
+ intent.action === "publish"
229
+ ? "Published."
230
+ : intent.action === "schedule"
231
+ ? "Scheduled."
232
+ : intent.action === "unpublish"
233
+ ? "Unpublished."
234
+ : "Archived."
235
+ );
236
+ setDialog(null);
237
+ router.refresh();
238
+ } catch (err) {
239
+ setError(err instanceof Error ? err.message : "Something went wrong.");
240
+ } finally {
241
+ setIsBusy(false);
242
+ }
243
+ };
244
+
245
+ const copyPublicUrl = async () => {
246
+ const url = typeof window === "undefined" ? publicPath : new URL(publicPath, window.location.origin).toString();
247
+ try {
248
+ await navigator.clipboard.writeText(url);
249
+ toast.success("Public URL copied.");
250
+ } catch {
251
+ toast.error("Couldn't copy the URL.");
252
+ }
253
+ };
254
+
255
+ const typeLabel = type === "product" ? "product" : type;
256
+ const busySpinner = <Loader2 className="mr-2 h-4 w-4 animate-spin" />;
257
+
258
+ // Amber, not destructive: publishing is still allowed. Same treatment as the
259
+ // "publish your unpublished edits too" opt-in below, which is the other caution
260
+ // shown in this dialog.
261
+ const publishWarningCallout = (
262
+ <div className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
263
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
264
+ <span>{publishWarning}</span>
265
+ </div>
266
+ );
267
+
268
+ return (
269
+ <div className="flex items-center gap-2">
270
+ <span
271
+ className={`inline-flex items-center gap-2 whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-semibold ${STATE_STYLE[state]}`}
272
+ title={
273
+ state === "published"
274
+ ? `Live at ${publicPath}`
275
+ : state === "scheduled"
276
+ ? "Not public yet — goes live automatically"
277
+ : "Not visible to the public"
278
+ }
279
+ >
280
+ <span className={`h-1.5 w-1.5 rounded-full ${DOT_STYLE[state]}`} aria-hidden="true" />
281
+ {label}
282
+ </span>
283
+
284
+ {hasDraft && (
285
+ <span className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border border-dashed border-amber-500/50 px-2.5 py-1 text-xs font-medium text-amber-700 dark:text-amber-300">
286
+ <span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden="true" />
287
+ Unpublished edits
288
+ </span>
289
+ )}
290
+
291
+ <div className="flex items-center">
292
+ {state !== "published" && (
293
+ <Button
294
+ size="sm"
295
+ className="rounded-r-none"
296
+ onClick={openPublishDialog}
297
+ disabled={isBusy}
298
+ >
299
+ <Globe className="mr-2 h-4 w-4" />
300
+ {state === "scheduled" ? "Publish now" : "Publish…"}
301
+ </Button>
302
+ )}
303
+ <DropdownMenu>
304
+ <DropdownMenuTrigger asChild>
305
+ <Button
306
+ size="sm"
307
+ variant={state === "published" ? "outline" : "default"}
308
+ className={state === "published" ? "" : "-ml-px rounded-l-none px-2"}
309
+ aria-label="Visibility options"
310
+ disabled={isBusy}
311
+ >
312
+ {state === "published" && <span className="mr-1">Visibility</span>}
313
+ <ChevronDown className="h-4 w-4" />
314
+ </Button>
315
+ </DropdownMenuTrigger>
316
+ <DropdownMenuContent align="end" className="w-64">
317
+ <DropdownMenuLabel>Visibility</DropdownMenuLabel>
318
+ <DropdownMenuSeparator />
319
+ <DropdownMenuItem
320
+ onSelect={() => {
321
+ setError(null);
322
+ setScheduleValue(toDateTimeLocal(publishedAt) || defaultScheduleValue());
323
+ setDialog("schedule");
324
+ }}
325
+ >
326
+ <CalendarClock className="mr-2 h-4 w-4" />
327
+ {state === "scheduled" ? "Reschedule…" : "Schedule…"}
328
+ </DropdownMenuItem>
329
+ <DropdownMenuItem onSelect={() => void copyPublicUrl()}>
330
+ <Link2 className="mr-2 h-4 w-4" />
331
+ Copy public URL
332
+ </DropdownMenuItem>
333
+ {(state === "published" || state === "scheduled") && (
334
+ <>
335
+ <DropdownMenuSeparator />
336
+ <DropdownMenuItem
337
+ className="text-destructive focus:text-destructive"
338
+ onSelect={() => {
339
+ setError(null);
340
+ setDialog("unpublish");
341
+ }}
342
+ >
343
+ <EyeOff className="mr-2 h-4 w-4" />
344
+ Unpublish
345
+ </DropdownMenuItem>
346
+ </>
347
+ )}
348
+ {state !== "archived" && (
349
+ <DropdownMenuItem
350
+ className="text-destructive focus:text-destructive"
351
+ onSelect={() => {
352
+ setError(null);
353
+ setDialog("archive");
354
+ }}
355
+ >
356
+ <Archive className="mr-2 h-4 w-4" />
357
+ Archive
358
+ </DropdownMenuItem>
359
+ )}
360
+ </DropdownMenuContent>
361
+ </DropdownMenu>
362
+ </div>
363
+
364
+ {/* ---- Publish ---- */}
365
+ <Dialog open={dialog === "publish"} onOpenChange={(open) => !open && setDialog(null)}>
366
+ <DialogContent>
367
+ <DialogHeader>
368
+ <DialogTitle>Make this {typeLabel} public?</DialogTitle>
369
+ <DialogDescription>
370
+ Anyone with the link will be able to see it, and search engines can index it.
371
+ </DialogDescription>
372
+ </DialogHeader>
373
+
374
+ <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-md border bg-muted/40 p-3 text-sm">
375
+ <dt className="text-xs uppercase tracking-wide text-muted-foreground">URL</dt>
376
+ <dd className="font-mono text-xs break-all">{publicPath}</dd>
377
+ {languageName && (
378
+ <>
379
+ <dt className="text-xs uppercase tracking-wide text-muted-foreground">Language</dt>
380
+ <dd>{languageName}</dd>
381
+ </>
382
+ )}
383
+ <dt className="text-xs uppercase tracking-wide text-muted-foreground">Goes live</dt>
384
+ <dd>Immediately</dd>
385
+ </dl>
386
+
387
+ {hasDraft && (
388
+ <label className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
389
+ <Checkbox
390
+ checked={alsoPublishDraft}
391
+ onCheckedChange={(checked) => setAlsoPublishDraft(checked === true)}
392
+ className="mt-0.5"
393
+ />
394
+ <span>
395
+ Publish your unpublished edits at the same time — otherwise the older live version
396
+ is what goes public.
397
+ </span>
398
+ </label>
399
+ )}
400
+
401
+ {siblings && siblings.some((sibling) => sibling.state !== "published") && (
402
+ <div className="space-y-1.5 text-sm text-muted-foreground">
403
+ {siblings
404
+ .filter((sibling) => sibling.state !== "published")
405
+ .map((sibling) => (
406
+ <p key={String(sibling.id)}>
407
+ <span className="font-semibold text-amber-600 dark:text-amber-400">!</span>{" "}
408
+ The {languageNameById.get(sibling.languageId) ?? "other language"} version is
409
+ still {STATE_LABEL[sibling.state].toLowerCase()}.
410
+ </p>
411
+ ))}
412
+ </div>
413
+ )}
414
+
415
+ {publishWarning && publishWarningCallout}
416
+
417
+ {error && <p className="text-sm text-destructive">{error}</p>}
418
+
419
+ <DialogFooter className="mt-2 gap-2">
420
+ <Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
421
+ Cancel
422
+ </Button>
423
+ <Button
424
+ onClick={() => void run({ action: "publish" }, { publishDraft: alsoPublishDraft })}
425
+ disabled={isBusy}
426
+ >
427
+ {isBusy && busySpinner}
428
+ {publishWarning ? `Publish anyway` : `Publish ${typeLabel}`}
429
+ </Button>
430
+ </DialogFooter>
431
+ </DialogContent>
432
+ </Dialog>
433
+
434
+ {/* ---- Schedule ---- */}
435
+ <Dialog open={dialog === "schedule"} onOpenChange={(open) => !open && setDialog(null)}>
436
+ <DialogContent>
437
+ <DialogHeader>
438
+ <DialogTitle>Schedule this {typeLabel}</DialogTitle>
439
+ <DialogDescription>
440
+ It stays private until the moment you pick, then goes live on its own. Times are in
441
+ your own timezone.
442
+ </DialogDescription>
443
+ </DialogHeader>
444
+
445
+ <div className="space-y-2">
446
+ <Label htmlFor="visibility-schedule">Go live on</Label>
447
+ <Input
448
+ id="visibility-schedule"
449
+ type="datetime-local"
450
+ value={scheduleValue}
451
+ onChange={(event) => setScheduleValue(event.target.value)}
452
+ />
453
+ </div>
454
+
455
+ {publishWarning && publishWarningCallout}
456
+
457
+ {error && <p className="text-sm text-destructive">{error}</p>}
458
+
459
+ <DialogFooter className="mt-2 gap-2">
460
+ <Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
461
+ Cancel
462
+ </Button>
463
+ <Button
464
+ onClick={() => {
465
+ const local = new Date(scheduleValue);
466
+ if (Number.isNaN(local.getTime())) {
467
+ setError("That isn't a valid date and time.");
468
+ return;
469
+ }
470
+ void run({ action: "schedule", publishedAt: local.toISOString() });
471
+ }}
472
+ disabled={isBusy || !scheduleValue}
473
+ >
474
+ {isBusy && busySpinner}
475
+ {publishWarning ? "Schedule anyway" : "Schedule"}
476
+ </Button>
477
+ </DialogFooter>
478
+ </DialogContent>
479
+ </Dialog>
480
+
481
+ {/* ---- Unpublish ---- */}
482
+ <Dialog open={dialog === "unpublish"} onOpenChange={(open) => !open && setDialog(null)}>
483
+ <DialogContent>
484
+ <DialogHeader>
485
+ <DialogTitle>Take this {typeLabel} off the live site?</DialogTitle>
486
+ <DialogDescription>
487
+ Visitors will get a 404 at{" "}
488
+ <span className="font-mono text-xs">{publicPath}</span>. Your content and any
489
+ unpublished edits are kept — you can publish it again at any time. Check your
490
+ navigation menus for links pointing here.
491
+ </DialogDescription>
492
+ </DialogHeader>
493
+
494
+ {error && <p className="text-sm text-destructive">{error}</p>}
495
+
496
+ <DialogFooter className="mt-2 gap-2">
497
+ <Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
498
+ Cancel
499
+ </Button>
500
+ <Button
501
+ variant="destructive"
502
+ onClick={() => void run({ action: "unpublish" })}
503
+ disabled={isBusy}
504
+ >
505
+ {isBusy && busySpinner}
506
+ Unpublish
507
+ </Button>
508
+ </DialogFooter>
509
+ </DialogContent>
510
+ </Dialog>
511
+
512
+ {/* ---- Archive ---- */}
513
+ <Dialog open={dialog === "archive"} onOpenChange={(open) => !open && setDialog(null)}>
514
+ <DialogContent>
515
+ <DialogHeader>
516
+ <DialogTitle>Archive this {typeLabel}?</DialogTitle>
517
+ <DialogDescription>
518
+ It comes off the live site and drops out of the default {typeLabel} list. Nothing is
519
+ deleted, and you can publish it again later.
520
+ </DialogDescription>
521
+ </DialogHeader>
522
+
523
+ {error && <p className="text-sm text-destructive">{error}</p>}
524
+
525
+ <DialogFooter className="mt-2 gap-2">
526
+ <Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
527
+ Cancel
528
+ </Button>
529
+ <Button
530
+ variant="destructive"
531
+ onClick={() => void run({ action: "archive" })}
532
+ disabled={isBusy}
533
+ >
534
+ {isBusy && busySpinner}
535
+ Archive
536
+ </Button>
537
+ </DialogFooter>
538
+ </DialogContent>
539
+ </Dialog>
540
+ </div>
541
+ );
542
+ }