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,404 @@
1
+ 'use server';
2
+
3
+ import { revalidatePath } from 'next/cache';
4
+ import { createClient, getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
5
+
6
+ import { requireAdminSupabaseClient } from './require-admin';
7
+ import { ensureThreadToken, sendThreadNotice } from '../../../lib/messages/threads';
8
+ import { invalidateEmailConfigCache } from '../../../lib/config/email-settings';
9
+ import { resolveSiteUrl } from '../../../lib/site-url';
10
+
11
+ export interface MessageActionState {
12
+ success: boolean;
13
+ message: string;
14
+ }
15
+
16
+ const MAX_REPLY_LENGTH = 5000;
17
+
18
+ function failure(error: unknown, fallback: string): MessageActionState {
19
+ const message = error instanceof Error ? error.message : fallback;
20
+ return { success: false, message };
21
+ }
22
+
23
+ /**
24
+ * Answer a private conversation.
25
+ *
26
+ * This is the ONLY place a visitor thread token is minted. Deferring it to the first
27
+ * reply means a store that receives a hundred enquiries and answers three has three
28
+ * live credentials rather than a hundred — and an enquiry nobody ever answered never
29
+ * had one at all.
30
+ */
31
+ export async function replyToThread(
32
+ threadId: string,
33
+ body: string
34
+ ): Promise<MessageActionState> {
35
+ try {
36
+ const { userId, fullName } = await requireAdminSupabaseClient();
37
+
38
+ const reply = body.trim().slice(0, MAX_REPLY_LENGTH);
39
+ if (!reply) return { success: false, message: 'Write a reply before sending.' };
40
+
41
+ const service = getServiceRoleSupabaseClient();
42
+
43
+ const { data: thread } = await service
44
+ .from('message_threads')
45
+ .select('id, source, subject_label, sender_name, sender_email, status')
46
+ .eq('id', threadId)
47
+ .maybeSingle();
48
+
49
+ if (!thread) return { success: false, message: 'That conversation no longer exists.' };
50
+
51
+ const { data: message, error: messageError } = await service
52
+ .from('thread_messages')
53
+ .insert({
54
+ thread_id: threadId,
55
+ direction: 'outbound',
56
+ body: reply,
57
+ author_id: userId,
58
+ author_name: fullName,
59
+ })
60
+ .select('id')
61
+ .single();
62
+
63
+ if (messageError || !message) {
64
+ console.error('[messages] Reply insert failed:', messageError?.message);
65
+ return { success: false, message: 'Could not save your reply.' };
66
+ }
67
+
68
+ const now = new Date().toISOString();
69
+ await service
70
+ .from('message_threads')
71
+ .update({
72
+ last_message_at: now,
73
+ unread_for_admin: false,
74
+ unread_for_visitor: true,
75
+ // Answering reopens a closed conversation — otherwise the visitor could reply
76
+ // to a thread the CMS still shows as done.
77
+ status: 'open',
78
+ })
79
+ .eq('id', threadId);
80
+
81
+ const { token, hasExistingToken } = await ensureThreadToken(threadId);
82
+
83
+ // AWAITED, not deferred to after().
84
+ //
85
+ // The public paths defer their notifications because a visitor should not wait on an
86
+ // SMTP round trip. An admin reply is the opposite: someone clicked Send and is owed a
87
+ // truthful answer. Deferring it also raced the page refresh — the row is written with
88
+ // email_delivered at its default of false, so the UI re-rendered before the send
89
+ // finished and reported "Not delivered" for a reply that was about to go out fine.
90
+ await sendThreadNotice({
91
+ threadId,
92
+ source: thread.source as 'product_inquiry' | 'contact_form',
93
+ messageId: message.id,
94
+ subjectLabel: thread.subject_label,
95
+ senderName: thread.sender_name,
96
+ senderEmail: thread.sender_email,
97
+ body: reply,
98
+ token,
99
+ hasExistingToken,
100
+ });
101
+
102
+ const { data: delivery } = await service
103
+ .from('thread_messages')
104
+ .select('email_delivered, email_error')
105
+ .eq('id', message.id)
106
+ .maybeSingle();
107
+
108
+ revalidatePath('/cms/messages');
109
+
110
+ if (!thread.sender_email) {
111
+ return {
112
+ success: true,
113
+ message: 'Reply saved. This sender left no email address, so they will not be notified.',
114
+ };
115
+ }
116
+
117
+ return delivery?.email_delivered
118
+ ? { success: true, message: 'Reply sent.' }
119
+ : {
120
+ // The reply itself is safely stored; only delivery failed. Say exactly that,
121
+ // rather than "sent" over a failure or "failed" over a saved reply.
122
+ success: false,
123
+ message: `Reply saved, but it could not be delivered. ${delivery?.email_error ?? ''}`.trim(),
124
+ };
125
+ } catch (error) {
126
+ return failure(error, 'Could not send that reply.');
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Try again to deliver a reply that was written but never sent.
132
+ *
133
+ * The whole design treats the stored row as the record and delivery as best-effort, so
134
+ * an SMTP outage or a misconfiguration leaves real replies sitting unsent. Without this
135
+ * the only way to deliver one is to retype it as a new reply, which also re-notifies the
136
+ * visitor about a message they may already have.
137
+ *
138
+ * Drops the SMTP config cache first: the usual reason a retry is being attempted is that
139
+ * the settings were just corrected, and a cached transport would fail the same way.
140
+ */
141
+ export async function retryThreadDelivery(messageId: string): Promise<MessageActionState> {
142
+ try {
143
+ await requireAdminSupabaseClient();
144
+ const service = getServiceRoleSupabaseClient();
145
+
146
+ const { data: message } = await service
147
+ .from('thread_messages')
148
+ .select('id, thread_id, body, direction, email_delivered')
149
+ .eq('id', messageId)
150
+ .maybeSingle();
151
+
152
+ if (!message) return { success: false, message: 'That reply no longer exists.' };
153
+ if (message.direction !== 'outbound') {
154
+ return { success: false, message: 'Only replies you sent can be re-delivered.' };
155
+ }
156
+ if (message.email_delivered) {
157
+ return { success: false, message: 'That reply was already delivered.' };
158
+ }
159
+
160
+ const { data: thread } = await service
161
+ .from('message_threads')
162
+ .select('id, source, subject_label, sender_name, sender_email')
163
+ .eq('id', message.thread_id)
164
+ .maybeSingle();
165
+
166
+ if (!thread) return { success: false, message: 'That conversation no longer exists.' };
167
+ if (!thread.sender_email) {
168
+ return { success: false, message: 'This sender left no email address, so there is nowhere to deliver it.' };
169
+ }
170
+
171
+ invalidateEmailConfigCache();
172
+
173
+ const { token, hasExistingToken } = await ensureThreadToken(thread.id);
174
+
175
+ // Awaited rather than deferred: the admin clicked a button and is waiting to learn
176
+ // whether it worked this time.
177
+ await sendThreadNotice({
178
+ threadId: thread.id,
179
+ source: thread.source as 'product_inquiry' | 'contact_form',
180
+ messageId: message.id,
181
+ subjectLabel: thread.subject_label,
182
+ senderName: thread.sender_name,
183
+ senderEmail: thread.sender_email,
184
+ body: message.body,
185
+ token,
186
+ hasExistingToken,
187
+ });
188
+
189
+ const { data: after } = await service
190
+ .from('thread_messages')
191
+ .select('email_delivered, email_error')
192
+ .eq('id', message.id)
193
+ .maybeSingle();
194
+
195
+ revalidatePath('/cms/messages');
196
+ return after?.email_delivered
197
+ ? { success: true, message: 'Delivered.' }
198
+ : { success: false, message: after?.email_error || 'Still could not deliver that reply.' };
199
+ } catch (error) {
200
+ return failure(error, 'Could not re-send that reply.');
201
+ }
202
+ }
203
+
204
+ /** Open or close a conversation. Mirrors the flag onto the originating enquiry row. */
205
+ export async function setThreadStatus(
206
+ threadId: string,
207
+ status: 'open' | 'closed'
208
+ ): Promise<MessageActionState> {
209
+ try {
210
+ await requireAdminSupabaseClient();
211
+ const service = getServiceRoleSupabaseClient();
212
+
213
+ const { data: thread } = await service
214
+ .from('message_threads')
215
+ .select('source, subject_id')
216
+ .eq('id', threadId)
217
+ .maybeSingle();
218
+
219
+ const { error } = await service
220
+ .from('message_threads')
221
+ .update({ status, unread_for_admin: false })
222
+ .eq('id', threadId);
223
+
224
+ if (error) return { success: false, message: 'Could not update that conversation.' };
225
+
226
+ // Keep the enquiry list truthful — it has its own is_resolved flag.
227
+ if (thread?.source === 'product_inquiry' && thread.subject_id) {
228
+ await service
229
+ .from('product_inquiries')
230
+ .update({ is_resolved: status === 'closed' })
231
+ .eq('id', thread.subject_id);
232
+ }
233
+
234
+ revalidatePath('/cms/messages');
235
+ return { success: true, message: status === 'closed' ? 'Marked as handled.' : 'Reopened.' };
236
+ } catch (error) {
237
+ return failure(error, 'Could not update that conversation.');
238
+ }
239
+ }
240
+
241
+ export async function markThreadRead(threadId: string): Promise<MessageActionState> {
242
+ try {
243
+ await requireAdminSupabaseClient();
244
+ const service = getServiceRoleSupabaseClient();
245
+ await service.from('message_threads').update({ unread_for_admin: false }).eq('id', threadId);
246
+ revalidatePath('/cms/messages');
247
+ return { success: true, message: '' };
248
+ } catch (error) {
249
+ return failure(error, 'Could not update that conversation.');
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Kill a visitor's link without destroying the conversation.
255
+ *
256
+ * The row is kept deliberately: its hash stays in the unique index, so a revoked token
257
+ * can never be re-minted onto a different thread, and the history survives. Thread rows
258
+ * cannot be deleted at all — `thread_messages` is append-only and the cascade would
259
+ * trip its trigger — so "delete" in this UI means closed plus revoked.
260
+ */
261
+ export async function revokeThreadLink(threadId: string): Promise<MessageActionState> {
262
+ try {
263
+ await requireAdminSupabaseClient();
264
+ const service = getServiceRoleSupabaseClient();
265
+ const { error } = await service
266
+ .from('message_threads')
267
+ .update({ token_revoked_at: new Date().toISOString() })
268
+ .eq('id', threadId);
269
+
270
+ if (error) return { success: false, message: 'Could not revoke that link.' };
271
+
272
+ revalidatePath('/cms/messages');
273
+ return {
274
+ success: true,
275
+ message: 'Link revoked. The next reply you send will issue a fresh one.',
276
+ };
277
+ } catch (error) {
278
+ return failure(error, 'Could not revoke that link.');
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Mint a fresh visitor link and hand it back to the admin, without sending anything.
284
+ *
285
+ * The notification email is best-effort by design, and a recipient's own filtering can
286
+ * remove a message after it has been accepted by every hop — Microsoft's ZAP does exactly
287
+ * that, silently and with no bounce. When that happens the conversation is not broken,
288
+ * only the delivery is: the thread, the reply and a valid link all still exist.
289
+ *
290
+ * This is the manual channel. The admin copies the link and sends it however actually
291
+ * works — their own mail client, a chat message, over the phone. Same rotation rule as a
292
+ * reply: minting a new one retires the previous link.
293
+ */
294
+ export async function createVisitorLink(
295
+ threadId: string
296
+ ): Promise<MessageActionState & { url?: string }> {
297
+ try {
298
+ await requireAdminSupabaseClient();
299
+
300
+ const { token } = await ensureThreadToken(threadId);
301
+ if (!token) {
302
+ return { success: false, message: 'Could not create a link for that conversation.' };
303
+ }
304
+
305
+ revalidatePath('/cms/messages');
306
+ return {
307
+ success: true,
308
+ message: 'Link copied. It replaces any link sent earlier.',
309
+ url: `${resolveSiteUrl()}/thread/${token}`,
310
+ };
311
+ } catch (error) {
312
+ return failure(error, 'Could not create a link for that conversation.');
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Save where a contact form's submissions go, plus the server-trusted field manifest.
318
+ * Called from the form block editor — the address lives here, never in block content.
319
+ */
320
+ export async function saveFormEndpoint(
321
+ formKey: string,
322
+ label: string,
323
+ recipientEmail: string,
324
+ fields: Array<{ temp_id?: string; label?: string; field_type?: string }>
325
+ ): Promise<MessageActionState> {
326
+ try {
327
+ await requireAdminSupabaseClient();
328
+
329
+ const email = recipientEmail.trim();
330
+ if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
331
+ return { success: false, message: "That doesn't look like a valid email address." };
332
+ }
333
+
334
+ const service = getServiceRoleSupabaseClient();
335
+ const { error } = await service.from('form_endpoints').upsert({
336
+ form_key: formKey,
337
+ label: label.trim() || 'Contact form',
338
+ recipient_email: email || null,
339
+ fields: fields.map((field) => ({
340
+ temp_id: field.temp_id,
341
+ label: field.label,
342
+ field_type: field.field_type,
343
+ })),
344
+ });
345
+
346
+ if (error) {
347
+ console.error('[messages] Could not save form endpoint:', error.message);
348
+ return { success: false, message: 'Could not save the form settings.' };
349
+ }
350
+
351
+ return {
352
+ success: true,
353
+ message: email
354
+ ? 'Saved. Submissions will be emailed there and stored in Messages.'
355
+ : 'Saved. Submissions will use your site contact address and are stored in Messages.',
356
+ };
357
+ } catch (error) {
358
+ return failure(error, 'Could not save the form settings.');
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Current endpoint settings for the block editor.
364
+ *
365
+ * Readable by WRITERs as well as ADMINs, matching form_endpoints_editor_read_policy —
366
+ * WRITERs can edit pages and therefore open this block, and showing them an empty field
367
+ * would read as "no address configured" rather than "you may not change this". Writing
368
+ * stays ADMIN-only; `canEdit` tells the UI which to render.
369
+ */
370
+ export async function getFormEndpointForEditor(
371
+ formKey: string
372
+ ): Promise<{ label: string; recipientEmail: string; canEdit: boolean } | null> {
373
+ try {
374
+ const supabase = createClient();
375
+ const {
376
+ data: { user },
377
+ } = await supabase.auth.getUser();
378
+ if (!user) return null;
379
+
380
+ const { data: profile } = await supabase
381
+ .from('profiles')
382
+ .select('role')
383
+ .eq('id', user.id)
384
+ .maybeSingle();
385
+ if (profile?.role !== 'ADMIN' && profile?.role !== 'WRITER') return null;
386
+
387
+ const service = getServiceRoleSupabaseClient();
388
+ const { data } = await service
389
+ .from('form_endpoints')
390
+ .select('label, recipient_email')
391
+ .eq('form_key', formKey)
392
+ .maybeSingle();
393
+
394
+ const canEdit = profile.role === 'ADMIN';
395
+ if (!data) return { label: '', recipientEmail: '', canEdit };
396
+ return {
397
+ label: data.label ?? '',
398
+ recipientEmail: data.recipient_email ?? '',
399
+ canEdit,
400
+ };
401
+ } catch {
402
+ return null;
403
+ }
404
+ }