create-nextblock 0.15.8 → 0.15.9
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/package.json +1 -1
- package/templates/nextblock-template/app/ToasterProvider.tsx +26 -17
- package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -0
- package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -0
- package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -0
- package/templates/nextblock-template/app/actions/email.ts +241 -110
- package/templates/nextblock-template/app/actions/formActions.ts +245 -116
- package/templates/nextblock-template/app/actions/interactions.ts +489 -396
- package/templates/nextblock-template/app/actions/threadActions.ts +166 -0
- package/templates/nextblock-template/app/api/checkout/route.ts +162 -146
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +664 -1
- package/templates/nextblock-template/app/checkout/page.tsx +57 -52
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +552 -529
- package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -181
- package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -0
- package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -0
- package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -528
- package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -0
- package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -0
- package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -51
- package/templates/nextblock-template/app/cms/layout.tsx +101 -73
- package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -0
- package/templates/nextblock-template/app/cms/messages/actions.ts +404 -0
- package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -0
- package/templates/nextblock-template/app/cms/messages/page.tsx +87 -0
- package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -0
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -362
- package/templates/nextblock-template/app/cms/revisions/service.ts +20 -0
- package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -185
- package/templates/nextblock-template/app/layout.tsx +671 -671
- package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -482
- package/templates/nextblock-template/app/providers.tsx +96 -96
- package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -0
- package/templates/nextblock-template/app/thread/[token]/route.ts +57 -0
- package/templates/nextblock-template/app/thread/layout.tsx +15 -0
- package/templates/nextblock-template/app/thread/page.tsx +98 -0
- package/templates/nextblock-template/components/BlockRenderer.tsx +312 -296
- package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -0
- package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -369
- package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -419
- package/templates/nextblock-template/components/StaffReplies.tsx +102 -0
- package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -17
- package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -19
- package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -22
- package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -381
- package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -92
- package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -31
- package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -600
- package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -0
- package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -0
- package/templates/nextblock-template/docs/README.md +42 -41
- package/templates/nextblock-template/docs/assets/lighthouse-scores.png +0 -0
- package/templates/nextblock-template/lib/blocks/blockColors.test.ts +22 -2
- package/templates/nextblock-template/lib/blocks/blockColors.ts +44 -1
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -753
- package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -0
- package/templates/nextblock-template/lib/cms/payments-reminder.ts +98 -0
- package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -0
- package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -0
- package/templates/nextblock-template/lib/config/email-settings.ts +323 -254
- package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -0
- package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -0
- package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -0
- package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -0
- package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -0
- package/templates/nextblock-template/lib/messages/thread-token.ts +157 -0
- package/templates/nextblock-template/lib/messages/threads.ts +579 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +20 -0
- package/templates/nextblock-template/lib/site-url.test.ts +89 -0
- package/templates/nextblock-template/lib/site-url.ts +102 -48
- package/templates/nextblock-template/package.json +1 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
|
|
4
|
+
|
|
5
|
+
import { isPlaceholderEmail, usableEmail } from '../email/placeholder-address';
|
|
6
|
+
import { resolveSellerContactEmail } from '../commerce/seller-contact';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A contact form that appears to work but delivers nowhere.
|
|
10
|
+
*
|
|
11
|
+
* The starter content ships a contact page whose form is addressed to
|
|
12
|
+
* `contact@example.com`. That is the worst kind of default: every layer downstream treats
|
|
13
|
+
* it as a real setting, the visitor is thanked, the transport accepts the message, and it
|
|
14
|
+
* is delivered to a domain RFC 2606 reserves so that it can never exist. There is no
|
|
15
|
+
* error anywhere for the operator to notice — which is exactly how a site runs for months
|
|
16
|
+
* quietly discarding enquiries.
|
|
17
|
+
*
|
|
18
|
+
* Submissions are still recorded as threads, so nothing is actually lost. But the owner
|
|
19
|
+
* is not being told about them, and they have no way to know that.
|
|
20
|
+
*/
|
|
21
|
+
export interface ContactReminder {
|
|
22
|
+
/** Forms addressed to a reserved placeholder domain. */
|
|
23
|
+
placeholderForms: Array<{ formKey: string; label: string; recipient: string }>;
|
|
24
|
+
/** True when nothing anywhere resolves to a deliverable address. */
|
|
25
|
+
noFallback: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function getContactReminder(): Promise<ContactReminder | null> {
|
|
29
|
+
// The sandbox is re-seeded on a schedule, so its placeholder is expected and permanent.
|
|
30
|
+
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') return null;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const supabase = getServiceRoleSupabaseClient();
|
|
34
|
+
const { data: endpoints } = await supabase
|
|
35
|
+
.from('form_endpoints')
|
|
36
|
+
.select('form_key, label, recipient_email');
|
|
37
|
+
|
|
38
|
+
const placeholderForms = (endpoints ?? [])
|
|
39
|
+
.filter((endpoint) => isPlaceholderEmail(endpoint.recipient_email))
|
|
40
|
+
.map((endpoint) => ({
|
|
41
|
+
formKey: endpoint.form_key,
|
|
42
|
+
label: endpoint.label || 'Contact form',
|
|
43
|
+
recipient: endpoint.recipient_email as string,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// Forms with no deliverable address of their own lean on the site-wide ladder.
|
|
47
|
+
// Whether a missing fallback matters depends entirely on whether anything needs it:
|
|
48
|
+
// an install whose every form carries its own real address is correctly configured,
|
|
49
|
+
// and warning it about an empty fallback would be noise.
|
|
50
|
+
const dependsOnFallback = (endpoints ?? []).some(
|
|
51
|
+
(endpoint) => !usableEmail(endpoint.recipient_email)
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const { email: fallback } = await resolveSellerContactEmail();
|
|
55
|
+
const noFallback = dependsOnFallback && !fallback;
|
|
56
|
+
|
|
57
|
+
if (placeholderForms.length === 0 && !noFallback) return null;
|
|
58
|
+
|
|
59
|
+
return { placeholderForms, noFallback };
|
|
60
|
+
} catch {
|
|
61
|
+
// Never let a reminder break the CMS shell.
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { createClient, verifyPackageOnline } from '@nextblock-cms/db/server';
|
|
4
|
+
import { getStoreReadiness } from '@nextblock-cms/ecommerce/server';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A store that has commerce switched on but cannot actually take money. This is easy to
|
|
8
|
+
* end up in — the products, prices and pages all look finished — and the only symptom
|
|
9
|
+
* used to be a shopper hitting a payment error at checkout. The banner makes the gap
|
|
10
|
+
* visible in the CMS instead of on the storefront.
|
|
11
|
+
*/
|
|
12
|
+
export interface PaymentsReminder {
|
|
13
|
+
/** Providers that are not ready, with what each is missing. */
|
|
14
|
+
blocked: Array<{ provider: 'stripe' | 'freemius'; label: string; missing: string[] }>;
|
|
15
|
+
/** Published products that currently cannot be bought because of the above. */
|
|
16
|
+
affectedProducts: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Best-effort reminder state for the CMS layout. Returns null (no banner) whenever
|
|
21
|
+
* nagging would be wrong or the answer can't be determined — an unmigrated database,
|
|
22
|
+
* an instance without commerce, or the sandbox.
|
|
23
|
+
*/
|
|
24
|
+
export async function getPaymentsReminder(): Promise<PaymentsReminder | null> {
|
|
25
|
+
// The sandbox refuses to store live credentials at all (savePaymentCredentials throws),
|
|
26
|
+
// so a "finish setting up payments" nag there is noise the operator cannot act on.
|
|
27
|
+
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') return null;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
if (!(await verifyPackageOnline('ecommerce'))) return null;
|
|
31
|
+
|
|
32
|
+
const supabase = createClient();
|
|
33
|
+
|
|
34
|
+
// Only ADMINs can act on this (the /cms/payments route is admin-only), so don't do
|
|
35
|
+
// the work for anyone else.
|
|
36
|
+
const {
|
|
37
|
+
data: { user },
|
|
38
|
+
} = await supabase.auth.getUser();
|
|
39
|
+
if (!user) return null;
|
|
40
|
+
const { data: profile } = await supabase
|
|
41
|
+
.from('profiles')
|
|
42
|
+
.select('role')
|
|
43
|
+
.eq('id', user.id)
|
|
44
|
+
.maybeSingle();
|
|
45
|
+
if (profile?.role !== 'ADMIN') return null;
|
|
46
|
+
|
|
47
|
+
const readiness = await getStoreReadiness();
|
|
48
|
+
const unready = (['stripe', 'freemius'] as const)
|
|
49
|
+
.map((provider) => readiness[provider])
|
|
50
|
+
.filter((entry) => !entry.ready);
|
|
51
|
+
|
|
52
|
+
if (unready.length === 0) return null;
|
|
53
|
+
|
|
54
|
+
// A provider the store does not sell through is not a problem to be nagged about.
|
|
55
|
+
// Both providers ship DISABLED (baseline seed), so filtering on readiness alone
|
|
56
|
+
// would show a permanent "Freemius still needs setting up" banner to every
|
|
57
|
+
// physical-goods shop that had finished its Stripe setup correctly.
|
|
58
|
+
//
|
|
59
|
+
// "In use" means the admin switched the provider on, or the catalogue already
|
|
60
|
+
// contains products that would need it — counted across ALL statuses, so a shop
|
|
61
|
+
// building a draft catalogue is warned before it publishes rather than after.
|
|
62
|
+
const usage = await Promise.all(
|
|
63
|
+
unready.map(async (entry) => {
|
|
64
|
+
const [{ count: totalCount }, { count: activeCount }] = await Promise.all([
|
|
65
|
+
supabase
|
|
66
|
+
.from('products')
|
|
67
|
+
.select('id', { count: 'exact', head: true })
|
|
68
|
+
.eq('payment_provider', entry.provider),
|
|
69
|
+
supabase
|
|
70
|
+
.from('products')
|
|
71
|
+
.select('id', { count: 'exact', head: true })
|
|
72
|
+
.eq('status', 'active')
|
|
73
|
+
.eq('payment_provider', entry.provider),
|
|
74
|
+
]);
|
|
75
|
+
return {
|
|
76
|
+
entry,
|
|
77
|
+
inUse: entry.enabled || (totalCount ?? 0) > 0,
|
|
78
|
+
activeProducts: activeCount ?? 0,
|
|
79
|
+
};
|
|
80
|
+
})
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const relevant = usage.filter((item) => item.inUse);
|
|
84
|
+
if (relevant.length === 0) return null;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
blocked: relevant.map(({ entry }) => ({
|
|
88
|
+
provider: entry.provider,
|
|
89
|
+
label: entry.label,
|
|
90
|
+
missing: entry.missing,
|
|
91
|
+
})),
|
|
92
|
+
affectedProducts: relevant.reduce((total, item) => total + item.activeProducts, 0),
|
|
93
|
+
};
|
|
94
|
+
} catch {
|
|
95
|
+
// Never let a reminder break the CMS shell.
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { createClient, getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How many messages want attention, for the CMS nav badge.
|
|
7
|
+
*
|
|
8
|
+
* Best-effort in the same shape as the system-alerts loader: any failure — including
|
|
9
|
+
* the tables not existing yet on an install that has not run migration 27 — resolves to
|
|
10
|
+
* zero rather than breaking the whole CMS chrome.
|
|
11
|
+
*
|
|
12
|
+
* Note the two halves count different things. Threads have a real `unread_for_admin`
|
|
13
|
+
* flag. Interactions have no per-user read marker anywhere in the schema, so "pending
|
|
14
|
+
* moderation" stands in for it — which means two admins working the queue see the same
|
|
15
|
+
* number until one of them acts.
|
|
16
|
+
*/
|
|
17
|
+
export async function getUnreadMessageCount(isAdmin: boolean): Promise<number> {
|
|
18
|
+
try {
|
|
19
|
+
let total = 0;
|
|
20
|
+
|
|
21
|
+
if (isAdmin) {
|
|
22
|
+
const service = getServiceRoleSupabaseClient();
|
|
23
|
+
const { count } = await service
|
|
24
|
+
.from('message_threads')
|
|
25
|
+
.select('id', { count: 'exact', head: true })
|
|
26
|
+
.eq('unread_for_admin', true);
|
|
27
|
+
total += count ?? 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const supabase = createClient();
|
|
31
|
+
const { count: pending } = await supabase
|
|
32
|
+
.from('cms_interactions')
|
|
33
|
+
.select('id', { count: 'exact', head: true })
|
|
34
|
+
.eq('status', 'pending')
|
|
35
|
+
.is('parent_id', null);
|
|
36
|
+
total += pending ?? 0;
|
|
37
|
+
|
|
38
|
+
return total;
|
|
39
|
+
} catch {
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
|
|
4
|
+
|
|
5
|
+
import { isEmailConfigured } from '../config/email-settings';
|
|
6
|
+
import { isPlaceholderEmail } from '../email/placeholder-address';
|
|
7
|
+
import { getPrivacySettings } from '../privacy/settings';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where a storefront enquiry should be delivered.
|
|
11
|
+
*
|
|
12
|
+
* This address MUST NEVER reach the browser. The public enquiry form posts a product id
|
|
13
|
+
* and the visitor's own details; the server resolves the destination here. That is the
|
|
14
|
+
* whole point of the form — a `mailto:` to the shop owner would publish a working inbox
|
|
15
|
+
* address to every scraper that visits the catalogue.
|
|
16
|
+
*
|
|
17
|
+
* Resolution order, most specific first:
|
|
18
|
+
* 1. The explicit "seller contact email" an ADMIN set in CMS → Payments.
|
|
19
|
+
* 2. The invoice/merchant email — already the business address printed on invoices.
|
|
20
|
+
* 3. The privacy/support email from CMS Settings → Privacy (skipping the neutral
|
|
21
|
+
* @example.com placeholder that ships with a fresh install).
|
|
22
|
+
* 4. The oldest ADMIN account's login address, as a last resort so a store that
|
|
23
|
+
* configured nothing still reaches a human.
|
|
24
|
+
*
|
|
25
|
+
* The sandbox overrides everything: its database is wiped and re-seeded on a schedule,
|
|
26
|
+
* so any address stored there is dummy data by construction.
|
|
27
|
+
*/
|
|
28
|
+
export const STORE_CONTACT_SETTINGS_KEY = 'store_contact';
|
|
29
|
+
|
|
30
|
+
export interface SellerContactResolution {
|
|
31
|
+
email: string | null;
|
|
32
|
+
/** Which rung of the ladder answered — for CMS diagnostics, never shown publicly. */
|
|
33
|
+
source: 'sandbox' | 'store_contact' | 'invoice' | 'privacy' | 'first_admin' | 'none';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clean(value: unknown): string {
|
|
37
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* An address is only usable if it can actually receive mail. Seeded @example.com values
|
|
42
|
+
* look configured to every check downstream while delivering nowhere, so they are
|
|
43
|
+
* treated as unset and the ladder continues past them.
|
|
44
|
+
*/
|
|
45
|
+
function usable(value: string): boolean {
|
|
46
|
+
return value.length > 0 && !isPlaceholderEmail(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The ADMIN-set override, or '' when unset. Safe to render inside the CMS. */
|
|
50
|
+
export async function getStoreContactEmail(): Promise<string> {
|
|
51
|
+
try {
|
|
52
|
+
const supabase = getServiceRoleSupabaseClient();
|
|
53
|
+
const { data } = await supabase
|
|
54
|
+
.from('site_settings')
|
|
55
|
+
.select('value')
|
|
56
|
+
.eq('key', STORE_CONTACT_SETTINGS_KEY)
|
|
57
|
+
.maybeSingle();
|
|
58
|
+
const value = data?.value;
|
|
59
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
60
|
+
return clean((value as Record<string, unknown>)['contactEmail']);
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Unconfigured or unmigrated instance — fall through to the other sources.
|
|
64
|
+
}
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Only ever a handful of admins; bounded so a pathological role assignment can't fan out. */
|
|
69
|
+
const MAX_ADMIN_LOOKUPS = 10;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The founding ADMIN's login address. Kept last in the ladder because it mails a
|
|
73
|
+
* personal account rather than a business address.
|
|
74
|
+
*
|
|
75
|
+
* `public.profiles` carries neither an email nor a created_at column, so "oldest" has
|
|
76
|
+
* to come from auth: read the ADMIN ids, then resolve each one individually. That is
|
|
77
|
+
* deliberately not `auth.admin.listUsers()`, which would page through every user on the
|
|
78
|
+
* instance to answer a question about two or three of them.
|
|
79
|
+
*/
|
|
80
|
+
async function getFirstAdminEmail(): Promise<string> {
|
|
81
|
+
try {
|
|
82
|
+
const supabase = getServiceRoleSupabaseClient();
|
|
83
|
+
const { data: admins } = await supabase
|
|
84
|
+
.from('profiles')
|
|
85
|
+
.select('id')
|
|
86
|
+
.eq('role', 'ADMIN')
|
|
87
|
+
.limit(MAX_ADMIN_LOOKUPS);
|
|
88
|
+
|
|
89
|
+
if (!admins || admins.length === 0) return '';
|
|
90
|
+
|
|
91
|
+
const resolved = await Promise.all(
|
|
92
|
+
admins.map(async ({ id }) => {
|
|
93
|
+
const { data } = await supabase.auth.admin.getUserById(id);
|
|
94
|
+
const email = clean(data?.user?.email);
|
|
95
|
+
return email ? { email, createdAt: data?.user?.created_at ?? '' } : null;
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const candidates = resolved.filter(
|
|
100
|
+
(entry): entry is { email: string; createdAt: string } => entry !== null
|
|
101
|
+
);
|
|
102
|
+
if (candidates.length === 0) return '';
|
|
103
|
+
|
|
104
|
+
// Earliest signup wins, so the answer is stable as staff come and go.
|
|
105
|
+
candidates.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
106
|
+
return candidates[0].email;
|
|
107
|
+
} catch {
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function resolveSellerContactEmail(): Promise<SellerContactResolution> {
|
|
113
|
+
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
114
|
+
const sandbox = clean(process.env['SANDBOX_CONTACT_EMAIL']);
|
|
115
|
+
if (sandbox) return { email: sandbox, source: 'sandbox' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const configured = await getStoreContactEmail();
|
|
119
|
+
if (usable(configured)) return { email: configured, source: 'store_contact' };
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const supabase = getServiceRoleSupabaseClient();
|
|
123
|
+
const { data } = await supabase
|
|
124
|
+
.from('site_settings')
|
|
125
|
+
.select('value')
|
|
126
|
+
.eq('key', 'invoice_settings')
|
|
127
|
+
.maybeSingle();
|
|
128
|
+
const value = data?.value;
|
|
129
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
130
|
+
const invoiceEmail = clean((value as Record<string, unknown>)['email']);
|
|
131
|
+
if (usable(invoiceEmail)) return { email: invoiceEmail, source: 'invoice' };
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
// Fall through.
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const privacy = await getPrivacySettings();
|
|
139
|
+
const supportEmail = clean(privacy.corporate.support_email);
|
|
140
|
+
if (usable(supportEmail)) return { email: supportEmail, source: 'privacy' };
|
|
141
|
+
} catch {
|
|
142
|
+
// Fall through.
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const adminEmail = await getFirstAdminEmail();
|
|
146
|
+
if (usable(adminEmail)) return { email: adminEmail, source: 'first_admin' };
|
|
147
|
+
|
|
148
|
+
return { email: null, source: 'none' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether an enquiry sent right now would actually reach someone by email. Used by the
|
|
153
|
+
* CMS to warn an admin, and to decide whether a stored enquiry is the only record.
|
|
154
|
+
* Never exposes the address itself.
|
|
155
|
+
*/
|
|
156
|
+
export async function canNotifySeller(): Promise<boolean> {
|
|
157
|
+
const [{ email }, smtpReady] = await Promise.all([
|
|
158
|
+
resolveSellerContactEmail(),
|
|
159
|
+
isEmailConfigured().catch(() => false),
|
|
160
|
+
]);
|
|
161
|
+
return Boolean(email) && smtpReady;
|
|
162
|
+
}
|