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.
Files changed (71) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/ToasterProvider.tsx +26 -17
  3. package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -0
  4. package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -0
  5. package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -0
  6. package/templates/nextblock-template/app/actions/email.ts +241 -110
  7. package/templates/nextblock-template/app/actions/formActions.ts +245 -116
  8. package/templates/nextblock-template/app/actions/interactions.ts +489 -396
  9. package/templates/nextblock-template/app/actions/threadActions.ts +166 -0
  10. package/templates/nextblock-template/app/api/checkout/route.ts +162 -146
  11. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +664 -1
  12. package/templates/nextblock-template/app/checkout/page.tsx +57 -52
  13. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +552 -529
  14. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -181
  15. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -0
  16. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -0
  17. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -528
  18. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -0
  19. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -0
  20. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -51
  21. package/templates/nextblock-template/app/cms/layout.tsx +101 -73
  22. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -0
  23. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -0
  24. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -0
  25. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -0
  26. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -0
  27. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -362
  28. package/templates/nextblock-template/app/cms/revisions/service.ts +20 -0
  29. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -185
  30. package/templates/nextblock-template/app/layout.tsx +671 -671
  31. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -482
  32. package/templates/nextblock-template/app/providers.tsx +96 -96
  33. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -0
  34. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -0
  35. package/templates/nextblock-template/app/thread/layout.tsx +15 -0
  36. package/templates/nextblock-template/app/thread/page.tsx +98 -0
  37. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -296
  38. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -0
  39. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -369
  40. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -419
  41. package/templates/nextblock-template/components/StaffReplies.tsx +102 -0
  42. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -17
  43. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -19
  44. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -22
  45. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -381
  46. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -92
  47. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -31
  48. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -600
  49. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -0
  50. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -0
  51. package/templates/nextblock-template/docs/README.md +42 -41
  52. package/templates/nextblock-template/docs/assets/lighthouse-scores.png +0 -0
  53. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +22 -2
  54. package/templates/nextblock-template/lib/blocks/blockColors.ts +44 -1
  55. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -753
  56. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -0
  57. package/templates/nextblock-template/lib/cms/payments-reminder.ts +98 -0
  58. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -0
  59. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -0
  60. package/templates/nextblock-template/lib/config/email-settings.ts +323 -254
  61. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -0
  62. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -0
  63. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -0
  64. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -0
  65. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -0
  66. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -0
  67. package/templates/nextblock-template/lib/messages/threads.ts +579 -0
  68. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +20 -0
  69. package/templates/nextblock-template/lib/site-url.test.ts +89 -0
  70. package/templates/nextblock-template/lib/site-url.ts +102 -48
  71. 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
+ }