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
|
@@ -1,184 +1,258 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
{
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
{
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
{field.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
{
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import type { CheckoutCustomFieldDefinition } from 'brainerce';
|
|
5
|
+
import { useTranslations } from '@/lib/translations';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
|
|
8
|
+
interface CustomFieldsStepProps {
|
|
9
|
+
fields: CheckoutCustomFieldDefinition[];
|
|
10
|
+
values: Record<string, unknown>;
|
|
11
|
+
onChange: (key: string, value: unknown) => void;
|
|
12
|
+
onApply: () => void;
|
|
13
|
+
onUploadFile?: (file: File) => Promise<{ url: string; key: string }>;
|
|
14
|
+
loading?: boolean;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
19
|
+
const ACCEPTED_IMAGE_TYPES = 'image/jpeg,image/png,image/webp,image/gif';
|
|
20
|
+
|
|
21
|
+
export function CustomFieldsStep({
|
|
22
|
+
fields,
|
|
23
|
+
values,
|
|
24
|
+
onChange,
|
|
25
|
+
onApply,
|
|
26
|
+
onUploadFile,
|
|
27
|
+
loading = false,
|
|
28
|
+
className,
|
|
29
|
+
}: CustomFieldsStepProps) {
|
|
30
|
+
const t = useTranslations('checkout');
|
|
31
|
+
const [uploadingKeys, setUploadingKeys] = useState<Set<string>>(new Set());
|
|
32
|
+
|
|
33
|
+
const isMissing = fields.some((f) => {
|
|
34
|
+
if (!f.required) return false;
|
|
35
|
+
const v = values[f.key];
|
|
36
|
+
return v === undefined || v === null || v === '';
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className={cn('space-y-4', className)}>
|
|
41
|
+
<p className="text-muted-foreground text-sm">{t('customFieldsSubtitle')}</p>
|
|
42
|
+
|
|
43
|
+
{fields.map((field) => {
|
|
44
|
+
const value = values[field.key];
|
|
45
|
+
const labelEl = (
|
|
46
|
+
<label
|
|
47
|
+
htmlFor={`cf-${field.key}`}
|
|
48
|
+
className="text-foreground mb-1 block text-sm font-medium"
|
|
49
|
+
>
|
|
50
|
+
{field.name}
|
|
51
|
+
{field.required && <span className="text-destructive ms-1">*</span>}
|
|
52
|
+
</label>
|
|
53
|
+
);
|
|
54
|
+
const helpEl = field.description ? (
|
|
55
|
+
<p className="text-muted-foreground mt-1 text-xs">{field.description}</p>
|
|
56
|
+
) : null;
|
|
57
|
+
|
|
58
|
+
switch (field.type) {
|
|
59
|
+
case 'TEXT':
|
|
60
|
+
return (
|
|
61
|
+
<div key={field.key}>
|
|
62
|
+
{labelEl}
|
|
63
|
+
<input
|
|
64
|
+
id={`cf-${field.key}`}
|
|
65
|
+
type="text"
|
|
66
|
+
value={(value as string) ?? ''}
|
|
67
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
68
|
+
required={field.required}
|
|
69
|
+
minLength={field.minLength ?? undefined}
|
|
70
|
+
maxLength={field.maxLength ?? undefined}
|
|
71
|
+
className="border-border bg-background text-foreground w-full rounded border px-3 py-2 text-sm"
|
|
72
|
+
/>
|
|
73
|
+
{helpEl}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
case 'TEXTAREA':
|
|
78
|
+
return (
|
|
79
|
+
<div key={field.key}>
|
|
80
|
+
{labelEl}
|
|
81
|
+
<textarea
|
|
82
|
+
id={`cf-${field.key}`}
|
|
83
|
+
value={(value as string) ?? ''}
|
|
84
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
85
|
+
required={field.required}
|
|
86
|
+
minLength={field.minLength ?? undefined}
|
|
87
|
+
maxLength={field.maxLength ?? undefined}
|
|
88
|
+
rows={3}
|
|
89
|
+
className="border-border bg-background text-foreground w-full rounded border px-3 py-2 text-sm"
|
|
90
|
+
/>
|
|
91
|
+
{helpEl}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
case 'NUMBER':
|
|
96
|
+
return (
|
|
97
|
+
<div key={field.key}>
|
|
98
|
+
{labelEl}
|
|
99
|
+
<input
|
|
100
|
+
id={`cf-${field.key}`}
|
|
101
|
+
type="number"
|
|
102
|
+
value={(value as number | string) ?? ''}
|
|
103
|
+
onChange={(e) =>
|
|
104
|
+
onChange(field.key, e.target.value === '' ? '' : Number(e.target.value))
|
|
105
|
+
}
|
|
106
|
+
required={field.required}
|
|
107
|
+
min={field.minValue ?? undefined}
|
|
108
|
+
max={field.maxValue ?? undefined}
|
|
109
|
+
className="border-border bg-background text-foreground w-full rounded border px-3 py-2 text-sm"
|
|
110
|
+
/>
|
|
111
|
+
{helpEl}
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
case 'BOOLEAN':
|
|
116
|
+
return (
|
|
117
|
+
<div key={field.key} className="flex items-start gap-2">
|
|
118
|
+
<input
|
|
119
|
+
id={`cf-${field.key}`}
|
|
120
|
+
type="checkbox"
|
|
121
|
+
checked={value === true}
|
|
122
|
+
onChange={(e) => onChange(field.key, e.target.checked)}
|
|
123
|
+
className="mt-1"
|
|
124
|
+
/>
|
|
125
|
+
<div className="flex-1">
|
|
126
|
+
<label
|
|
127
|
+
htmlFor={`cf-${field.key}`}
|
|
128
|
+
className="text-foreground text-sm font-medium"
|
|
129
|
+
>
|
|
130
|
+
{field.name}
|
|
131
|
+
{field.required && <span className="text-destructive ms-1">*</span>}
|
|
132
|
+
</label>
|
|
133
|
+
{helpEl}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
case 'SELECT':
|
|
139
|
+
return (
|
|
140
|
+
<div key={field.key}>
|
|
141
|
+
{labelEl}
|
|
142
|
+
<select
|
|
143
|
+
id={`cf-${field.key}`}
|
|
144
|
+
value={(value as string) ?? ''}
|
|
145
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
146
|
+
required={field.required}
|
|
147
|
+
className="border-border bg-background text-foreground w-full rounded border px-3 py-2 text-sm"
|
|
148
|
+
>
|
|
149
|
+
<option value="">{t('customFieldsSelectPlaceholder')}</option>
|
|
150
|
+
{field.options?.map((opt) => (
|
|
151
|
+
<option key={opt.value} value={opt.value}>
|
|
152
|
+
{opt.label}
|
|
153
|
+
</option>
|
|
154
|
+
))}
|
|
155
|
+
</select>
|
|
156
|
+
{helpEl}
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
case 'DATE':
|
|
161
|
+
return (
|
|
162
|
+
<div key={field.key}>
|
|
163
|
+
{labelEl}
|
|
164
|
+
<input
|
|
165
|
+
id={`cf-${field.key}`}
|
|
166
|
+
type="date"
|
|
167
|
+
value={(value as string) ?? ''}
|
|
168
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
169
|
+
required={field.required}
|
|
170
|
+
className="border-border bg-background text-foreground w-full rounded border px-3 py-2 text-sm"
|
|
171
|
+
/>
|
|
172
|
+
{helpEl}
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
case 'IMAGE': {
|
|
177
|
+
const isUploading = uploadingKeys.has(field.key);
|
|
178
|
+
return (
|
|
179
|
+
<div key={field.key}>
|
|
180
|
+
{labelEl}
|
|
181
|
+
{value ? (
|
|
182
|
+
<div className="relative inline-block">
|
|
183
|
+
<img
|
|
184
|
+
src={value as string}
|
|
185
|
+
alt={field.name}
|
|
186
|
+
className="border-border max-h-32 rounded border object-contain"
|
|
187
|
+
/>
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onClick={() => onChange(field.key, '')}
|
|
191
|
+
className="bg-background/80 text-foreground absolute end-1 top-1 rounded-full p-1 text-xs leading-none"
|
|
192
|
+
aria-label={t('customFieldsImageRemove')}
|
|
193
|
+
>
|
|
194
|
+
✕
|
|
195
|
+
</button>
|
|
196
|
+
</div>
|
|
197
|
+
) : (
|
|
198
|
+
<label
|
|
199
|
+
htmlFor={`cf-${field.key}`}
|
|
200
|
+
className={cn(
|
|
201
|
+
'border-border flex cursor-pointer flex-col items-center gap-2 rounded border-2 border-dashed p-6 text-center transition-colors',
|
|
202
|
+
isUploading ? 'opacity-50' : 'hover:border-primary/40',
|
|
203
|
+
)}
|
|
204
|
+
>
|
|
205
|
+
<span className="text-muted-foreground text-sm">
|
|
206
|
+
{isUploading ? t('customFieldsImageUploading') : t('customFieldsImageUpload')}
|
|
207
|
+
</span>
|
|
208
|
+
<input
|
|
209
|
+
id={`cf-${field.key}`}
|
|
210
|
+
type="file"
|
|
211
|
+
accept={ACCEPTED_IMAGE_TYPES}
|
|
212
|
+
disabled={isUploading || !onUploadFile}
|
|
213
|
+
className="hidden"
|
|
214
|
+
onChange={async (e) => {
|
|
215
|
+
const file = e.target.files?.[0];
|
|
216
|
+
if (!file || !onUploadFile) return;
|
|
217
|
+
if (file.size > MAX_IMAGE_SIZE) {
|
|
218
|
+
alert(t('customFieldsImageTooLarge'));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
setUploadingKeys((prev) => new Set(prev).add(field.key));
|
|
222
|
+
try {
|
|
223
|
+
const result = await onUploadFile(file);
|
|
224
|
+
onChange(field.key, result.url);
|
|
225
|
+
} catch {
|
|
226
|
+
// Upload failed — user can retry
|
|
227
|
+
} finally {
|
|
228
|
+
setUploadingKeys((prev) => {
|
|
229
|
+
const next = new Set(prev);
|
|
230
|
+
next.delete(field.key);
|
|
231
|
+
return next;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}}
|
|
235
|
+
/>
|
|
236
|
+
</label>
|
|
237
|
+
)}
|
|
238
|
+
{helpEl}
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
default:
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
})}
|
|
247
|
+
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
onClick={onApply}
|
|
251
|
+
disabled={loading || isMissing}
|
|
252
|
+
className="bg-primary text-primary-foreground w-full rounded px-4 py-3 text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
253
|
+
>
|
|
254
|
+
{loading ? t('customFieldsApplying') : t('customFieldsApply')}
|
|
255
|
+
</button>
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
|
4
4
|
import type { PaymentIntent, PaymentClientSdk } from 'brainerce';
|
|
5
|
+
import { formatPrice } from 'brainerce';
|
|
5
6
|
import { getClient } from '@/lib/brainerce';
|
|
6
7
|
import { useTranslations } from '@/lib/translations';
|
|
7
8
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
9
|
+
import { useStoreInfo } from '@/providers/store-provider';
|
|
8
10
|
import { cn } from '@/lib/utils';
|
|
11
|
+
import { isAllowedPaymentUrl, isValidCheckoutId, safePaymentRedirect } from '@/lib/safe-redirect';
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
14
|
* Backward-compat defaults when backend doesn't return clientSdk.
|
|
@@ -67,6 +70,18 @@ function extractMessage(response: unknown): string {
|
|
|
67
70
|
|
|
68
71
|
export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
69
72
|
const t = useTranslations('checkout');
|
|
73
|
+
const { storeInfo } = useStoreInfo();
|
|
74
|
+
|
|
75
|
+
// Defense in depth: the parent already validates checkoutId from URL params,
|
|
76
|
+
// but we re-check here so the component is safe to render in any context.
|
|
77
|
+
if (!isValidCheckoutId(checkoutId)) {
|
|
78
|
+
return (
|
|
79
|
+
<div className={cn('rounded-md border border-destructive/50 p-4', className)}>
|
|
80
|
+
<p className="text-sm text-destructive">{t('paymentError')}</p>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
const [paymentIntent, setPaymentIntent] = useState<PaymentIntent | null>(null);
|
|
71
86
|
const [preloadedSdk, setPreloadedSdk] = useState<PaymentClientSdk | null>(null);
|
|
72
87
|
const [loading, setLoading] = useState(true);
|
|
@@ -351,13 +366,21 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
351
366
|
if (sdk.renderType === 'sandbox') return;
|
|
352
367
|
|
|
353
368
|
if (sdk.renderType === 'redirect') {
|
|
354
|
-
|
|
369
|
+
if (!isAllowedPaymentUrl(intent.clientSecret)) {
|
|
370
|
+
setError(t('paymentRedirectBlocked'));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
safePaymentRedirect(intent.clientSecret);
|
|
355
374
|
return;
|
|
356
375
|
}
|
|
357
376
|
|
|
358
377
|
// Iframe mode: listen for postMessage from the /payment-complete callback
|
|
359
378
|
// page that loads inside the iframe after the provider redirects on completion.
|
|
360
379
|
if (sdk.renderType === 'iframe') {
|
|
380
|
+
if (!isAllowedPaymentUrl(intent.clientSecret)) {
|
|
381
|
+
setError(t('paymentRedirectBlocked'));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
361
384
|
const handleMessage = (event: MessageEvent) => {
|
|
362
385
|
if (event.origin !== window.location.origin) return;
|
|
363
386
|
if (event.data?.type !== 'brainerce:payment-complete') return;
|
|
@@ -533,38 +556,79 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
533
556
|
}
|
|
534
557
|
|
|
535
558
|
if (sdk.renderType === 'iframe') {
|
|
559
|
+
if (!isAllowedPaymentUrl(paymentIntent.clientSecret)) return null;
|
|
560
|
+
const formattedAmount = formatPrice(Number(paymentIntent.amount) || 0, {
|
|
561
|
+
currency: paymentIntent.currency,
|
|
562
|
+
}) as string;
|
|
536
563
|
return (
|
|
537
564
|
<>
|
|
538
565
|
{/* Modal overlay */}
|
|
539
566
|
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 py-6 backdrop-blur-sm">
|
|
540
|
-
<div className="relative mx-4 w-full max-w-
|
|
541
|
-
{/*
|
|
542
|
-
<
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
567
|
+
<div className="bg-background relative mx-4 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl shadow-2xl">
|
|
568
|
+
{/* Header */}
|
|
569
|
+
<div className="border-border flex items-center justify-between gap-4 border-b px-5 py-4">
|
|
570
|
+
<div className="flex min-w-0 flex-col">
|
|
571
|
+
<span className="text-foreground truncate text-sm font-semibold">
|
|
572
|
+
{storeInfo?.name}
|
|
573
|
+
</span>
|
|
574
|
+
<span className="text-muted-foreground text-xs">{t('payment')}</span>
|
|
575
|
+
</div>
|
|
576
|
+
<div className="flex items-baseline gap-1.5">
|
|
577
|
+
<span className="text-foreground text-lg font-bold tabular-nums">
|
|
578
|
+
{formattedAmount}
|
|
579
|
+
</span>
|
|
580
|
+
<span className="text-muted-foreground text-xs uppercase">
|
|
581
|
+
{paymentIntent.currency}
|
|
582
|
+
</span>
|
|
583
|
+
</div>
|
|
584
|
+
<button
|
|
585
|
+
onClick={() => {
|
|
586
|
+
window.location.href = `/checkout?checkout_id=${checkoutId}&canceled=true`;
|
|
587
|
+
}}
|
|
588
|
+
className="text-muted-foreground hover:bg-secondary hover:text-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors"
|
|
589
|
+
aria-label="Close"
|
|
590
|
+
>
|
|
591
|
+
<svg
|
|
592
|
+
width="14"
|
|
593
|
+
height="14"
|
|
594
|
+
viewBox="0 0 14 14"
|
|
595
|
+
fill="none"
|
|
596
|
+
stroke="currentColor"
|
|
597
|
+
strokeWidth="2"
|
|
598
|
+
strokeLinecap="round"
|
|
599
|
+
>
|
|
600
|
+
<path d="M1 1l12 12M13 1L1 13" />
|
|
601
|
+
</svg>
|
|
602
|
+
</button>
|
|
603
|
+
</div>
|
|
604
|
+
{/* Iframe body */}
|
|
605
|
+
<iframe
|
|
606
|
+
src={paymentIntent.clientSecret}
|
|
607
|
+
className="w-full border-0"
|
|
608
|
+
style={{ height: '70vh' }}
|
|
609
|
+
title={t('payment')}
|
|
610
|
+
allow="payment"
|
|
611
|
+
/>
|
|
612
|
+
{/* Footer */}
|
|
613
|
+
<div className="border-border bg-secondary/30 text-muted-foreground flex items-center justify-center gap-2 border-t px-5 py-3 text-xs">
|
|
549
614
|
<svg
|
|
550
615
|
width="14"
|
|
551
616
|
height="14"
|
|
552
|
-
viewBox="0 0
|
|
617
|
+
viewBox="0 0 24 24"
|
|
553
618
|
fill="none"
|
|
554
619
|
stroke="currentColor"
|
|
555
620
|
strokeWidth="2"
|
|
556
621
|
strokeLinecap="round"
|
|
622
|
+
strokeLinejoin="round"
|
|
623
|
+
aria-hidden="true"
|
|
557
624
|
>
|
|
558
|
-
<path d="
|
|
625
|
+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
|
626
|
+
<path d="m9 12 2 2 4-4" />
|
|
559
627
|
</svg>
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
style={{ height: '80vh' }}
|
|
565
|
-
title={t('payment')}
|
|
566
|
-
allow="payment"
|
|
567
|
-
/>
|
|
628
|
+
<span>
|
|
629
|
+
{t('securePayment')} · <span className="font-medium">Brainerce</span>
|
|
630
|
+
</span>
|
|
631
|
+
</div>
|
|
568
632
|
</div>
|
|
569
633
|
</div>
|
|
570
634
|
{/* Placeholder so the checkout layout doesn't collapse */}
|