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,166 @@
1
+ 'use server';
2
+
3
+ import { headers } from 'next/headers';
4
+ import { after } from 'next/server';
5
+ import { revalidatePath } from 'next/cache';
6
+ import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
7
+
8
+ import { getCookieValue } from '../../lib/auth/cookies';
9
+ import { THREAD_COOKIE, verifyThreadToken } from '../../lib/messages/thread-token';
10
+ import {
11
+ getFormEndpoint,
12
+ notifyAdminOfMessage,
13
+ resolveFormRecipient,
14
+ } from '../../lib/messages/threads';
15
+ import { resolveSellerContactEmail } from '../../lib/commerce/seller-contact';
16
+ import { verifyBotProtection } from '../../lib/botProtection/verify';
17
+
18
+ /**
19
+ * A visitor replying on their own thread page.
20
+ *
21
+ * The token is read from the HttpOnly COOKIE, never from a form field — so it is never
22
+ * in a POST body, never in a client-side variable, and cannot be swapped by editing the
23
+ * request. Everything else here mirrors the enquiry action: bot protection first, a
24
+ * fail-closed per-IP throttle, then a write.
25
+ */
26
+
27
+ const MAX_BODY_LENGTH = 5000;
28
+ const MAX_USER_AGENT_LENGTH = 500;
29
+ const THROTTLE_WINDOW_MINUTES = 10;
30
+ const THROTTLE_MAX_REPLIES = 10;
31
+ const UNKNOWN_IP_BUCKET = 'unknown';
32
+
33
+ export interface ThreadReplyState {
34
+ success: boolean;
35
+ messageKey: 'thread.sent' | 'thread.error' | 'thread.throttled' | 'thread.closed' | '';
36
+ message?: string;
37
+ }
38
+
39
+ function firstHop(headerValue: string | null): string | null {
40
+ const [first] = (headerValue ?? '').split(',');
41
+ const trimmed = first?.trim() ?? '';
42
+ return trimmed || null;
43
+ }
44
+
45
+ function maskIp(ip: string | null): string {
46
+ if (!ip) return UNKNOWN_IP_BUCKET;
47
+ if (ip.includes(':')) {
48
+ const hextets = ip.split(':').filter(Boolean);
49
+ return `${hextets.slice(0, 3).join(':')}::x`;
50
+ }
51
+ const octets = ip.split('.');
52
+ if (octets.length === 4) {
53
+ octets[3] = 'x';
54
+ return octets.join('.');
55
+ }
56
+ return UNKNOWN_IP_BUCKET;
57
+ }
58
+
59
+ export async function submitThreadReply(
60
+ _prevState: unknown,
61
+ formData: FormData
62
+ ): Promise<ThreadReplyState> {
63
+ const verification = await verifyBotProtection(formData);
64
+ if (!verification.ok) {
65
+ if (verification.reason === 'honeypot') {
66
+ return { success: true, messageKey: 'thread.sent' };
67
+ }
68
+ return { success: false, messageKey: '', message: verification.message };
69
+ }
70
+
71
+ const rawBody = formData.get('body');
72
+ const body = typeof rawBody === 'string' ? rawBody.trim().slice(0, MAX_BODY_LENGTH) : '';
73
+ if (!body) return { success: false, messageKey: 'thread.error' };
74
+
75
+ try {
76
+ const supabase = getServiceRoleSupabaseClient();
77
+
78
+ const token = await getCookieValue(THREAD_COOKIE);
79
+ const check = await verifyThreadToken(supabase, token);
80
+ if (!check.valid) return { success: false, messageKey: 'thread.error' };
81
+
82
+ const thread = check.thread;
83
+ if (thread.status === 'closed') return { success: false, messageKey: 'thread.closed' };
84
+
85
+ const requestHeaders = await headers();
86
+ const ipMasked = maskIp(
87
+ firstHop(requestHeaders.get('x-vercel-forwarded-for')) ??
88
+ firstHop(requestHeaders.get('x-real-ip')) ??
89
+ firstHop(requestHeaders.get('x-forwarded-for'))
90
+ );
91
+ const userAgent = requestHeaders.get('user-agent');
92
+
93
+ const since = new Date(Date.now() - THROTTLE_WINDOW_MINUTES * 60_000).toISOString();
94
+ const { count } = await supabase
95
+ .from('thread_messages')
96
+ .select('id', { count: 'exact', head: true })
97
+ .eq('ip_masked', ipMasked)
98
+ .gte('created_at', since);
99
+ if ((count ?? 0) >= THROTTLE_MAX_REPLIES) {
100
+ return { success: false, messageKey: 'thread.throttled' };
101
+ }
102
+
103
+ const { data: message, error } = await supabase
104
+ .from('thread_messages')
105
+ .insert({
106
+ thread_id: thread.id,
107
+ direction: 'inbound',
108
+ body,
109
+ author_name: thread.sender_name,
110
+ ip_masked: ipMasked,
111
+ })
112
+ .select('id')
113
+ .single();
114
+
115
+ if (error || !message) {
116
+ console.error('[thread] Could not store a visitor reply:', error?.message);
117
+ return { success: false, messageKey: 'thread.error' };
118
+ }
119
+
120
+ await supabase
121
+ .from('message_threads')
122
+ .update({
123
+ last_message_at: new Date().toISOString(),
124
+ unread_for_admin: true,
125
+ unread_for_visitor: false,
126
+ // A visitor writing back REOPENS the conversation. Marking it handled was a
127
+ // statement about the last exchange, not a permanent verdict — leaving it closed
128
+ // would drop their reply out of the inbox's default view, which is precisely
129
+ // where someone waiting on an answer must not go.
130
+ status: 'open',
131
+ user_agent: userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null,
132
+ })
133
+ .eq('id', thread.id);
134
+
135
+ after(async () => {
136
+ // Route the notice the same way the original submission was routed, so a
137
+ // contact-form conversation keeps reaching that form's address.
138
+ const recipient =
139
+ thread.source === 'contact_form'
140
+ ? await resolveFormRecipient(
141
+ // Load the ORIGINATING form's endpoint. Passing null here would skip the
142
+ // per-form address entirely and quietly deliver a careers-form follow-up
143
+ // to the general mailbox.
144
+ thread.form_key ? await getFormEndpoint(thread.form_key) : null
145
+ )
146
+ : (await resolveSellerContactEmail()).email;
147
+
148
+ await notifyAdminOfMessage({
149
+ threadId: thread.id,
150
+ source: thread.source as 'product_inquiry' | 'contact_form',
151
+ messageId: message.id,
152
+ subjectLabel: thread.subject_label,
153
+ senderName: thread.sender_name,
154
+ senderEmail: thread.sender_email,
155
+ message: body,
156
+ recipient,
157
+ });
158
+ });
159
+
160
+ revalidatePath('/thread');
161
+ return { success: true, messageKey: 'thread.sent' };
162
+ } catch (error) {
163
+ console.error('[thread] Visitor reply failed:', error);
164
+ return { success: false, messageKey: 'thread.error' };
165
+ }
166
+ }
@@ -1,146 +1,162 @@
1
- import { NextResponse } from 'next/server';
2
- import { getPaymentProvider } from '@nextblock-cms/ecommerce/server';
3
- import { createClient, verifyPackageOnline } from '@nextblock-cms/db/server';
4
- import { normalizeCustomerAddress } from '@nextblock-cms/ecommerce';
5
-
6
- function jsonError(errorKey: string, error: string, status: number) {
7
- return NextResponse.json({ error, errorKey }, { status });
8
- }
9
-
10
- function resolveProviderFromItem(item: any): 'stripe' | 'freemius' | null {
11
- if (item?.provider === 'stripe' || item?.provider === 'freemius') {
12
- return item.provider;
13
- }
14
-
15
- if (item?.payment_provider === 'stripe' || item?.payment_provider === 'freemius') {
16
- return item.payment_provider;
17
- }
18
-
19
- if (item?.product_type === 'digital') {
20
- return 'freemius';
21
- }
22
-
23
- if (item?.product_type === 'physical') {
24
- return 'stripe';
25
- }
26
-
27
- if (item?.freemius_product_id) {
28
- return 'freemius';
29
- }
30
-
31
- return null;
32
- }
33
-
34
- export async function POST(req: Request) {
35
- try {
36
- const isOnline = await verifyPackageOnline('ecommerce');
37
- if (!isOnline) {
38
- return jsonError(
39
- 'ecommerce.checkout_license_inactive',
40
- 'Ecommerce module license is inactive',
41
- 403
42
- );
43
- }
44
-
45
- const {
46
- items,
47
- customerEmail,
48
- customerPhone,
49
- billingAddress,
50
- shippingAddress,
51
- shippingMethodId,
52
- currencyCode,
53
- locale,
54
- couponCode,
55
- couponContextItems,
56
- } = await req.json();
57
-
58
- if (!items || !Array.isArray(items)) {
59
- return jsonError(
60
- 'ecommerce.checkout_invalid_items',
61
- 'Invalid items data',
62
- 400
63
- );
64
- }
65
-
66
- const providerNames = Array.from(
67
- new Set(items.map((item) => resolveProviderFromItem(item)).filter(Boolean))
68
- ) as Array<'stripe' | 'freemius'>;
69
-
70
- if (providerNames.length === 0) {
71
- return jsonError(
72
- 'ecommerce.checkout_provider_items_required',
73
- 'Each checkout request must include provider-aware cart items.',
74
- 400
75
- );
76
- }
77
-
78
- if (providerNames.length > 1) {
79
- return jsonError(
80
- 'ecommerce.checkout_mixed_provider_steps',
81
- 'Mixed-provider carts must be checked out in separate steps.',
82
- 400
83
- );
84
- }
85
-
86
- const providerName = providerNames[0];
87
-
88
- if (providerName === 'freemius' && items.length !== 1) {
89
- return jsonError(
90
- 'ecommerce.checkout_freemius_single_item',
91
- 'Freemius items must be checked out one at a time.',
92
- 400
93
- );
94
- }
95
-
96
- if (!billingAddress) {
97
- return jsonError(
98
- 'ecommerce.checkout_billing_address_required',
99
- 'Billing address is required',
100
- 400
101
- );
102
- }
103
-
104
- const supabase = createClient();
105
- const provider = getPaymentProvider(providerName);
106
-
107
- const { data: { user } } = await supabase.auth.getUser();
108
- const userId = user?.id;
109
- const resolvedCustomerEmail = user?.email || customerEmail || null;
110
-
111
- const { url, error, errorKey, errorParams, errorStatus, customProps } =
112
- await provider.createCheckoutSession({
113
- items,
114
- customerEmail: resolvedCustomerEmail,
115
- customerPhone,
116
- userId,
117
- billingAddress: normalizeCustomerAddress(billingAddress) ?? billingAddress,
118
- shippingAddress:
119
- providerName === 'stripe'
120
- ? normalizeCustomerAddress(shippingAddress)
121
- : null,
122
- shippingMethodId: providerName === 'stripe' ? shippingMethodId : null,
123
- currencyCode: typeof currencyCode === 'string' ? currencyCode : null,
124
- locale: typeof locale === 'string' ? locale : null,
125
- couponCode: typeof couponCode === 'string' ? couponCode : null,
126
- couponContextItems: Array.isArray(couponContextItems) ? couponContextItems : items,
127
- });
128
-
129
- if (error) {
130
- console.error('Checkout Error:', error);
131
- return NextResponse.json(
132
- { error, errorKey, errorParams },
133
- { status: errorStatus ?? 500 }
134
- );
135
- }
136
-
137
- return NextResponse.json({ url, customProps });
138
- } catch (err: any) {
139
- console.error('Checkout API Error:', err);
140
- return jsonError(
141
- 'ecommerce.checkout_internal_server_error',
142
- 'Internal Server Error',
143
- 500
144
- );
145
- }
146
- }
1
+ import { NextResponse } from 'next/server';
2
+ import { getPaymentProvider, getProviderReadiness } from '@nextblock-cms/ecommerce/server';
3
+ import { createClient, verifyPackageOnline } from '@nextblock-cms/db/server';
4
+ import { normalizeCustomerAddress } from '@nextblock-cms/ecommerce';
5
+
6
+ function jsonError(errorKey: string, error: string, status: number) {
7
+ return NextResponse.json({ error, errorKey }, { status });
8
+ }
9
+
10
+ function resolveProviderFromItem(item: any): 'stripe' | 'freemius' | null {
11
+ if (item?.provider === 'stripe' || item?.provider === 'freemius') {
12
+ return item.provider;
13
+ }
14
+
15
+ if (item?.payment_provider === 'stripe' || item?.payment_provider === 'freemius') {
16
+ return item.payment_provider;
17
+ }
18
+
19
+ if (item?.product_type === 'digital') {
20
+ return 'freemius';
21
+ }
22
+
23
+ if (item?.product_type === 'physical') {
24
+ return 'stripe';
25
+ }
26
+
27
+ if (item?.freemius_product_id) {
28
+ return 'freemius';
29
+ }
30
+
31
+ return null;
32
+ }
33
+
34
+ export async function POST(req: Request) {
35
+ try {
36
+ const isOnline = await verifyPackageOnline('ecommerce');
37
+ if (!isOnline) {
38
+ return jsonError(
39
+ 'ecommerce.checkout_license_inactive',
40
+ 'Ecommerce module license is inactive',
41
+ 403
42
+ );
43
+ }
44
+
45
+ const {
46
+ items,
47
+ customerEmail,
48
+ customerPhone,
49
+ billingAddress,
50
+ shippingAddress,
51
+ shippingMethodId,
52
+ currencyCode,
53
+ locale,
54
+ couponCode,
55
+ couponContextItems,
56
+ } = await req.json();
57
+
58
+ if (!items || !Array.isArray(items)) {
59
+ return jsonError(
60
+ 'ecommerce.checkout_invalid_items',
61
+ 'Invalid items data',
62
+ 400
63
+ );
64
+ }
65
+
66
+ const providerNames = Array.from(
67
+ new Set(items.map((item) => resolveProviderFromItem(item)).filter(Boolean))
68
+ ) as Array<'stripe' | 'freemius'>;
69
+
70
+ if (providerNames.length === 0) {
71
+ return jsonError(
72
+ 'ecommerce.checkout_provider_items_required',
73
+ 'Each checkout request must include provider-aware cart items.',
74
+ 400
75
+ );
76
+ }
77
+
78
+ if (providerNames.length > 1) {
79
+ return jsonError(
80
+ 'ecommerce.checkout_mixed_provider_steps',
81
+ 'Mixed-provider carts must be checked out in separate steps.',
82
+ 400
83
+ );
84
+ }
85
+
86
+ const providerName = providerNames[0];
87
+
88
+ if (providerName === 'freemius' && items.length !== 1) {
89
+ return jsonError(
90
+ 'ecommerce.checkout_freemius_single_item',
91
+ 'Freemius items must be checked out one at a time.',
92
+ 400
93
+ );
94
+ }
95
+
96
+ if (!billingAddress) {
97
+ return jsonError(
98
+ 'ecommerce.checkout_billing_address_required',
99
+ 'Billing address is required',
100
+ 400
101
+ );
102
+ }
103
+
104
+ // Preflight: refuse before the provider writes anything. StripeProvider inserts a
105
+ // pending `orders` row and can burn a coupon redemption BEFORE it ever calls Stripe,
106
+ // so an unconfigured store used to accumulate failed orders and spent coupons on
107
+ // every attempt and the shopper got Stripe's raw "Invalid API Key" string back.
108
+ const readiness = await getProviderReadiness(providerName);
109
+ if (!readiness.ready) {
110
+ console.error(
111
+ `Checkout blocked: ${readiness.label} is not ready (missing: ${readiness.missing.join(', ') || 'unknown'})`
112
+ );
113
+ return jsonError(
114
+ 'ecommerce.checkout_payments_unavailable',
115
+ 'This store is not able to take payments right now. Please contact the seller to complete your purchase.',
116
+ 503
117
+ );
118
+ }
119
+
120
+ const supabase = createClient();
121
+ const provider = getPaymentProvider(providerName);
122
+
123
+ const { data: { user } } = await supabase.auth.getUser();
124
+ const userId = user?.id;
125
+ const resolvedCustomerEmail = user?.email || customerEmail || null;
126
+
127
+ const { url, error, errorKey, errorParams, errorStatus, customProps } =
128
+ await provider.createCheckoutSession({
129
+ items,
130
+ customerEmail: resolvedCustomerEmail,
131
+ customerPhone,
132
+ userId,
133
+ billingAddress: normalizeCustomerAddress(billingAddress) ?? billingAddress,
134
+ shippingAddress:
135
+ providerName === 'stripe'
136
+ ? normalizeCustomerAddress(shippingAddress)
137
+ : null,
138
+ shippingMethodId: providerName === 'stripe' ? shippingMethodId : null,
139
+ currencyCode: typeof currencyCode === 'string' ? currencyCode : null,
140
+ locale: typeof locale === 'string' ? locale : null,
141
+ couponCode: typeof couponCode === 'string' ? couponCode : null,
142
+ couponContextItems: Array.isArray(couponContextItems) ? couponContextItems : items,
143
+ });
144
+
145
+ if (error) {
146
+ console.error('Checkout Error:', error);
147
+ return NextResponse.json(
148
+ { error, errorKey, errorParams },
149
+ { status: errorStatus ?? 500 }
150
+ );
151
+ }
152
+
153
+ return NextResponse.json({ url, customProps });
154
+ } catch (err: any) {
155
+ console.error('Checkout API Error:', err);
156
+ return jsonError(
157
+ 'ecommerce.checkout_internal_server_error',
158
+ 'Internal Server Error',
159
+ 500
160
+ );
161
+ }
162
+ }