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
@@ -1,110 +1,241 @@
1
- import 'server-only';
2
- // The single outbound-mail choke point. Deliberately NOT a "use server" module: every
3
- // caller is server-side (2FA codes, form/interaction notifications, feedback, the SMTP
4
- // test), and marking it as an action would register `sendEmail` as a client-callable
5
- // endpoint — an open relay taking an arbitrary recipient, subject and HTML body.
6
-
7
- import nodemailer, { type Transporter } from 'nodemailer';
8
- import { resolveEmailServerConfig, type ResolvedEmailConfig } from '../../lib/config/email-settings';
9
- import { applyEmailBranding, resolveEmailBranding } from '../../lib/email/branding';
10
-
11
- interface EmailParams {
12
- to: string;
13
- subject: string;
14
- text: string;
15
- html: string;
16
- }
17
-
18
- // Without explicit bounds nodemailer inherits the OS socket timeouts, so an unreachable
19
- // or silently-dropping relay hangs the request (and the user's spinner) for minutes.
20
- const CONNECTION_TIMEOUT_MS = 10_000;
21
- const GREETING_TIMEOUT_MS = 10_000;
22
- const SOCKET_TIMEOUT_MS = 20_000;
23
-
24
- // Opening a fresh SMTP connection per message costs a TCP handshake + TLS negotiation +
25
- // AUTH round trip before the first byte of the message is sent — typically the bulk of the
26
- // wait when a user clicks "send me a code". A pooled transport keeps the authenticated
27
- // connection warm so subsequent sends start at DATA. Set SMTP_POOL=false to opt out.
28
- const POOL_ENABLED = process.env['SMTP_POOL'] !== 'false';
29
-
30
- let cachedTransport: { key: string; transporter: Transporter } | null = null;
31
-
32
- /** Identity of a transport: anything that changes it must force a rebuild. */
33
- function transportKey(config: ResolvedEmailConfig): string {
34
- return [config.host, config.port, config.secure, config.auth.user].join('|');
35
- }
36
-
37
- function getTransporter(config: ResolvedEmailConfig): Transporter {
38
- const key = transportKey(config);
39
- if (cachedTransport && cachedTransport.key === key) {
40
- return cachedTransport.transporter;
41
- }
42
-
43
- // Settings changed — tear the old pool down rather than leaking its sockets.
44
- if (cachedTransport) {
45
- try {
46
- cachedTransport.transporter.close();
47
- } catch {
48
- /* already closed */
49
- }
50
- cachedTransport = null;
51
- }
52
-
53
- const base = {
54
- host: config.host,
55
- port: config.port,
56
- secure: config.secure,
57
- auth: config.auth,
58
- connectionTimeout: CONNECTION_TIMEOUT_MS,
59
- greetingTimeout: GREETING_TIMEOUT_MS,
60
- socketTimeout: SOCKET_TIMEOUT_MS,
61
- };
62
-
63
- // Branched rather than `pool: POOL_ENABLED` — nodemailer types `pool` as the literal
64
- // `true` to select the pooled transport overload, so a boolean matches neither.
65
- const transporter: Transporter = POOL_ENABLED
66
- ? nodemailer.createTransport({ ...base, pool: true, maxConnections: 3, maxMessages: 100 })
67
- : nodemailer.createTransport(base);
68
-
69
- // A pool that errors out (relay restart, credentials rotated, idle socket reaped) must
70
- // not be handed to the next caller drop it so the following send reconnects cleanly.
71
- transporter.on('error', () => {
72
- if (cachedTransport?.transporter === transporter) {
73
- cachedTransport = null;
74
- }
75
- });
76
-
77
- cachedTransport = { key, transporter };
78
- return transporter;
79
- }
80
-
81
- export async function sendEmail({ to, subject, text, html }: EmailParams) {
82
- // DB-first (CMS Settings Configuration Email), falling back to SMTP_* env vars.
83
- // Resolved in parallel with branding — neither depends on the other, and both are
84
- // pure reads standing between the click and the first SMTP packet.
85
- const [emailConfig, branding] = await Promise.all([
86
- resolveEmailServerConfig(),
87
- resolveEmailBranding(),
88
- ]);
89
-
90
- if (!emailConfig) {
91
- throw new Error("Email server is not configured. Configure SMTP in CMS Settings → Configuration → Email.");
92
- }
93
-
94
- // Single interception point: white-label every outgoing email with the tenant's own
95
- // logo + site name (or a text banner when no logo is set). Every app-dispatched email
96
- // funnels through here, so branding is applied once, centrally.
97
- const brandedHtml = applyEmailBranding(html, branding);
98
-
99
- const transporter = getTransporter(emailConfig);
100
-
101
- const options = {
102
- from: emailConfig.from,
103
- to,
104
- subject,
105
- text,
106
- html: brandedHtml,
107
- };
108
-
109
- return transporter.sendMail(options);
110
- }
1
+ import 'server-only';
2
+ // The single outbound-mail choke point. Deliberately NOT a "use server" module: every
3
+ // caller is server-side (2FA codes, form/interaction notifications, feedback, the SMTP
4
+ // test), and marking it as an action would register `sendEmail` as a client-callable
5
+ // endpoint — an open relay taking an arbitrary recipient, subject and HTML body.
6
+
7
+ import nodemailer, { type Transporter } from 'nodemailer';
8
+ import { resolveEmailServerConfig, type ResolvedEmailConfig } from '../../lib/config/email-settings';
9
+ import { applyEmailBranding, resolveEmailBranding } from '../../lib/email/branding';
10
+
11
+ interface EmailParams {
12
+ to: string;
13
+ subject: string;
14
+ text: string;
15
+ html: string;
16
+ /**
17
+ * Where a reply should go, when that is not the SMTP identity. Set only from a
18
+ * server-validated address it lands in a mail header, so an unvalidated value
19
+ * would be a header-injection vector.
20
+ */
21
+ replyTo?: string;
22
+ /** Stable id for this message, so later mail can reference it. */
23
+ messageId?: string;
24
+ /** Thread root this message answers. Makes a "Re:" subject legitimate. */
25
+ inReplyTo?: string;
26
+ /** Full reference chain; mail clients thread on this. */
27
+ references?: string;
28
+ }
29
+
30
+ // Without explicit bounds nodemailer inherits the OS socket timeouts, so an unreachable
31
+ // or silently-dropping relay hangs the request (and the user's spinner) for minutes.
32
+ const CONNECTION_TIMEOUT_MS = 10_000;
33
+ const GREETING_TIMEOUT_MS = 10_000;
34
+ const SOCKET_TIMEOUT_MS = 20_000;
35
+
36
+ // Opening a fresh SMTP connection per message costs a TCP handshake + TLS negotiation +
37
+ // AUTH round trip before the first byte of the message is sent — typically the bulk of the
38
+ // wait when a user clicks "send me a code". A pooled transport keeps the authenticated
39
+ // connection warm so subsequent sends start at DATA. Set SMTP_POOL=false to opt out.
40
+ const POOL_ENABLED = process.env['SMTP_POOL'] !== 'false';
41
+
42
+ let cachedTransport: { key: string; transporter: Transporter } | null = null;
43
+
44
+ /** Identity of a transport: anything that changes it must force a rebuild. */
45
+ function transportKey(config: ResolvedEmailConfig): string {
46
+ return [config.host, config.port, config.secure, config.requireTLS ?? false, config.auth.user].join('|');
47
+ }
48
+
49
+ function getTransporter(config: ResolvedEmailConfig): Transporter {
50
+ const key = transportKey(config);
51
+ if (cachedTransport && cachedTransport.key === key) {
52
+ return cachedTransport.transporter;
53
+ }
54
+
55
+ // Settings changed — tear the old pool down rather than leaking its sockets.
56
+ if (cachedTransport) {
57
+ try {
58
+ cachedTransport.transporter.close();
59
+ } catch {
60
+ /* already closed */
61
+ }
62
+ cachedTransport = null;
63
+ }
64
+
65
+ const base = {
66
+ host: config.host,
67
+ port: config.port,
68
+ secure: config.secure,
69
+ // Set on STARTTLS ports against a remote relay: without it nodemailer would fall
70
+ // back to plaintext if the server stopped advertising STARTTLS, quietly shipping
71
+ // credentials in the clear.
72
+ ...(config.requireTLS ? { requireTLS: true } : {}),
73
+ auth: config.auth,
74
+ connectionTimeout: CONNECTION_TIMEOUT_MS,
75
+ greetingTimeout: GREETING_TIMEOUT_MS,
76
+ socketTimeout: SOCKET_TIMEOUT_MS,
77
+ };
78
+
79
+ // Branched rather than `pool: POOL_ENABLED` — nodemailer types `pool` as the literal
80
+ // `true` to select the pooled transport overload, so a boolean matches neither.
81
+ const transporter: Transporter = POOL_ENABLED
82
+ ? nodemailer.createTransport({ ...base, pool: true, maxConnections: 3, maxMessages: 100 })
83
+ : nodemailer.createTransport(base);
84
+
85
+ // A pool that errors out (relay restart, credentials rotated, idle socket reaped) must
86
+ // not be handed to the next caller — drop it so the following send reconnects cleanly.
87
+ transporter.on('error', () => {
88
+ if (cachedTransport?.transporter === transporter) {
89
+ cachedTransport = null;
90
+ }
91
+ });
92
+
93
+ cachedTransport = { key, transporter };
94
+ return transporter;
95
+ }
96
+
97
+ /** Any value bound for a mail header: a newline here would inject extra headers. */
98
+ function header(value: string): string {
99
+ return value.replace(/[\r\n]+/g, ' ').trim();
100
+ }
101
+
102
+ /**
103
+ * Domain of the configured From address, used to mint RFC-compliant Message-IDs.
104
+ * Falls back to a neutral literal rather than inventing a domain we do not control.
105
+ */
106
+ export async function resolveFromDomain(): Promise<string> {
107
+ const config = await resolveEmailServerConfig({ silent: true });
108
+ const match = config?.from?.match(/@([^>\s]+)/);
109
+ return match?.[1]?.trim() || 'localhost';
110
+ }
111
+
112
+ /**
113
+ * Turn a transport failure into something the person reading it can act on.
114
+ *
115
+ * The raw text is written for whoever wrote OpenSSL, not for a shop owner: an admin who
116
+ * sees "ssl3_get_record:wrong version number" beside their reply learns nothing, and the
117
+ * actual cause — a TLS mode that does not match the port — is never mentioned. The
118
+ * original is still logged server-side; this is what gets stored and shown.
119
+ */
120
+ export function describeSmtpError(error: unknown): string {
121
+ const raw = error instanceof Error ? error.message : String(error);
122
+ const code = (error as { code?: string } | null)?.code ?? '';
123
+
124
+ if (/wrong version number/i.test(raw)) {
125
+ return 'The mail server refused the TLS handshake. This almost always means the TLS setting does not match the port: ports 25, 587 and 2525 need TLS OFF (STARTTLS), port 465 needs it ON. Check CMS Settings → Email.';
126
+ }
127
+ if (/ssl|tls|certificate/i.test(raw) && /alert|handshake|self.signed|unable to verify/i.test(raw)) {
128
+ return `The mail server's TLS certificate could not be verified (${raw.slice(0, 120)}). Check the host name matches the certificate.`;
129
+ }
130
+ if (code === 'EAUTH' || /invalid login|authentication failed|535/i.test(raw)) {
131
+ return 'The mail server rejected the username or password. Re-enter the SMTP credentials in CMS Settings → Email.';
132
+ }
133
+ if (code === 'ENOTFOUND' || /getaddrinfo/i.test(raw)) {
134
+ return 'The mail server hostname could not be resolved. Check the SMTP host in CMS Settings → Email.';
135
+ }
136
+ if (code === 'ECONNREFUSED') {
137
+ return 'The mail server refused the connection. Check the SMTP host and port in CMS Settings → Email.';
138
+ }
139
+ if (code === 'ETIMEDOUT' || code === 'ESOCKET' || /timeout/i.test(raw)) {
140
+ return 'The mail server did not respond in time. It may be unreachable from this host, or the port may be blocked.';
141
+ }
142
+ if (/not configured/i.test(raw)) {
143
+ return 'Email sending is not configured yet. Add SMTP details in CMS Settings → Email.';
144
+ }
145
+ return raw;
146
+ }
147
+
148
+ export async function sendEmail({
149
+ to,
150
+ subject,
151
+ text,
152
+ html,
153
+ replyTo,
154
+ messageId,
155
+ inReplyTo,
156
+ references,
157
+ }: EmailParams) {
158
+ // DB-first (CMS Settings → Configuration → Email), falling back to SMTP_* env vars.
159
+ // Resolved in parallel with branding — neither depends on the other, and both are
160
+ // pure reads standing between the click and the first SMTP packet.
161
+ const [emailConfig, branding] = await Promise.all([
162
+ resolveEmailServerConfig(),
163
+ resolveEmailBranding(),
164
+ ]);
165
+
166
+ if (!emailConfig) {
167
+ throw new Error("Email server is not configured. Configure SMTP in CMS Settings → Configuration → Email.");
168
+ }
169
+
170
+ // Single interception point: white-label every outgoing email with the tenant's own
171
+ // logo + site name (or a text banner when no logo is set). Every app-dispatched email
172
+ // funnels through here, so branding is applied once, centrally.
173
+ const brandedHtml = applyEmailBranding(html, branding);
174
+
175
+ const transporter = getTransporter(emailConfig);
176
+
177
+ const options = {
178
+ from: emailConfig.from,
179
+ to,
180
+ subject,
181
+ text,
182
+ html: brandedHtml,
183
+ // Stripped of CR/LF defensively: these values end up in mail headers.
184
+ ...(replyTo ? { replyTo: header(replyTo) } : {}),
185
+ ...(messageId ? { messageId: header(messageId) } : {}),
186
+ ...(inReplyTo ? { inReplyTo: header(inReplyTo) } : {}),
187
+ ...(references ? { references: header(references) } : {}),
188
+ };
189
+
190
+ try {
191
+ return await transporter.sendMail(options);
192
+ } catch (error) {
193
+ if (!isTransientTransportError(error)) throw error;
194
+
195
+ // A pooled connection that the relay reaped while it sat idle looks alive to
196
+ // nodemailer until the send hangs and the socket times out. That is exactly what a
197
+ // long-running dev server or a quiet production instance produces: everything
198
+ // verifies, one send fails, the next works. Drop the pool and try once more on a
199
+ // fresh single-use connection before reporting failure.
200
+ console.warn('[email] Transient transport failure; retrying once on a fresh connection.', error);
201
+ try {
202
+ cachedTransport?.transporter.close();
203
+ } catch {
204
+ /* already closed */
205
+ }
206
+ cachedTransport = null;
207
+
208
+ const retryTransport = nodemailer.createTransport({
209
+ host: emailConfig.host,
210
+ port: emailConfig.port,
211
+ secure: emailConfig.secure,
212
+ ...(emailConfig.requireTLS ? { requireTLS: true } : {}),
213
+ auth: emailConfig.auth,
214
+ connectionTimeout: CONNECTION_TIMEOUT_MS,
215
+ greetingTimeout: GREETING_TIMEOUT_MS,
216
+ socketTimeout: SOCKET_TIMEOUT_MS,
217
+ });
218
+
219
+ try {
220
+ return await retryTransport.sendMail(options);
221
+ } finally {
222
+ retryTransport.close();
223
+ }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Whether a failure is worth one more attempt.
229
+ *
230
+ * Only connection-level faults qualify. Retrying a rejected password or a TLS mismatch
231
+ * just fails again a second time and delays the real answer.
232
+ */
233
+ function isTransientTransportError(error: unknown): boolean {
234
+ const code = (error as { code?: string } | null)?.code ?? '';
235
+ const message = error instanceof Error ? error.message : String(error);
236
+
237
+ return (
238
+ ['ETIMEDOUT', 'ESOCKET', 'ECONNRESET', 'EPIPE', 'ECONNECTION', 'EAI_AGAIN'].includes(code) ||
239
+ /timeout|socket close|connection closed|read ECONNRESET/i.test(message)
240
+ );
241
+ }