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.
Files changed (35) hide show
  1. package/dist/index.js +95 -22
  2. package/messages/en.json +12 -1
  3. package/messages/he.json +12 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/.env.local.ejs +3 -3
  6. package/templates/nextjs/base/next.config.ts +13 -12
  7. package/templates/nextjs/base/package.json.ejs +2 -1
  8. package/templates/nextjs/base/src/app/api/auth/logout/route.ts +15 -14
  9. package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +66 -59
  10. package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +76 -77
  11. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +229 -198
  12. package/templates/nextjs/base/src/app/checkout/page.tsx +975 -972
  13. package/templates/nextjs/base/src/app/layout.tsx.ejs +29 -13
  14. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +271 -271
  15. package/templates/nextjs/base/src/app/payment-complete/page.tsx +59 -59
  16. package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +501 -486
  17. package/templates/nextjs/base/src/app/products/page.tsx +475 -475
  18. package/templates/nextjs/base/src/app/reset-password/page.tsx +138 -131
  19. package/templates/nextjs/base/src/components/auth/register-form.tsx +245 -232
  20. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +416 -415
  21. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +258 -184
  22. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +84 -20
  23. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +86 -72
  24. package/templates/nextjs/base/src/lib/csrf.ts +11 -0
  25. package/templates/nextjs/base/src/lib/navigation.tsx.ejs +60 -60
  26. package/templates/nextjs/base/src/lib/nonce.ts +10 -0
  27. package/templates/nextjs/base/src/lib/safe-redirect.ts +45 -0
  28. package/templates/nextjs/base/src/lib/sanitize-html.ts +93 -0
  29. package/templates/nextjs/base/src/lib/validation.ts +37 -0
  30. package/templates/nextjs/base/src/middleware.ts.ejs +91 -8
  31. package/templates/nextjs/base/tsconfig.tsbuildinfo +1 -0
  32. package/templates/nextjs/themes/luxury/globals.css +399 -399
  33. package/templates/nextjs/themes/luxury/theme.json +23 -23
  34. package/templates/nextjs/themes/playful/globals.css +400 -400
  35. package/templates/nextjs/themes/playful/theme.json +23 -23
@@ -1,184 +1,258 @@
1
- 'use client';
2
-
3
- import type { CheckoutCustomFieldDefinition } from 'brainerce';
4
- import { useTranslations } from '@/lib/translations';
5
- import { cn } from '@/lib/utils';
6
-
7
- interface CustomFieldsStepProps {
8
- fields: CheckoutCustomFieldDefinition[];
9
- values: Record<string, unknown>;
10
- onChange: (key: string, value: unknown) => void;
11
- onApply: () => void;
12
- loading?: boolean;
13
- className?: string;
14
- }
15
-
16
- export function CustomFieldsStep({
17
- fields,
18
- values,
19
- onChange,
20
- onApply,
21
- loading = false,
22
- className,
23
- }: CustomFieldsStepProps) {
24
- const t = useTranslations('checkout');
25
-
26
- const isMissing = fields.some((f) => {
27
- if (!f.required) return false;
28
- const v = values[f.key];
29
- return v === undefined || v === null || v === '';
30
- });
31
-
32
- return (
33
- <div className={cn('space-y-4', className)}>
34
- <p className="text-muted-foreground text-sm">{t('customFieldsSubtitle')}</p>
35
-
36
- {fields.map((field) => {
37
- const value = values[field.key];
38
- const labelEl = (
39
- <label
40
- htmlFor={`cf-${field.key}`}
41
- className="text-foreground mb-1 block text-sm font-medium"
42
- >
43
- {field.name}
44
- {field.required && <span className="text-destructive ms-1">*</span>}
45
- </label>
46
- );
47
- const helpEl = field.description ? (
48
- <p className="text-muted-foreground mt-1 text-xs">{field.description}</p>
49
- ) : null;
50
-
51
- switch (field.type) {
52
- case 'TEXT':
53
- return (
54
- <div key={field.key}>
55
- {labelEl}
56
- <input
57
- id={`cf-${field.key}`}
58
- type="text"
59
- value={(value as string) ?? ''}
60
- onChange={(e) => onChange(field.key, e.target.value)}
61
- required={field.required}
62
- minLength={field.minLength ?? undefined}
63
- maxLength={field.maxLength ?? undefined}
64
- className="border-border bg-background text-foreground w-full rounded border px-3 py-2 text-sm"
65
- />
66
- {helpEl}
67
- </div>
68
- );
69
-
70
- case 'TEXTAREA':
71
- return (
72
- <div key={field.key}>
73
- {labelEl}
74
- <textarea
75
- id={`cf-${field.key}`}
76
- value={(value as string) ?? ''}
77
- onChange={(e) => onChange(field.key, e.target.value)}
78
- required={field.required}
79
- minLength={field.minLength ?? undefined}
80
- maxLength={field.maxLength ?? undefined}
81
- rows={3}
82
- className="border-border bg-background text-foreground w-full rounded border px-3 py-2 text-sm"
83
- />
84
- {helpEl}
85
- </div>
86
- );
87
-
88
- case 'NUMBER':
89
- return (
90
- <div key={field.key}>
91
- {labelEl}
92
- <input
93
- id={`cf-${field.key}`}
94
- type="number"
95
- value={(value as number | string) ?? ''}
96
- onChange={(e) =>
97
- onChange(field.key, e.target.value === '' ? '' : Number(e.target.value))
98
- }
99
- required={field.required}
100
- min={field.minValue ?? undefined}
101
- max={field.maxValue ?? undefined}
102
- className="border-border bg-background text-foreground w-full rounded border px-3 py-2 text-sm"
103
- />
104
- {helpEl}
105
- </div>
106
- );
107
-
108
- case 'BOOLEAN':
109
- return (
110
- <div key={field.key} className="flex items-start gap-2">
111
- <input
112
- id={`cf-${field.key}`}
113
- type="checkbox"
114
- checked={value === true}
115
- onChange={(e) => onChange(field.key, e.target.checked)}
116
- className="mt-1"
117
- />
118
- <div className="flex-1">
119
- <label
120
- htmlFor={`cf-${field.key}`}
121
- className="text-foreground text-sm font-medium"
122
- >
123
- {field.name}
124
- {field.required && <span className="text-destructive ms-1">*</span>}
125
- </label>
126
- {helpEl}
127
- </div>
128
- </div>
129
- );
130
-
131
- case 'SELECT':
132
- return (
133
- <div key={field.key}>
134
- {labelEl}
135
- <select
136
- id={`cf-${field.key}`}
137
- value={(value as string) ?? ''}
138
- onChange={(e) => onChange(field.key, e.target.value)}
139
- required={field.required}
140
- className="border-border bg-background text-foreground w-full rounded border px-3 py-2 text-sm"
141
- >
142
- <option value="">{t('customFieldsSelectPlaceholder')}</option>
143
- {field.options?.map((opt) => (
144
- <option key={opt.value} value={opt.value}>
145
- {opt.label}
146
- </option>
147
- ))}
148
- </select>
149
- {helpEl}
150
- </div>
151
- );
152
-
153
- case 'DATE':
154
- return (
155
- <div key={field.key}>
156
- {labelEl}
157
- <input
158
- id={`cf-${field.key}`}
159
- type="date"
160
- value={(value as string) ?? ''}
161
- onChange={(e) => onChange(field.key, e.target.value)}
162
- required={field.required}
163
- className="border-border bg-background text-foreground w-full rounded border px-3 py-2 text-sm"
164
- />
165
- {helpEl}
166
- </div>
167
- );
168
-
169
- default:
170
- return null;
171
- }
172
- })}
173
-
174
- <button
175
- type="button"
176
- onClick={onApply}
177
- disabled={loading || isMissing}
178
- 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"
179
- >
180
- {loading ? t('customFieldsApplying') : t('customFieldsApply')}
181
- </button>
182
- </div>
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
- window.location.href = intent.clientSecret;
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-md rounded-2xl bg-white shadow-2xl">
541
- {/* Close button */}
542
- <button
543
- onClick={() => {
544
- window.location.href = `/checkout?checkout_id=${checkoutId}&canceled=true`;
545
- }}
546
- className="absolute end-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-gray-500 shadow-sm transition-colors hover:bg-gray-100 hover:text-gray-700"
547
- aria-label="Close"
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 14 14"
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="M1 1l12 12M13 1L1 13" />
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
- </button>
561
- <iframe
562
- src={paymentIntent.clientSecret}
563
- className="w-full rounded-2xl border-0"
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 */}