create-brainerce-store 1.27.5 → 1.28.0
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/dist/index.js +95 -22
- package/messages/en.json +12 -1
- package/messages/he.json +12 -1
- package/package.json +1 -1
- package/templates/nextjs/base/.env.local.ejs +3 -3
- package/templates/nextjs/base/next.config.ts +13 -12
- package/templates/nextjs/base/package.json.ejs +2 -1
- package/templates/nextjs/base/src/app/api/auth/logout/route.ts +15 -14
- package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +66 -59
- package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +76 -77
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +229 -198
- package/templates/nextjs/base/src/app/checkout/page.tsx +975 -972
- package/templates/nextjs/base/src/app/layout.tsx.ejs +29 -13
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +271 -271
- package/templates/nextjs/base/src/app/payment-complete/page.tsx +59 -59
- package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +501 -486
- package/templates/nextjs/base/src/app/products/page.tsx +475 -475
- package/templates/nextjs/base/src/app/reset-password/page.tsx +138 -131
- package/templates/nextjs/base/src/components/auth/register-form.tsx +245 -232
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +416 -415
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +258 -184
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +84 -20
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +86 -72
- package/templates/nextjs/base/src/lib/csrf.ts +11 -0
- package/templates/nextjs/base/src/lib/navigation.tsx.ejs +60 -60
- package/templates/nextjs/base/src/lib/nonce.ts +10 -0
- package/templates/nextjs/base/src/lib/safe-redirect.ts +45 -0
- package/templates/nextjs/base/src/lib/sanitize-html.ts +93 -0
- package/templates/nextjs/base/src/lib/validation.ts +37 -0
- package/templates/nextjs/base/src/middleware.ts.ejs +91 -8
- package/templates/nextjs/base/tsconfig.tsbuildinfo +1 -0
- package/templates/nextjs/themes/luxury/globals.css +399 -399
- package/templates/nextjs/themes/luxury/theme.json +23 -23
- package/templates/nextjs/themes/playful/globals.css +400 -400
- package/templates/nextjs/themes/playful/theme.json +23 -23
|
@@ -5,6 +5,7 @@ import { StoreProvider } from '@/providers/store-provider';
|
|
|
5
5
|
import { Header } from '@/components/layout/header';
|
|
6
6
|
import { Footer } from '@/components/layout/footer';
|
|
7
7
|
import { getDirection, supportedLocales } from '@/i18n';
|
|
8
|
+
import { getNonce } from '@/lib/nonce';
|
|
8
9
|
import '../globals.css';
|
|
9
10
|
|
|
10
11
|
<%- fontVariable %>
|
|
@@ -14,15 +15,15 @@ const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
|
14
15
|
export const metadata: Metadata = {
|
|
15
16
|
metadataBase: new URL(baseUrl),
|
|
16
17
|
title: {
|
|
17
|
-
default:
|
|
18
|
-
template:
|
|
18
|
+
default: <%- storeNameJs %>,
|
|
19
|
+
template: <%- titleTemplateJs %>,
|
|
19
20
|
},
|
|
20
|
-
description:
|
|
21
|
+
description: <%- storeNameJs %>,
|
|
21
22
|
alternates: {
|
|
22
23
|
canonical: '/',
|
|
23
24
|
},
|
|
24
25
|
openGraph: {
|
|
25
|
-
siteName:
|
|
26
|
+
siteName: <%- storeNameJs %>,
|
|
26
27
|
type: 'website',
|
|
27
28
|
},
|
|
28
29
|
robots: {
|
|
@@ -34,7 +35,7 @@ export const metadata: Metadata = {
|
|
|
34
35
|
const organizationJsonLd = {
|
|
35
36
|
'@context': 'https://schema.org',
|
|
36
37
|
'@type': 'Organization',
|
|
37
|
-
name:
|
|
38
|
+
name: <%- storeNameJs %>,
|
|
38
39
|
url: baseUrl,
|
|
39
40
|
};
|
|
40
41
|
|
|
@@ -51,13 +52,20 @@ export default async function RootLayout({
|
|
|
51
52
|
}) {
|
|
52
53
|
const { locale } = await params;
|
|
53
54
|
const dir = getDirection(locale);
|
|
55
|
+
const nonce = await getNonce();
|
|
54
56
|
|
|
55
57
|
return (
|
|
56
58
|
<html lang={locale} dir={dir}>
|
|
57
59
|
<head>
|
|
58
60
|
<script
|
|
59
61
|
type="application/ld+json"
|
|
60
|
-
|
|
62
|
+
nonce={nonce}
|
|
63
|
+
dangerouslySetInnerHTML={{
|
|
64
|
+
__html: JSON.stringify(organizationJsonLd)
|
|
65
|
+
.replace(/</g, '\\u003c')
|
|
66
|
+
.replace(/>/g, '\\u003e')
|
|
67
|
+
.replace(/&/g, '\\u0026'),
|
|
68
|
+
}}
|
|
61
69
|
/>
|
|
62
70
|
</head>
|
|
63
71
|
<body className={font.className}>
|
|
@@ -78,6 +86,7 @@ import type { Metadata } from 'next';
|
|
|
78
86
|
import { StoreProvider } from '@/providers/store-provider';
|
|
79
87
|
import { Header } from '@/components/layout/header';
|
|
80
88
|
import { Footer } from '@/components/layout/footer';
|
|
89
|
+
import { getNonce } from '@/lib/nonce';
|
|
81
90
|
import './globals.css';
|
|
82
91
|
|
|
83
92
|
<%- fontVariable %>
|
|
@@ -87,15 +96,15 @@ const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
|
87
96
|
export const metadata: Metadata = {
|
|
88
97
|
metadataBase: new URL(baseUrl),
|
|
89
98
|
title: {
|
|
90
|
-
default:
|
|
91
|
-
template:
|
|
99
|
+
default: <%- storeNameJs %>,
|
|
100
|
+
template: <%- titleTemplateJs %>,
|
|
92
101
|
},
|
|
93
|
-
description:
|
|
102
|
+
description: <%- storeNameJs %>,
|
|
94
103
|
alternates: {
|
|
95
104
|
canonical: '/',
|
|
96
105
|
},
|
|
97
106
|
openGraph: {
|
|
98
|
-
siteName:
|
|
107
|
+
siteName: <%- storeNameJs %>,
|
|
99
108
|
locale: '<%= ogLocale %>',
|
|
100
109
|
type: 'website',
|
|
101
110
|
},
|
|
@@ -108,21 +117,28 @@ export const metadata: Metadata = {
|
|
|
108
117
|
const organizationJsonLd = {
|
|
109
118
|
'@context': 'https://schema.org',
|
|
110
119
|
'@type': 'Organization',
|
|
111
|
-
name:
|
|
120
|
+
name: <%- storeNameJs %>,
|
|
112
121
|
url: baseUrl,
|
|
113
122
|
};
|
|
114
123
|
|
|
115
|
-
export default function RootLayout({
|
|
124
|
+
export default async function RootLayout({
|
|
116
125
|
children,
|
|
117
126
|
}: {
|
|
118
127
|
children: React.ReactNode;
|
|
119
128
|
}) {
|
|
129
|
+
const nonce = await getNonce();
|
|
120
130
|
return (
|
|
121
131
|
<html lang="<%= language %>" dir="<%= direction %>">
|
|
122
132
|
<head>
|
|
123
133
|
<script
|
|
124
134
|
type="application/ld+json"
|
|
125
|
-
|
|
135
|
+
nonce={nonce}
|
|
136
|
+
dangerouslySetInnerHTML={{
|
|
137
|
+
__html: JSON.stringify(organizationJsonLd)
|
|
138
|
+
.replace(/</g, '\\u003c')
|
|
139
|
+
.replace(/>/g, '\\u003e')
|
|
140
|
+
.replace(/&/g, '\\u0026'),
|
|
141
|
+
}}
|
|
126
142
|
/>
|
|
127
143
|
</head>
|
|
128
144
|
<body className={font.className}>
|
|
@@ -1,271 +1,271 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { Suspense, useEffect, useState } from 'react';
|
|
4
|
-
import { useSearchParams } from 'next/navigation';
|
|
5
|
-
import { Link } from '@/lib/navigation';
|
|
6
|
-
import type { WaitForOrderResult, OrderDownloadLink } from 'brainerce';
|
|
7
|
-
import { getClient } from '@/lib/brainerce';
|
|
8
|
-
import { useCart } from '@/providers/store-provider';
|
|
9
|
-
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
10
|
-
import { useTranslations } from '@/lib/translations';
|
|
11
|
-
|
|
12
|
-
function OrderConfirmationContent() {
|
|
13
|
-
const searchParams = useSearchParams();
|
|
14
|
-
const checkoutId = searchParams.get('checkout_id');
|
|
15
|
-
|
|
16
|
-
const { refreshCart } = useCart();
|
|
17
|
-
const t = useTranslations('orderConfirmation');
|
|
18
|
-
const tc = useTranslations('common');
|
|
19
|
-
const [result, setResult] = useState<WaitForOrderResult | null>(null);
|
|
20
|
-
const [loading, setLoading] = useState(true);
|
|
21
|
-
const [error, setError] = useState<string | null>(null);
|
|
22
|
-
|
|
23
|
-
useEffect(() => {
|
|
24
|
-
if (!checkoutId) {
|
|
25
|
-
setError(t('missingCheckoutInfo'));
|
|
26
|
-
setLoading(false);
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async function waitForOrder() {
|
|
31
|
-
try {
|
|
32
|
-
const client = getClient();
|
|
33
|
-
|
|
34
|
-
// Clear cart state after successful payment
|
|
35
|
-
client.handlePaymentSuccess(checkoutId!);
|
|
36
|
-
await refreshCart();
|
|
37
|
-
|
|
38
|
-
// For redirect-based payment providers (e.g. CardCom), the customer
|
|
39
|
-
// returns with provider params in the URL (lowprofilecode, etc.).
|
|
40
|
-
// Send these to the backend for server-side verification via the
|
|
41
|
-
// provider's API (e.g. GetLpResult) — never trust URL params alone.
|
|
42
|
-
const lowProfileCode =
|
|
43
|
-
searchParams.get('lowprofilecode') || searchParams.get('LowProfileCode');
|
|
44
|
-
if (lowProfileCode) {
|
|
45
|
-
try {
|
|
46
|
-
await client.confirmSdkPayment(checkoutId!, {
|
|
47
|
-
paymentIntentId: lowProfileCode,
|
|
48
|
-
});
|
|
49
|
-
} catch (err) {
|
|
50
|
-
console.warn('Redirect payment confirmation failed:', err);
|
|
51
|
-
// Don't block — webhook may still process the payment
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const orderResult = await client.waitForOrder(checkoutId!, {
|
|
56
|
-
maxWaitMs: 30000,
|
|
57
|
-
});
|
|
58
|
-
setResult(orderResult);
|
|
59
|
-
} catch (err) {
|
|
60
|
-
const message = err instanceof Error ? err.message : 'Failed to confirm order';
|
|
61
|
-
setError(message);
|
|
62
|
-
} finally {
|
|
63
|
-
setLoading(false);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
waitForOrder();
|
|
68
|
-
}, [checkoutId, refreshCart]);
|
|
69
|
-
|
|
70
|
-
if (loading) {
|
|
71
|
-
return (
|
|
72
|
-
<div className="flex min-h-[60vh] flex-col items-center justify-center">
|
|
73
|
-
<LoadingSpinner size="lg" />
|
|
74
|
-
<p className="text-muted-foreground mt-4">{t('confirming')}</p>
|
|
75
|
-
<p className="text-muted-foreground mt-1 text-xs">{t('confirmingHint')}</p>
|
|
76
|
-
</div>
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (error) {
|
|
81
|
-
return (
|
|
82
|
-
<div className="mx-auto max-w-2xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
83
|
-
<svg
|
|
84
|
-
className="text-destructive mx-auto mb-4 h-16 w-16"
|
|
85
|
-
fill="none"
|
|
86
|
-
viewBox="0 0 24 24"
|
|
87
|
-
stroke="currentColor"
|
|
88
|
-
>
|
|
89
|
-
<path
|
|
90
|
-
strokeLinecap="round"
|
|
91
|
-
strokeLinejoin="round"
|
|
92
|
-
strokeWidth={1.5}
|
|
93
|
-
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.834-2.694-.834-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"
|
|
94
|
-
/>
|
|
95
|
-
</svg>
|
|
96
|
-
<h1 className="text-foreground text-2xl font-bold">{t('errorTitle')}</h1>
|
|
97
|
-
<p className="text-muted-foreground mt-2">{error}</p>
|
|
98
|
-
<p className="text-muted-foreground mt-1 text-sm">{t('errorChargedHint')}</p>
|
|
99
|
-
<Link
|
|
100
|
-
href="/"
|
|
101
|
-
className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
102
|
-
>
|
|
103
|
-
{t('returnHome')}
|
|
104
|
-
</Link>
|
|
105
|
-
</div>
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Order was created successfully
|
|
110
|
-
if (result?.success) {
|
|
111
|
-
const orderNumber = result.status.orderNumber;
|
|
112
|
-
|
|
113
|
-
return (
|
|
114
|
-
<div className="mx-auto max-w-2xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
115
|
-
<svg
|
|
116
|
-
className="text-primary mx-auto mb-4 h-16 w-16"
|
|
117
|
-
fill="none"
|
|
118
|
-
viewBox="0 0 24 24"
|
|
119
|
-
stroke="currentColor"
|
|
120
|
-
>
|
|
121
|
-
<path
|
|
122
|
-
strokeLinecap="round"
|
|
123
|
-
strokeLinejoin="round"
|
|
124
|
-
strokeWidth={1.5}
|
|
125
|
-
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
126
|
-
/>
|
|
127
|
-
</svg>
|
|
128
|
-
|
|
129
|
-
<h1 className="text-foreground text-2xl font-bold">{t('thankYou')}</h1>
|
|
130
|
-
|
|
131
|
-
{orderNumber && (
|
|
132
|
-
<p className="text-foreground mt-3 text-lg">
|
|
133
|
-
{t('orderNumber')} <span className="font-semibold">{orderNumber}</span>
|
|
134
|
-
</p>
|
|
135
|
-
)}
|
|
136
|
-
|
|
137
|
-
<p className="text-muted-foreground mt-2">{t('confirmationEmail')}</p>
|
|
138
|
-
|
|
139
|
-
{result.status.orderId && (
|
|
140
|
-
<ConfirmationDownloads orderId={result.status.orderId} checkoutId={checkoutId!} />
|
|
141
|
-
)}
|
|
142
|
-
|
|
143
|
-
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
|
144
|
-
<Link
|
|
145
|
-
href="/products"
|
|
146
|
-
className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
147
|
-
>
|
|
148
|
-
{tc('continueShopping')}
|
|
149
|
-
</Link>
|
|
150
|
-
|
|
151
|
-
<Link
|
|
152
|
-
href="/account"
|
|
153
|
-
className="border-border text-foreground hover:bg-muted inline-flex items-center rounded border px-6 py-3 font-medium transition-colors"
|
|
154
|
-
>
|
|
155
|
-
{t('viewOrders')}
|
|
156
|
-
</Link>
|
|
157
|
-
</div>
|
|
158
|
-
</div>
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Order not yet confirmed (polling timed out) - still show success
|
|
163
|
-
return (
|
|
164
|
-
<div className="mx-auto max-w-2xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
165
|
-
<svg
|
|
166
|
-
className="text-primary mx-auto mb-4 h-16 w-16"
|
|
167
|
-
fill="none"
|
|
168
|
-
viewBox="0 0 24 24"
|
|
169
|
-
stroke="currentColor"
|
|
170
|
-
>
|
|
171
|
-
<path
|
|
172
|
-
strokeLinecap="round"
|
|
173
|
-
strokeLinejoin="round"
|
|
174
|
-
strokeWidth={1.5}
|
|
175
|
-
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
176
|
-
/>
|
|
177
|
-
</svg>
|
|
178
|
-
|
|
179
|
-
<h1 className="text-foreground text-2xl font-bold">{t('paymentReceived')}</h1>
|
|
180
|
-
|
|
181
|
-
<p className="text-muted-foreground mt-2">{t('orderProcessing')}</p>
|
|
182
|
-
|
|
183
|
-
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
|
184
|
-
<Link
|
|
185
|
-
href="/products"
|
|
186
|
-
className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
187
|
-
>
|
|
188
|
-
{tc('continueShopping')}
|
|
189
|
-
</Link>
|
|
190
|
-
|
|
191
|
-
<Link
|
|
192
|
-
href="/account"
|
|
193
|
-
className="border-border text-foreground hover:bg-muted inline-flex items-center rounded border px-6 py-3 font-medium transition-colors"
|
|
194
|
-
>
|
|
195
|
-
{t('viewOrders')}
|
|
196
|
-
</Link>
|
|
197
|
-
</div>
|
|
198
|
-
</div>
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function ConfirmationDownloads({ orderId, checkoutId }: { orderId: string; checkoutId: string }) {
|
|
203
|
-
const t = useTranslations('orderConfirmation');
|
|
204
|
-
const [downloads, setDownloads] = useState<OrderDownloadLink[] | null>(null);
|
|
205
|
-
|
|
206
|
-
useEffect(() => {
|
|
207
|
-
let cancelled = false;
|
|
208
|
-
async function fetchDownloads() {
|
|
209
|
-
const client = getClient();
|
|
210
|
-
// Retry a few times — the worker may still be writing downloadMeta
|
|
211
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
212
|
-
try {
|
|
213
|
-
const links = await client.getOrderDownloads(orderId, { checkoutId });
|
|
214
|
-
if (!cancelled && links.length > 0) {
|
|
215
|
-
setDownloads(links);
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
} catch {
|
|
219
|
-
// Not all orders have downloads
|
|
220
|
-
}
|
|
221
|
-
if (attempt < 2 && !cancelled) {
|
|
222
|
-
await new Promise((r) => setTimeout(r, 1500));
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
fetchDownloads();
|
|
227
|
-
return () => {
|
|
228
|
-
cancelled = true;
|
|
229
|
-
};
|
|
230
|
-
}, [orderId, checkoutId]);
|
|
231
|
-
|
|
232
|
-
if (!downloads || downloads.length === 0) return null;
|
|
233
|
-
|
|
234
|
-
return (
|
|
235
|
-
<div className="border-border bg-muted/30 mx-auto mt-8 max-w-md rounded-lg border p-6 text-start">
|
|
236
|
-
<h3 className="text-foreground mb-3 text-sm font-semibold">{t('yourDownloads')}</h3>
|
|
237
|
-
<div className="space-y-2">
|
|
238
|
-
{downloads.map((link, idx) => (
|
|
239
|
-
<div key={idx} className="flex items-center justify-between gap-3">
|
|
240
|
-
<div className="min-w-0 flex-1">
|
|
241
|
-
<p className="text-foreground truncate text-sm">{link.fileName}</p>
|
|
242
|
-
<p className="text-muted-foreground truncate text-xs">{link.productName}</p>
|
|
243
|
-
</div>
|
|
244
|
-
<a
|
|
245
|
-
href={link.downloadUrl}
|
|
246
|
-
target="_blank"
|
|
247
|
-
rel="noopener noreferrer"
|
|
248
|
-
className="bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1.5 text-xs font-medium hover:opacity-90"
|
|
249
|
-
>
|
|
250
|
-
{t('download')}
|
|
251
|
-
</a>
|
|
252
|
-
</div>
|
|
253
|
-
))}
|
|
254
|
-
</div>
|
|
255
|
-
</div>
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
export default function OrderConfirmationPage() {
|
|
260
|
-
return (
|
|
261
|
-
<Suspense
|
|
262
|
-
fallback={
|
|
263
|
-
<div className="flex min-h-[60vh] items-center justify-center">
|
|
264
|
-
<LoadingSpinner size="lg" />
|
|
265
|
-
</div>
|
|
266
|
-
}
|
|
267
|
-
>
|
|
268
|
-
<OrderConfirmationContent />
|
|
269
|
-
</Suspense>
|
|
270
|
-
);
|
|
271
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Suspense, useEffect, useState } from 'react';
|
|
4
|
+
import { useSearchParams } from 'next/navigation';
|
|
5
|
+
import { Link } from '@/lib/navigation';
|
|
6
|
+
import type { WaitForOrderResult, OrderDownloadLink } from 'brainerce';
|
|
7
|
+
import { getClient } from '@/lib/brainerce';
|
|
8
|
+
import { useCart } from '@/providers/store-provider';
|
|
9
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
10
|
+
import { useTranslations } from '@/lib/translations';
|
|
11
|
+
|
|
12
|
+
function OrderConfirmationContent() {
|
|
13
|
+
const searchParams = useSearchParams();
|
|
14
|
+
const checkoutId = searchParams.get('checkout_id');
|
|
15
|
+
|
|
16
|
+
const { refreshCart } = useCart();
|
|
17
|
+
const t = useTranslations('orderConfirmation');
|
|
18
|
+
const tc = useTranslations('common');
|
|
19
|
+
const [result, setResult] = useState<WaitForOrderResult | null>(null);
|
|
20
|
+
const [loading, setLoading] = useState(true);
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!checkoutId) {
|
|
25
|
+
setError(t('missingCheckoutInfo'));
|
|
26
|
+
setLoading(false);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function waitForOrder() {
|
|
31
|
+
try {
|
|
32
|
+
const client = getClient();
|
|
33
|
+
|
|
34
|
+
// Clear cart state after successful payment
|
|
35
|
+
client.handlePaymentSuccess(checkoutId!);
|
|
36
|
+
await refreshCart();
|
|
37
|
+
|
|
38
|
+
// For redirect-based payment providers (e.g. CardCom), the customer
|
|
39
|
+
// returns with provider params in the URL (lowprofilecode, etc.).
|
|
40
|
+
// Send these to the backend for server-side verification via the
|
|
41
|
+
// provider's API (e.g. GetLpResult) — never trust URL params alone.
|
|
42
|
+
const lowProfileCode =
|
|
43
|
+
searchParams.get('lowprofilecode') || searchParams.get('LowProfileCode');
|
|
44
|
+
if (lowProfileCode) {
|
|
45
|
+
try {
|
|
46
|
+
await client.confirmSdkPayment(checkoutId!, {
|
|
47
|
+
paymentIntentId: lowProfileCode,
|
|
48
|
+
});
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.warn('Redirect payment confirmation failed:', err);
|
|
51
|
+
// Don't block — webhook may still process the payment
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const orderResult = await client.waitForOrder(checkoutId!, {
|
|
56
|
+
maxWaitMs: 30000,
|
|
57
|
+
});
|
|
58
|
+
setResult(orderResult);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
const message = err instanceof Error ? err.message : 'Failed to confirm order';
|
|
61
|
+
setError(message);
|
|
62
|
+
} finally {
|
|
63
|
+
setLoading(false);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
waitForOrder();
|
|
68
|
+
}, [checkoutId, refreshCart]);
|
|
69
|
+
|
|
70
|
+
if (loading) {
|
|
71
|
+
return (
|
|
72
|
+
<div className="flex min-h-[60vh] flex-col items-center justify-center">
|
|
73
|
+
<LoadingSpinner size="lg" />
|
|
74
|
+
<p className="text-muted-foreground mt-4">{t('confirming')}</p>
|
|
75
|
+
<p className="text-muted-foreground mt-1 text-xs">{t('confirmingHint')}</p>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (error) {
|
|
81
|
+
return (
|
|
82
|
+
<div className="mx-auto max-w-2xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
83
|
+
<svg
|
|
84
|
+
className="text-destructive mx-auto mb-4 h-16 w-16"
|
|
85
|
+
fill="none"
|
|
86
|
+
viewBox="0 0 24 24"
|
|
87
|
+
stroke="currentColor"
|
|
88
|
+
>
|
|
89
|
+
<path
|
|
90
|
+
strokeLinecap="round"
|
|
91
|
+
strokeLinejoin="round"
|
|
92
|
+
strokeWidth={1.5}
|
|
93
|
+
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.834-2.694-.834-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"
|
|
94
|
+
/>
|
|
95
|
+
</svg>
|
|
96
|
+
<h1 className="text-foreground text-2xl font-bold">{t('errorTitle')}</h1>
|
|
97
|
+
<p className="text-muted-foreground mt-2">{error}</p>
|
|
98
|
+
<p className="text-muted-foreground mt-1 text-sm">{t('errorChargedHint')}</p>
|
|
99
|
+
<Link
|
|
100
|
+
href="/"
|
|
101
|
+
className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
102
|
+
>
|
|
103
|
+
{t('returnHome')}
|
|
104
|
+
</Link>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Order was created successfully
|
|
110
|
+
if (result?.success) {
|
|
111
|
+
const orderNumber = result.status.orderNumber;
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="mx-auto max-w-2xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
115
|
+
<svg
|
|
116
|
+
className="text-primary mx-auto mb-4 h-16 w-16"
|
|
117
|
+
fill="none"
|
|
118
|
+
viewBox="0 0 24 24"
|
|
119
|
+
stroke="currentColor"
|
|
120
|
+
>
|
|
121
|
+
<path
|
|
122
|
+
strokeLinecap="round"
|
|
123
|
+
strokeLinejoin="round"
|
|
124
|
+
strokeWidth={1.5}
|
|
125
|
+
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
126
|
+
/>
|
|
127
|
+
</svg>
|
|
128
|
+
|
|
129
|
+
<h1 className="text-foreground text-2xl font-bold">{t('thankYou')}</h1>
|
|
130
|
+
|
|
131
|
+
{orderNumber && (
|
|
132
|
+
<p className="text-foreground mt-3 text-lg">
|
|
133
|
+
{t('orderNumber')} <span className="font-semibold">{orderNumber}</span>
|
|
134
|
+
</p>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
<p className="text-muted-foreground mt-2">{t('confirmationEmail')}</p>
|
|
138
|
+
|
|
139
|
+
{result.status.orderId && (
|
|
140
|
+
<ConfirmationDownloads orderId={result.status.orderId} checkoutId={checkoutId!} />
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
|
144
|
+
<Link
|
|
145
|
+
href="/products"
|
|
146
|
+
className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
147
|
+
>
|
|
148
|
+
{tc('continueShopping')}
|
|
149
|
+
</Link>
|
|
150
|
+
|
|
151
|
+
<Link
|
|
152
|
+
href="/account"
|
|
153
|
+
className="border-border text-foreground hover:bg-muted inline-flex items-center rounded border px-6 py-3 font-medium transition-colors"
|
|
154
|
+
>
|
|
155
|
+
{t('viewOrders')}
|
|
156
|
+
</Link>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Order not yet confirmed (polling timed out) - still show success
|
|
163
|
+
return (
|
|
164
|
+
<div className="mx-auto max-w-2xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
165
|
+
<svg
|
|
166
|
+
className="text-primary mx-auto mb-4 h-16 w-16"
|
|
167
|
+
fill="none"
|
|
168
|
+
viewBox="0 0 24 24"
|
|
169
|
+
stroke="currentColor"
|
|
170
|
+
>
|
|
171
|
+
<path
|
|
172
|
+
strokeLinecap="round"
|
|
173
|
+
strokeLinejoin="round"
|
|
174
|
+
strokeWidth={1.5}
|
|
175
|
+
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
176
|
+
/>
|
|
177
|
+
</svg>
|
|
178
|
+
|
|
179
|
+
<h1 className="text-foreground text-2xl font-bold">{t('paymentReceived')}</h1>
|
|
180
|
+
|
|
181
|
+
<p className="text-muted-foreground mt-2">{t('orderProcessing')}</p>
|
|
182
|
+
|
|
183
|
+
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
|
184
|
+
<Link
|
|
185
|
+
href="/products"
|
|
186
|
+
className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
187
|
+
>
|
|
188
|
+
{tc('continueShopping')}
|
|
189
|
+
</Link>
|
|
190
|
+
|
|
191
|
+
<Link
|
|
192
|
+
href="/account"
|
|
193
|
+
className="border-border text-foreground hover:bg-muted inline-flex items-center rounded border px-6 py-3 font-medium transition-colors"
|
|
194
|
+
>
|
|
195
|
+
{t('viewOrders')}
|
|
196
|
+
</Link>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function ConfirmationDownloads({ orderId, checkoutId }: { orderId: string; checkoutId: string }) {
|
|
203
|
+
const t = useTranslations('orderConfirmation');
|
|
204
|
+
const [downloads, setDownloads] = useState<OrderDownloadLink[] | null>(null);
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
let cancelled = false;
|
|
208
|
+
async function fetchDownloads() {
|
|
209
|
+
const client = getClient();
|
|
210
|
+
// Retry a few times — the worker may still be writing downloadMeta
|
|
211
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
212
|
+
try {
|
|
213
|
+
const links = await client.getOrderDownloads(orderId, { checkoutId });
|
|
214
|
+
if (!cancelled && links.length > 0) {
|
|
215
|
+
setDownloads(links);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
// Not all orders have downloads
|
|
220
|
+
}
|
|
221
|
+
if (attempt < 2 && !cancelled) {
|
|
222
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
fetchDownloads();
|
|
227
|
+
return () => {
|
|
228
|
+
cancelled = true;
|
|
229
|
+
};
|
|
230
|
+
}, [orderId, checkoutId]);
|
|
231
|
+
|
|
232
|
+
if (!downloads || downloads.length === 0) return null;
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<div className="border-border bg-muted/30 mx-auto mt-8 max-w-md rounded-lg border p-6 text-start">
|
|
236
|
+
<h3 className="text-foreground mb-3 text-sm font-semibold">{t('yourDownloads')}</h3>
|
|
237
|
+
<div className="space-y-2">
|
|
238
|
+
{downloads.map((link, idx) => (
|
|
239
|
+
<div key={idx} className="flex items-center justify-between gap-3">
|
|
240
|
+
<div className="min-w-0 flex-1">
|
|
241
|
+
<p className="text-foreground truncate text-sm">{link.fileName}</p>
|
|
242
|
+
<p className="text-muted-foreground truncate text-xs">{link.productName}</p>
|
|
243
|
+
</div>
|
|
244
|
+
<a
|
|
245
|
+
href={link.downloadUrl}
|
|
246
|
+
target="_blank"
|
|
247
|
+
rel="noopener noreferrer"
|
|
248
|
+
className="bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1.5 text-xs font-medium hover:opacity-90"
|
|
249
|
+
>
|
|
250
|
+
{t('download')}
|
|
251
|
+
</a>
|
|
252
|
+
</div>
|
|
253
|
+
))}
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export default function OrderConfirmationPage() {
|
|
260
|
+
return (
|
|
261
|
+
<Suspense
|
|
262
|
+
fallback={
|
|
263
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
264
|
+
<LoadingSpinner size="lg" />
|
|
265
|
+
</div>
|
|
266
|
+
}
|
|
267
|
+
>
|
|
268
|
+
<OrderConfirmationContent />
|
|
269
|
+
</Suspense>
|
|
270
|
+
);
|
|
271
|
+
}
|