create-nextblock 0.16.1 → 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.
- package/docker-template/.dockerignore +1 -1
- package/libs/db/tsconfig.lib.json +3 -3
- package/libs/editor/tsconfig.lib.json +3 -3
- package/libs/ui/tsconfig.lib.json +3 -3
- package/libs/utils/tsconfig.json +3 -3
- package/package.json +50 -50
- package/project.json +19 -19
- package/templates/nextblock-template/.dockerignore +1 -1
- package/templates/nextblock-template/.swcrc +30 -30
- package/templates/nextblock-template/AGENTS.md +9 -9
- package/templates/nextblock-template/CLAUDE.md +1 -1
- package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
- package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
- package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
- package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
- package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
- package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
- package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
- package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
- package/templates/nextblock-template/app/actions/email.ts +241 -241
- package/templates/nextblock-template/app/actions/formActions.ts +245 -245
- package/templates/nextblock-template/app/actions/interactions.ts +489 -489
- package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
- package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
- package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -221
- package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -186
- package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
- package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +3092 -3092
- package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
- package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
- package/templates/nextblock-template/app/api/search/route.ts +43 -43
- package/templates/nextblock-template/app/api/view/route.ts +114 -114
- package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
- package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
- package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
- package/templates/nextblock-template/app/cart/page.tsx +7 -7
- package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
- package/templates/nextblock-template/app/checkout/page.tsx +57 -57
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -558
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
- package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
- package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -406
- package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
- package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
- package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
- package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -254
- package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
- package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
- package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
- package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
- package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
- package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
- package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
- package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
- package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
- package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/layout.tsx +101 -101
- package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
- package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
- package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
- package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
- package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
- package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
- package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
- package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
- package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -649
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +618 -618
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
- package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
- package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
- package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
- package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
- package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
- package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
- package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
- package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
- package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
- package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
- package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
- package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
- package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
- package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
- package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
- package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
- package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
- package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
- package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
- package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
- package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
- package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
- package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -514
- package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -529
- package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -57
- package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -448
- package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -93
- package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -46
- package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -47
- package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
- package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
- package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
- package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
- package/templates/nextblock-template/app/layout.tsx +671 -671
- package/templates/nextblock-template/app/lib/seo.ts +319 -319
- package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
- package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
- package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
- package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
- package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
- package/templates/nextblock-template/app/profile/account-links.ts +22 -22
- package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
- package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
- package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
- package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
- package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
- package/templates/nextblock-template/app/providers.tsx +96 -96
- package/templates/nextblock-template/app/robots.ts +123 -123
- package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
- package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
- package/templates/nextblock-template/app/thread/layout.tsx +15 -15
- package/templates/nextblock-template/app/thread/page.tsx +98 -98
- package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
- package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
- package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
- package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
- package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
- package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
- package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
- package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
- package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
- package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
- package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
- package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
- package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
- package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
- package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
- package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
- package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
- package/templates/nextblock-template/components/Header.tsx +38 -38
- package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
- package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
- package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
- package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
- package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
- package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
- package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
- package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
- package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
- package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
- package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
- package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
- package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
- package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
- package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
- package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
- package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
- package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
- package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
- package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
- package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
- package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
- package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
- package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
- package/templates/nextblock-template/components/blocks/types.ts +7 -7
- package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
- package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
- package/templates/nextblock-template/components/form-message.tsx +32 -32
- package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -137
- package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -244
- package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -749
- package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -195
- package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -144
- package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -243
- package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -110
- package/templates/nextblock-template/components/submit-button.tsx +23 -23
- package/templates/nextblock-template/components/theme-icon.tsx +78 -78
- package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
- package/templates/nextblock-template/context/AuthContext.tsx +23 -23
- package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
- package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
- package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
- package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
- package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
- package/templates/nextblock-template/docs/README.md +42 -42
- package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
- package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
- package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
- package/templates/nextblock-template/index.d.ts +7 -7
- package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
- package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
- package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
- package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
- package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
- package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
- package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
- package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
- package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
- package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
- package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
- package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -86
- package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -60
- package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
- package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
- package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
- package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
- package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
- package/templates/nextblock-template/lib/messages/threads.ts +579 -579
- package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
- package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
- package/templates/nextblock-template/lib/search/types.ts +27 -27
- package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -154
- package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -109
- package/templates/nextblock-template/lib/seo/block-content.ts +123 -123
- package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -242
- package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -204
- package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -140
- package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -350
- package/templates/nextblock-template/lib/seo/page-document.ts +412 -412
- package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -479
- package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -466
- package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -102
- package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -41
- package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -370
- package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -510
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -177
- package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
- package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
- package/templates/nextblock-template/lib/site-url.test.ts +89 -89
- package/templates/nextblock-template/lib/site-url.ts +102 -102
- package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
- package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
- package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
- package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
- package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
- package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
- package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
- package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
- package/templates/nextblock-template/lib/zod-config.ts +5 -5
- package/templates/nextblock-template/next-env.d.ts +1 -2
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/postcss.config.js +6 -6
- package/templates/nextblock-template/scripts/backup.js +115 -115
- package/templates/nextblock-template/scripts/restore.js +385 -385
- package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
- package/templates/nextblock-template/tailwind.config.js +25 -25
- package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
- package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
- package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
- package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
- package/templates/nextblock-template/tools/update.mjs +1303 -1303
- package/tsconfig.base.json +3 -3
|
@@ -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’t configured, so nobody is notified when a message
|
|
199
|
-
arrives and replies can’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’t configured, so nobody is notified when a message
|
|
199
|
+
arrives and replies can’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
|
+
}
|