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,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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
'
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
}
|