create-brainerce-store 1.4.1 → 1.5.1
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 +1 -1
- package/messages/en.json +9 -1
- package/messages/he.json +9 -1
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/account/page.tsx +8 -4
- package/templates/nextjs/base/src/app/auth/callback/page.tsx +90 -90
- package/templates/nextjs/base/src/app/cart/page.tsx +110 -110
- package/templates/nextjs/base/src/app/checkout/page.tsx +614 -614
- package/templates/nextjs/base/src/app/login/page.tsx +58 -58
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +193 -193
- package/templates/nextjs/base/src/app/page.tsx +98 -98
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +435 -435
- package/templates/nextjs/base/src/app/products/page.tsx +246 -246
- package/templates/nextjs/base/src/app/register/page.tsx +68 -68
- package/templates/nextjs/base/src/app/verify-email/page.tsx +293 -293
- package/templates/nextjs/base/src/components/account/order-history.tsx +198 -198
- package/templates/nextjs/base/src/components/account/profile-section.tsx +189 -40
- package/templates/nextjs/base/src/components/auth/login-form.tsx +94 -94
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +137 -137
- package/templates/nextjs/base/src/components/auth/register-form.tsx +184 -184
- package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
- package/templates/nextjs/base/src/components/cart/cart-summary.tsx +70 -70
- package/templates/nextjs/base/src/components/cart/coupon-input.tsx +134 -134
- package/templates/nextjs/base/src/components/cart/reservation-countdown.tsx +103 -103
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +305 -305
- package/templates/nextjs/base/src/components/checkout/delivery-method-step.tsx +64 -64
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +350 -344
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +199 -199
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +110 -110
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +65 -65
- package/templates/nextjs/base/src/components/layout/footer.tsx +38 -38
- package/templates/nextjs/base/src/components/layout/header.tsx +332 -332
- package/templates/nextjs/base/src/components/products/product-card.tsx +96 -96
- package/templates/nextjs/base/src/components/products/product-grid.tsx +35 -35
- package/templates/nextjs/base/src/components/shared/loading-spinner.tsx +32 -32
- package/templates/nextjs/base/src/lib/translations.ts +11 -11
- package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +5 -1
|
@@ -1,199 +1,199 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import type { PickupLocation } from 'brainerce';
|
|
5
|
-
import { formatPrice } from 'brainerce';
|
|
6
|
-
import { useTranslations } from '@/lib/translations';
|
|
7
|
-
import { useStoreInfo } from '@/providers/store-provider';
|
|
8
|
-
import { cn } from '@/lib/utils';
|
|
9
|
-
|
|
10
|
-
interface PickupStepProps {
|
|
11
|
-
locations: PickupLocation[];
|
|
12
|
-
onSelect: (
|
|
13
|
-
locationId: string,
|
|
14
|
-
customerInfo: { email: string; firstName?: string; lastName?: string; phone?: string }
|
|
15
|
-
) => void;
|
|
16
|
-
loading?: boolean;
|
|
17
|
-
initialEmail?: string;
|
|
18
|
-
className?: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function PickupStep({
|
|
22
|
-
locations,
|
|
23
|
-
onSelect,
|
|
24
|
-
loading = false,
|
|
25
|
-
initialEmail = '',
|
|
26
|
-
className,
|
|
27
|
-
}: PickupStepProps) {
|
|
28
|
-
const t = useTranslations('checkout');
|
|
29
|
-
const tf = useTranslations('checkoutForm');
|
|
30
|
-
const tc = useTranslations('common');
|
|
31
|
-
const { storeInfo } = useStoreInfo();
|
|
32
|
-
const currency = storeInfo?.currency || 'USD';
|
|
33
|
-
|
|
34
|
-
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
35
|
-
const [email, setEmail] = useState(initialEmail);
|
|
36
|
-
const [firstName, setFirstName] = useState('');
|
|
37
|
-
const [lastName, setLastName] = useState('');
|
|
38
|
-
const [phone, setPhone] = useState('');
|
|
39
|
-
const [error, setError] = useState<string | null>(null);
|
|
40
|
-
|
|
41
|
-
const handleSubmit = (e: React.FormEvent) => {
|
|
42
|
-
e.preventDefault();
|
|
43
|
-
|
|
44
|
-
if (!selectedId) {
|
|
45
|
-
setError(t('pickupLocationRequired'));
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
if (!email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
49
|
-
setError(tf('emailInvalid'));
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
setError(null);
|
|
54
|
-
onSelect(selectedId, {
|
|
55
|
-
email: email.trim(),
|
|
56
|
-
firstName: firstName.trim() || undefined,
|
|
57
|
-
lastName: lastName.trim() || undefined,
|
|
58
|
-
phone: phone.trim() || undefined,
|
|
59
|
-
});
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const inputClass =
|
|
63
|
-
'bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-10 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2';
|
|
64
|
-
|
|
65
|
-
return (
|
|
66
|
-
<form onSubmit={handleSubmit} className={cn('space-y-6', className)}>
|
|
67
|
-
{/* Pickup locations */}
|
|
68
|
-
<div className="space-y-3">
|
|
69
|
-
<p className="text-foreground text-sm font-medium">{t('selectPickupLocation')}</p>
|
|
70
|
-
{locations.map((loc) => {
|
|
71
|
-
const price = parseFloat(loc.price);
|
|
72
|
-
const isFree = price === 0;
|
|
73
|
-
const isSelected = selectedId === loc.id;
|
|
74
|
-
|
|
75
|
-
return (
|
|
76
|
-
<button
|
|
77
|
-
key={loc.id}
|
|
78
|
-
type="button"
|
|
79
|
-
onClick={() => {
|
|
80
|
-
setSelectedId(loc.id);
|
|
81
|
-
setError(null);
|
|
82
|
-
}}
|
|
83
|
-
className={cn(
|
|
84
|
-
'flex w-full items-start gap-4 rounded border px-4 py-3 text-start transition-colors',
|
|
85
|
-
isSelected
|
|
86
|
-
? 'border-primary bg-primary/5'
|
|
87
|
-
: 'border-border hover:border-muted-foreground'
|
|
88
|
-
)}
|
|
89
|
-
>
|
|
90
|
-
{/* Radio indicator */}
|
|
91
|
-
<div
|
|
92
|
-
className={cn(
|
|
93
|
-
'mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2',
|
|
94
|
-
isSelected ? 'border-primary' : 'border-muted-foreground/40'
|
|
95
|
-
)}
|
|
96
|
-
>
|
|
97
|
-
{isSelected && <div className="bg-primary h-2 w-2 rounded-full" />}
|
|
98
|
-
</div>
|
|
99
|
-
|
|
100
|
-
{/* Location info */}
|
|
101
|
-
<div className="min-w-0 flex-1">
|
|
102
|
-
<p className="text-foreground text-sm font-medium">{loc.name}</p>
|
|
103
|
-
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
104
|
-
{loc.address.line1}
|
|
105
|
-
{loc.address.city && `, ${loc.address.city}`}
|
|
106
|
-
</p>
|
|
107
|
-
{loc.hours && <p className="text-muted-foreground mt-0.5 text-xs">{loc.hours}</p>}
|
|
108
|
-
{loc.instructions && (
|
|
109
|
-
<p className="text-muted-foreground mt-1 text-xs italic">{loc.instructions}</p>
|
|
110
|
-
)}
|
|
111
|
-
</div>
|
|
112
|
-
|
|
113
|
-
{/* Price */}
|
|
114
|
-
<span
|
|
115
|
-
className={cn(
|
|
116
|
-
'flex-shrink-0 text-sm font-medium',
|
|
117
|
-
isFree ? 'text-primary' : 'text-foreground'
|
|
118
|
-
)}
|
|
119
|
-
>
|
|
120
|
-
{isFree ? tc('free') : (formatPrice(price, { currency }) as string)}
|
|
121
|
-
</span>
|
|
122
|
-
</button>
|
|
123
|
-
);
|
|
124
|
-
})}
|
|
125
|
-
</div>
|
|
126
|
-
|
|
127
|
-
{/* Customer info */}
|
|
128
|
-
<div className="space-y-4">
|
|
129
|
-
<p className="text-foreground text-sm font-medium">{t('yourDetails')}</p>
|
|
130
|
-
|
|
131
|
-
<div>
|
|
132
|
-
<label htmlFor="pickup-email" className="text-foreground mb-1 block text-sm">
|
|
133
|
-
{tf('email')} <span className="text-destructive">*</span>
|
|
134
|
-
</label>
|
|
135
|
-
<input
|
|
136
|
-
id="pickup-email"
|
|
137
|
-
type="email"
|
|
138
|
-
value={email}
|
|
139
|
-
onChange={(e) => setEmail(e.target.value)}
|
|
140
|
-
className={cn(inputClass, 'border-border')}
|
|
141
|
-
placeholder="your@email.com"
|
|
142
|
-
required
|
|
143
|
-
/>
|
|
144
|
-
</div>
|
|
145
|
-
|
|
146
|
-
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
147
|
-
<div>
|
|
148
|
-
<label htmlFor="pickup-firstName" className="text-foreground mb-1 block text-sm">
|
|
149
|
-
{tf('firstName')}
|
|
150
|
-
</label>
|
|
151
|
-
<input
|
|
152
|
-
id="pickup-firstName"
|
|
153
|
-
type="text"
|
|
154
|
-
value={firstName}
|
|
155
|
-
onChange={(e) => setFirstName(e.target.value)}
|
|
156
|
-
className={cn(inputClass, 'border-border')}
|
|
157
|
-
/>
|
|
158
|
-
</div>
|
|
159
|
-
<div>
|
|
160
|
-
<label htmlFor="pickup-lastName" className="text-foreground mb-1 block text-sm">
|
|
161
|
-
{tf('lastName')}
|
|
162
|
-
</label>
|
|
163
|
-
<input
|
|
164
|
-
id="pickup-lastName"
|
|
165
|
-
type="text"
|
|
166
|
-
value={lastName}
|
|
167
|
-
onChange={(e) => setLastName(e.target.value)}
|
|
168
|
-
className={cn(inputClass, 'border-border')}
|
|
169
|
-
/>
|
|
170
|
-
</div>
|
|
171
|
-
</div>
|
|
172
|
-
|
|
173
|
-
<div>
|
|
174
|
-
<label htmlFor="pickup-phone" className="text-foreground mb-1 block text-sm">
|
|
175
|
-
{tf('phone')}
|
|
176
|
-
</label>
|
|
177
|
-
<input
|
|
178
|
-
id="pickup-phone"
|
|
179
|
-
type="tel"
|
|
180
|
-
value={phone}
|
|
181
|
-
onChange={(e) => setPhone(e.target.value)}
|
|
182
|
-
className={cn(inputClass, 'border-border')}
|
|
183
|
-
placeholder={tf('phonePlaceholder')}
|
|
184
|
-
/>
|
|
185
|
-
</div>
|
|
186
|
-
</div>
|
|
187
|
-
|
|
188
|
-
{error && <p className="text-destructive text-sm">{error}</p>}
|
|
189
|
-
|
|
190
|
-
<button
|
|
191
|
-
type="submit"
|
|
192
|
-
disabled={loading || !selectedId}
|
|
193
|
-
className="bg-primary text-primary-foreground w-full rounded px-6 py-3 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
|
194
|
-
>
|
|
195
|
-
{loading ? tc('saving') : t('continueToPayment')}
|
|
196
|
-
</button>
|
|
197
|
-
</form>
|
|
198
|
-
);
|
|
199
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import type { PickupLocation } from 'brainerce';
|
|
5
|
+
import { formatPrice } from 'brainerce';
|
|
6
|
+
import { useTranslations } from '@/lib/translations';
|
|
7
|
+
import { useStoreInfo } from '@/providers/store-provider';
|
|
8
|
+
import { cn } from '@/lib/utils';
|
|
9
|
+
|
|
10
|
+
interface PickupStepProps {
|
|
11
|
+
locations: PickupLocation[];
|
|
12
|
+
onSelect: (
|
|
13
|
+
locationId: string,
|
|
14
|
+
customerInfo: { email: string; firstName?: string; lastName?: string; phone?: string }
|
|
15
|
+
) => void;
|
|
16
|
+
loading?: boolean;
|
|
17
|
+
initialEmail?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function PickupStep({
|
|
22
|
+
locations,
|
|
23
|
+
onSelect,
|
|
24
|
+
loading = false,
|
|
25
|
+
initialEmail = '',
|
|
26
|
+
className,
|
|
27
|
+
}: PickupStepProps) {
|
|
28
|
+
const t = useTranslations('checkout');
|
|
29
|
+
const tf = useTranslations('checkoutForm');
|
|
30
|
+
const tc = useTranslations('common');
|
|
31
|
+
const { storeInfo } = useStoreInfo();
|
|
32
|
+
const currency = storeInfo?.currency || 'USD';
|
|
33
|
+
|
|
34
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
35
|
+
const [email, setEmail] = useState(initialEmail);
|
|
36
|
+
const [firstName, setFirstName] = useState('');
|
|
37
|
+
const [lastName, setLastName] = useState('');
|
|
38
|
+
const [phone, setPhone] = useState('');
|
|
39
|
+
const [error, setError] = useState<string | null>(null);
|
|
40
|
+
|
|
41
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
|
|
44
|
+
if (!selectedId) {
|
|
45
|
+
setError(t('pickupLocationRequired'));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (!email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
49
|
+
setError(tf('emailInvalid'));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
setError(null);
|
|
54
|
+
onSelect(selectedId, {
|
|
55
|
+
email: email.trim(),
|
|
56
|
+
firstName: firstName.trim() || undefined,
|
|
57
|
+
lastName: lastName.trim() || undefined,
|
|
58
|
+
phone: phone.trim() || undefined,
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const inputClass =
|
|
63
|
+
'bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-10 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2';
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<form onSubmit={handleSubmit} className={cn('space-y-6', className)}>
|
|
67
|
+
{/* Pickup locations */}
|
|
68
|
+
<div className="space-y-3">
|
|
69
|
+
<p className="text-foreground text-sm font-medium">{t('selectPickupLocation')}</p>
|
|
70
|
+
{locations.map((loc) => {
|
|
71
|
+
const price = parseFloat(loc.price);
|
|
72
|
+
const isFree = price === 0;
|
|
73
|
+
const isSelected = selectedId === loc.id;
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<button
|
|
77
|
+
key={loc.id}
|
|
78
|
+
type="button"
|
|
79
|
+
onClick={() => {
|
|
80
|
+
setSelectedId(loc.id);
|
|
81
|
+
setError(null);
|
|
82
|
+
}}
|
|
83
|
+
className={cn(
|
|
84
|
+
'flex w-full items-start gap-4 rounded border px-4 py-3 text-start transition-colors',
|
|
85
|
+
isSelected
|
|
86
|
+
? 'border-primary bg-primary/5'
|
|
87
|
+
: 'border-border hover:border-muted-foreground'
|
|
88
|
+
)}
|
|
89
|
+
>
|
|
90
|
+
{/* Radio indicator */}
|
|
91
|
+
<div
|
|
92
|
+
className={cn(
|
|
93
|
+
'mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2',
|
|
94
|
+
isSelected ? 'border-primary' : 'border-muted-foreground/40'
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
{isSelected && <div className="bg-primary h-2 w-2 rounded-full" />}
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
{/* Location info */}
|
|
101
|
+
<div className="min-w-0 flex-1">
|
|
102
|
+
<p className="text-foreground text-sm font-medium">{loc.name}</p>
|
|
103
|
+
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
104
|
+
{loc.address.line1}
|
|
105
|
+
{loc.address.city && `, ${loc.address.city}`}
|
|
106
|
+
</p>
|
|
107
|
+
{loc.hours && <p className="text-muted-foreground mt-0.5 text-xs">{loc.hours}</p>}
|
|
108
|
+
{loc.instructions && (
|
|
109
|
+
<p className="text-muted-foreground mt-1 text-xs italic">{loc.instructions}</p>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{/* Price */}
|
|
114
|
+
<span
|
|
115
|
+
className={cn(
|
|
116
|
+
'flex-shrink-0 text-sm font-medium',
|
|
117
|
+
isFree ? 'text-primary' : 'text-foreground'
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
{isFree ? tc('free') : (formatPrice(price, { currency }) as string)}
|
|
121
|
+
</span>
|
|
122
|
+
</button>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{/* Customer info */}
|
|
128
|
+
<div className="space-y-4">
|
|
129
|
+
<p className="text-foreground text-sm font-medium">{t('yourDetails')}</p>
|
|
130
|
+
|
|
131
|
+
<div>
|
|
132
|
+
<label htmlFor="pickup-email" className="text-foreground mb-1 block text-sm">
|
|
133
|
+
{tf('email')} <span className="text-destructive">*</span>
|
|
134
|
+
</label>
|
|
135
|
+
<input
|
|
136
|
+
id="pickup-email"
|
|
137
|
+
type="email"
|
|
138
|
+
value={email}
|
|
139
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
140
|
+
className={cn(inputClass, 'border-border')}
|
|
141
|
+
placeholder="your@email.com"
|
|
142
|
+
required
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
147
|
+
<div>
|
|
148
|
+
<label htmlFor="pickup-firstName" className="text-foreground mb-1 block text-sm">
|
|
149
|
+
{tf('firstName')}
|
|
150
|
+
</label>
|
|
151
|
+
<input
|
|
152
|
+
id="pickup-firstName"
|
|
153
|
+
type="text"
|
|
154
|
+
value={firstName}
|
|
155
|
+
onChange={(e) => setFirstName(e.target.value)}
|
|
156
|
+
className={cn(inputClass, 'border-border')}
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
<div>
|
|
160
|
+
<label htmlFor="pickup-lastName" className="text-foreground mb-1 block text-sm">
|
|
161
|
+
{tf('lastName')}
|
|
162
|
+
</label>
|
|
163
|
+
<input
|
|
164
|
+
id="pickup-lastName"
|
|
165
|
+
type="text"
|
|
166
|
+
value={lastName}
|
|
167
|
+
onChange={(e) => setLastName(e.target.value)}
|
|
168
|
+
className={cn(inputClass, 'border-border')}
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div>
|
|
174
|
+
<label htmlFor="pickup-phone" className="text-foreground mb-1 block text-sm">
|
|
175
|
+
{tf('phone')}
|
|
176
|
+
</label>
|
|
177
|
+
<input
|
|
178
|
+
id="pickup-phone"
|
|
179
|
+
type="tel"
|
|
180
|
+
value={phone}
|
|
181
|
+
onChange={(e) => setPhone(e.target.value)}
|
|
182
|
+
className={cn(inputClass, 'border-border')}
|
|
183
|
+
placeholder={tf('phonePlaceholder')}
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
{error && <p className="text-destructive text-sm">{error}</p>}
|
|
189
|
+
|
|
190
|
+
<button
|
|
191
|
+
type="submit"
|
|
192
|
+
disabled={loading || !selectedId}
|
|
193
|
+
className="bg-primary text-primary-foreground w-full rounded px-6 py-3 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
|
194
|
+
>
|
|
195
|
+
{loading ? tc('saving') : t('continueToPayment')}
|
|
196
|
+
</button>
|
|
197
|
+
</form>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -1,110 +1,110 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import type { ShippingRate } from 'brainerce';
|
|
4
|
-
import { formatPrice } from 'brainerce';
|
|
5
|
-
import { useTranslations } from '@/lib/translations';
|
|
6
|
-
import { useStoreInfo } from '@/providers/store-provider';
|
|
7
|
-
import { cn } from '@/lib/utils';
|
|
8
|
-
|
|
9
|
-
interface ShippingStepProps {
|
|
10
|
-
rates: ShippingRate[];
|
|
11
|
-
selectedRateId: string | null;
|
|
12
|
-
onSelect: (rateId: string) => void;
|
|
13
|
-
loading?: boolean;
|
|
14
|
-
className?: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function ShippingStep({
|
|
18
|
-
rates,
|
|
19
|
-
selectedRateId,
|
|
20
|
-
onSelect,
|
|
21
|
-
loading = false,
|
|
22
|
-
className,
|
|
23
|
-
}: ShippingStepProps) {
|
|
24
|
-
const t = useTranslations('checkout');
|
|
25
|
-
const tc = useTranslations('common');
|
|
26
|
-
const { storeInfo } = useStoreInfo();
|
|
27
|
-
const currency = storeInfo?.currency || 'USD';
|
|
28
|
-
|
|
29
|
-
if (rates.length === 0) {
|
|
30
|
-
return (
|
|
31
|
-
<div className={cn('py-8 text-center', className)}>
|
|
32
|
-
<svg
|
|
33
|
-
className="text-muted-foreground mx-auto mb-3 h-10 w-10"
|
|
34
|
-
fill="none"
|
|
35
|
-
viewBox="0 0 24 24"
|
|
36
|
-
stroke="currentColor"
|
|
37
|
-
>
|
|
38
|
-
<path
|
|
39
|
-
strokeLinecap="round"
|
|
40
|
-
strokeLinejoin="round"
|
|
41
|
-
strokeWidth={1.5}
|
|
42
|
-
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
|
43
|
-
/>
|
|
44
|
-
</svg>
|
|
45
|
-
<p className="text-muted-foreground text-sm">{t('noShippingOptions')}</p>
|
|
46
|
-
<p className="text-muted-foreground mt-1 text-xs">{t('noShippingOptionsHint')}</p>
|
|
47
|
-
</div>
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return (
|
|
52
|
-
<div className={cn('space-y-3', className)}>
|
|
53
|
-
{rates.map((rate) => {
|
|
54
|
-
const price = parseFloat(rate.price);
|
|
55
|
-
const isFree = price === 0;
|
|
56
|
-
const isSelected = selectedRateId === rate.id;
|
|
57
|
-
|
|
58
|
-
return (
|
|
59
|
-
<button
|
|
60
|
-
key={rate.id}
|
|
61
|
-
type="button"
|
|
62
|
-
onClick={() => onSelect(rate.id)}
|
|
63
|
-
disabled={loading}
|
|
64
|
-
className={cn(
|
|
65
|
-
'flex w-full items-center gap-4 rounded border px-4 py-3 text-start transition-colors',
|
|
66
|
-
isSelected
|
|
67
|
-
? 'border-primary bg-primary/5'
|
|
68
|
-
: 'border-border hover:border-muted-foreground',
|
|
69
|
-
loading && 'cursor-not-allowed opacity-60'
|
|
70
|
-
)}
|
|
71
|
-
>
|
|
72
|
-
{/* Radio indicator */}
|
|
73
|
-
<div
|
|
74
|
-
className={cn(
|
|
75
|
-
'flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2',
|
|
76
|
-
isSelected ? 'border-primary' : 'border-muted-foreground/40'
|
|
77
|
-
)}
|
|
78
|
-
>
|
|
79
|
-
{isSelected && <div className="bg-primary h-2 w-2 rounded-full" />}
|
|
80
|
-
</div>
|
|
81
|
-
|
|
82
|
-
{/* Rate info */}
|
|
83
|
-
<div className="min-w-0 flex-1">
|
|
84
|
-
<p className="text-foreground text-sm font-medium">{rate.name}</p>
|
|
85
|
-
{rate.description && (
|
|
86
|
-
<p className="text-muted-foreground mt-0.5 text-xs">{rate.description}</p>
|
|
87
|
-
)}
|
|
88
|
-
{rate.estimatedDays != null && (
|
|
89
|
-
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
90
|
-
{t('estimatedDelivery')} {rate.estimatedDays}{' '}
|
|
91
|
-
{rate.estimatedDays === 1 ? tc('day') : tc('days')}
|
|
92
|
-
</p>
|
|
93
|
-
)}
|
|
94
|
-
</div>
|
|
95
|
-
|
|
96
|
-
{/* Price */}
|
|
97
|
-
<span
|
|
98
|
-
className={cn(
|
|
99
|
-
'flex-shrink-0 text-sm font-medium',
|
|
100
|
-
isFree ? 'text-primary' : 'text-foreground'
|
|
101
|
-
)}
|
|
102
|
-
>
|
|
103
|
-
{isFree ? tc('free') : (formatPrice(price, { currency }) as string)}
|
|
104
|
-
</span>
|
|
105
|
-
</button>
|
|
106
|
-
);
|
|
107
|
-
})}
|
|
108
|
-
</div>
|
|
109
|
-
);
|
|
110
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ShippingRate } from 'brainerce';
|
|
4
|
+
import { formatPrice } from 'brainerce';
|
|
5
|
+
import { useTranslations } from '@/lib/translations';
|
|
6
|
+
import { useStoreInfo } from '@/providers/store-provider';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
8
|
+
|
|
9
|
+
interface ShippingStepProps {
|
|
10
|
+
rates: ShippingRate[];
|
|
11
|
+
selectedRateId: string | null;
|
|
12
|
+
onSelect: (rateId: string) => void;
|
|
13
|
+
loading?: boolean;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ShippingStep({
|
|
18
|
+
rates,
|
|
19
|
+
selectedRateId,
|
|
20
|
+
onSelect,
|
|
21
|
+
loading = false,
|
|
22
|
+
className,
|
|
23
|
+
}: ShippingStepProps) {
|
|
24
|
+
const t = useTranslations('checkout');
|
|
25
|
+
const tc = useTranslations('common');
|
|
26
|
+
const { storeInfo } = useStoreInfo();
|
|
27
|
+
const currency = storeInfo?.currency || 'USD';
|
|
28
|
+
|
|
29
|
+
if (rates.length === 0) {
|
|
30
|
+
return (
|
|
31
|
+
<div className={cn('py-8 text-center', className)}>
|
|
32
|
+
<svg
|
|
33
|
+
className="text-muted-foreground mx-auto mb-3 h-10 w-10"
|
|
34
|
+
fill="none"
|
|
35
|
+
viewBox="0 0 24 24"
|
|
36
|
+
stroke="currentColor"
|
|
37
|
+
>
|
|
38
|
+
<path
|
|
39
|
+
strokeLinecap="round"
|
|
40
|
+
strokeLinejoin="round"
|
|
41
|
+
strokeWidth={1.5}
|
|
42
|
+
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
|
43
|
+
/>
|
|
44
|
+
</svg>
|
|
45
|
+
<p className="text-muted-foreground text-sm">{t('noShippingOptions')}</p>
|
|
46
|
+
<p className="text-muted-foreground mt-1 text-xs">{t('noShippingOptionsHint')}</p>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className={cn('space-y-3', className)}>
|
|
53
|
+
{rates.map((rate) => {
|
|
54
|
+
const price = parseFloat(rate.price);
|
|
55
|
+
const isFree = price === 0;
|
|
56
|
+
const isSelected = selectedRateId === rate.id;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<button
|
|
60
|
+
key={rate.id}
|
|
61
|
+
type="button"
|
|
62
|
+
onClick={() => onSelect(rate.id)}
|
|
63
|
+
disabled={loading}
|
|
64
|
+
className={cn(
|
|
65
|
+
'flex w-full items-center gap-4 rounded border px-4 py-3 text-start transition-colors',
|
|
66
|
+
isSelected
|
|
67
|
+
? 'border-primary bg-primary/5'
|
|
68
|
+
: 'border-border hover:border-muted-foreground',
|
|
69
|
+
loading && 'cursor-not-allowed opacity-60'
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
{/* Radio indicator */}
|
|
73
|
+
<div
|
|
74
|
+
className={cn(
|
|
75
|
+
'flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2',
|
|
76
|
+
isSelected ? 'border-primary' : 'border-muted-foreground/40'
|
|
77
|
+
)}
|
|
78
|
+
>
|
|
79
|
+
{isSelected && <div className="bg-primary h-2 w-2 rounded-full" />}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{/* Rate info */}
|
|
83
|
+
<div className="min-w-0 flex-1">
|
|
84
|
+
<p className="text-foreground text-sm font-medium">{rate.name}</p>
|
|
85
|
+
{rate.description && (
|
|
86
|
+
<p className="text-muted-foreground mt-0.5 text-xs">{rate.description}</p>
|
|
87
|
+
)}
|
|
88
|
+
{rate.estimatedDays != null && (
|
|
89
|
+
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
90
|
+
{t('estimatedDelivery')} {rate.estimatedDays}{' '}
|
|
91
|
+
{rate.estimatedDays === 1 ? tc('day') : tc('days')}
|
|
92
|
+
</p>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{/* Price */}
|
|
97
|
+
<span
|
|
98
|
+
className={cn(
|
|
99
|
+
'flex-shrink-0 text-sm font-medium',
|
|
100
|
+
isFree ? 'text-primary' : 'text-foreground'
|
|
101
|
+
)}
|
|
102
|
+
>
|
|
103
|
+
{isFree ? tc('free') : (formatPrice(price, { currency }) as string)}
|
|
104
|
+
</span>
|
|
105
|
+
</button>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|