create-brainerce-store 1.0.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.d.ts +1 -0
- package/dist/index.js +502 -0
- package/package.json +44 -0
- package/templates/nextjs/base/.env.local.ejs +3 -0
- package/templates/nextjs/base/.eslintrc.json +3 -0
- package/templates/nextjs/base/gitignore +30 -0
- package/templates/nextjs/base/next.config.ts +9 -0
- package/templates/nextjs/base/package.json.ejs +30 -0
- package/templates/nextjs/base/postcss.config.mjs +9 -0
- package/templates/nextjs/base/src/app/account/page.tsx +105 -0
- package/templates/nextjs/base/src/app/auth/callback/page.tsx +99 -0
- package/templates/nextjs/base/src/app/cart/page.tsx +263 -0
- package/templates/nextjs/base/src/app/checkout/page.tsx +463 -0
- package/templates/nextjs/base/src/app/globals.css +30 -0
- package/templates/nextjs/base/src/app/layout.tsx.ejs +33 -0
- package/templates/nextjs/base/src/app/login/page.tsx +56 -0
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +191 -0
- package/templates/nextjs/base/src/app/page.tsx +95 -0
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +346 -0
- package/templates/nextjs/base/src/app/products/page.tsx +243 -0
- package/templates/nextjs/base/src/app/register/page.tsx +66 -0
- package/templates/nextjs/base/src/app/verify-email/page.tsx +291 -0
- package/templates/nextjs/base/src/components/account/order-history.tsx +184 -0
- package/templates/nextjs/base/src/components/account/profile-section.tsx +73 -0
- package/templates/nextjs/base/src/components/auth/login-form.tsx +92 -0
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +134 -0
- package/templates/nextjs/base/src/components/auth/register-form.tsx +177 -0
- package/templates/nextjs/base/src/components/cart/cart-item.tsx +150 -0
- package/templates/nextjs/base/src/components/cart/cart-nudges.tsx +39 -0
- package/templates/nextjs/base/src/components/cart/cart-summary.tsx +67 -0
- package/templates/nextjs/base/src/components/cart/coupon-input.tsx +131 -0
- package/templates/nextjs/base/src/components/cart/reservation-countdown.tsx +100 -0
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +273 -0
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +124 -0
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +111 -0
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +62 -0
- package/templates/nextjs/base/src/components/layout/footer.tsx +35 -0
- package/templates/nextjs/base/src/components/layout/header.tsx +329 -0
- package/templates/nextjs/base/src/components/products/discount-badge.tsx +36 -0
- package/templates/nextjs/base/src/components/products/product-card.tsx +94 -0
- package/templates/nextjs/base/src/components/products/product-grid.tsx +33 -0
- package/templates/nextjs/base/src/components/products/stock-badge.tsx +34 -0
- package/templates/nextjs/base/src/components/products/variant-selector.tsx +147 -0
- package/templates/nextjs/base/src/components/shared/loading-spinner.tsx +30 -0
- package/templates/nextjs/base/src/components/shared/price-display.tsx +62 -0
- package/templates/nextjs/base/src/hooks/use-search.ts +77 -0
- package/templates/nextjs/base/src/lib/brainerce.ts.ejs +59 -0
- package/templates/nextjs/base/src/lib/utils.ts +6 -0
- package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +168 -0
- package/templates/nextjs/base/tailwind.config.ts +30 -0
- package/templates/nextjs/base/tsconfig.json +23 -0
- package/templates/nextjs/themes/minimal/globals.css +30 -0
- package/templates/nextjs/themes/minimal/theme.json +23 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import type { SetShippingAddressDto } from 'brainerce';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
|
|
7
|
+
interface CheckoutFormProps {
|
|
8
|
+
onSubmit: (address: SetShippingAddressDto) => void;
|
|
9
|
+
loading?: boolean;
|
|
10
|
+
initialValues?: Partial<SetShippingAddressDto>;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function CheckoutForm({
|
|
15
|
+
onSubmit,
|
|
16
|
+
loading = false,
|
|
17
|
+
initialValues,
|
|
18
|
+
className,
|
|
19
|
+
}: CheckoutFormProps) {
|
|
20
|
+
const [formData, setFormData] = useState<SetShippingAddressDto>({
|
|
21
|
+
email: initialValues?.email || '',
|
|
22
|
+
firstName: initialValues?.firstName || '',
|
|
23
|
+
lastName: initialValues?.lastName || '',
|
|
24
|
+
line1: initialValues?.line1 || '',
|
|
25
|
+
line2: initialValues?.line2 || '',
|
|
26
|
+
city: initialValues?.city || '',
|
|
27
|
+
region: initialValues?.region || '',
|
|
28
|
+
postalCode: initialValues?.postalCode || '',
|
|
29
|
+
country: initialValues?.country || '',
|
|
30
|
+
phone: initialValues?.phone || '',
|
|
31
|
+
});
|
|
32
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
33
|
+
|
|
34
|
+
function validate(): boolean {
|
|
35
|
+
const newErrors: Record<string, string> = {};
|
|
36
|
+
|
|
37
|
+
if (!formData.email.trim()) {
|
|
38
|
+
newErrors.email = 'Email is required';
|
|
39
|
+
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
|
40
|
+
newErrors.email = 'Please enter a valid email';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!formData.firstName.trim()) {
|
|
44
|
+
newErrors.firstName = 'First name is required';
|
|
45
|
+
}
|
|
46
|
+
if (!formData.lastName.trim()) {
|
|
47
|
+
newErrors.lastName = 'Last name is required';
|
|
48
|
+
}
|
|
49
|
+
if (!formData.line1.trim()) {
|
|
50
|
+
newErrors.line1 = 'Address is required';
|
|
51
|
+
}
|
|
52
|
+
if (!formData.city.trim()) {
|
|
53
|
+
newErrors.city = 'City is required';
|
|
54
|
+
}
|
|
55
|
+
if (!formData.postalCode.trim()) {
|
|
56
|
+
newErrors.postalCode = 'Postal code is required';
|
|
57
|
+
}
|
|
58
|
+
if (!formData.country.trim()) {
|
|
59
|
+
newErrors.country = 'Country is required';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setErrors(newErrors);
|
|
63
|
+
return Object.keys(newErrors).length === 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function handleSubmit(e: React.FormEvent) {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
if (validate()) {
|
|
69
|
+
onSubmit(formData);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function updateField(field: keyof SetShippingAddressDto, value: string) {
|
|
74
|
+
setFormData((prev) => ({ ...prev, [field]: value }));
|
|
75
|
+
if (errors[field]) {
|
|
76
|
+
setErrors((prev) => {
|
|
77
|
+
const next = { ...prev };
|
|
78
|
+
delete next[field];
|
|
79
|
+
return next;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<form onSubmit={handleSubmit} className={cn('space-y-4', className)}>
|
|
86
|
+
{/* Email */}
|
|
87
|
+
<div>
|
|
88
|
+
<label htmlFor="email" className="text-foreground mb-1 block text-sm font-medium">
|
|
89
|
+
Email <span className="text-destructive">*</span>
|
|
90
|
+
</label>
|
|
91
|
+
<input
|
|
92
|
+
id="email"
|
|
93
|
+
type="email"
|
|
94
|
+
value={formData.email}
|
|
95
|
+
onChange={(e) => updateField('email', e.target.value)}
|
|
96
|
+
className={cn(
|
|
97
|
+
'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',
|
|
98
|
+
errors.email ? 'border-destructive' : 'border-border'
|
|
99
|
+
)}
|
|
100
|
+
placeholder="your@email.com"
|
|
101
|
+
/>
|
|
102
|
+
{errors.email && <p className="text-destructive mt-1 text-xs">{errors.email}</p>}
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{/* Name row */}
|
|
106
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
107
|
+
<div>
|
|
108
|
+
<label htmlFor="firstName" className="text-foreground mb-1 block text-sm font-medium">
|
|
109
|
+
First Name <span className="text-destructive">*</span>
|
|
110
|
+
</label>
|
|
111
|
+
<input
|
|
112
|
+
id="firstName"
|
|
113
|
+
type="text"
|
|
114
|
+
value={formData.firstName}
|
|
115
|
+
onChange={(e) => updateField('firstName', e.target.value)}
|
|
116
|
+
className={cn(
|
|
117
|
+
'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',
|
|
118
|
+
errors.firstName ? 'border-destructive' : 'border-border'
|
|
119
|
+
)}
|
|
120
|
+
/>
|
|
121
|
+
{errors.firstName && <p className="text-destructive mt-1 text-xs">{errors.firstName}</p>}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div>
|
|
125
|
+
<label htmlFor="lastName" className="text-foreground mb-1 block text-sm font-medium">
|
|
126
|
+
Last Name <span className="text-destructive">*</span>
|
|
127
|
+
</label>
|
|
128
|
+
<input
|
|
129
|
+
id="lastName"
|
|
130
|
+
type="text"
|
|
131
|
+
value={formData.lastName}
|
|
132
|
+
onChange={(e) => updateField('lastName', e.target.value)}
|
|
133
|
+
className={cn(
|
|
134
|
+
'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',
|
|
135
|
+
errors.lastName ? 'border-destructive' : 'border-border'
|
|
136
|
+
)}
|
|
137
|
+
/>
|
|
138
|
+
{errors.lastName && <p className="text-destructive mt-1 text-xs">{errors.lastName}</p>}
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
{/* Address line 1 */}
|
|
143
|
+
<div>
|
|
144
|
+
<label htmlFor="line1" className="text-foreground mb-1 block text-sm font-medium">
|
|
145
|
+
Address <span className="text-destructive">*</span>
|
|
146
|
+
</label>
|
|
147
|
+
<input
|
|
148
|
+
id="line1"
|
|
149
|
+
type="text"
|
|
150
|
+
value={formData.line1}
|
|
151
|
+
onChange={(e) => updateField('line1', e.target.value)}
|
|
152
|
+
className={cn(
|
|
153
|
+
'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',
|
|
154
|
+
errors.line1 ? 'border-destructive' : 'border-border'
|
|
155
|
+
)}
|
|
156
|
+
placeholder="Street address"
|
|
157
|
+
/>
|
|
158
|
+
{errors.line1 && <p className="text-destructive mt-1 text-xs">{errors.line1}</p>}
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
{/* Address line 2 */}
|
|
162
|
+
<div>
|
|
163
|
+
<label htmlFor="line2" className="text-foreground mb-1 block text-sm font-medium">
|
|
164
|
+
Apartment, suite, etc.
|
|
165
|
+
</label>
|
|
166
|
+
<input
|
|
167
|
+
id="line2"
|
|
168
|
+
type="text"
|
|
169
|
+
value={formData.line2 || ''}
|
|
170
|
+
onChange={(e) => updateField('line2', e.target.value)}
|
|
171
|
+
className="border-border 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"
|
|
172
|
+
placeholder="Apt, suite, unit, etc. (optional)"
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
{/* City + Region row */}
|
|
177
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
178
|
+
<div>
|
|
179
|
+
<label htmlFor="city" className="text-foreground mb-1 block text-sm font-medium">
|
|
180
|
+
City <span className="text-destructive">*</span>
|
|
181
|
+
</label>
|
|
182
|
+
<input
|
|
183
|
+
id="city"
|
|
184
|
+
type="text"
|
|
185
|
+
value={formData.city}
|
|
186
|
+
onChange={(e) => updateField('city', e.target.value)}
|
|
187
|
+
className={cn(
|
|
188
|
+
'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',
|
|
189
|
+
errors.city ? 'border-destructive' : 'border-border'
|
|
190
|
+
)}
|
|
191
|
+
/>
|
|
192
|
+
{errors.city && <p className="text-destructive mt-1 text-xs">{errors.city}</p>}
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div>
|
|
196
|
+
<label htmlFor="region" className="text-foreground mb-1 block text-sm font-medium">
|
|
197
|
+
State / Region
|
|
198
|
+
</label>
|
|
199
|
+
<input
|
|
200
|
+
id="region"
|
|
201
|
+
type="text"
|
|
202
|
+
value={formData.region || ''}
|
|
203
|
+
onChange={(e) => updateField('region', e.target.value)}
|
|
204
|
+
className="border-border 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"
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{/* Postal code + Country row */}
|
|
210
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
211
|
+
<div>
|
|
212
|
+
<label htmlFor="postalCode" className="text-foreground mb-1 block text-sm font-medium">
|
|
213
|
+
Postal Code <span className="text-destructive">*</span>
|
|
214
|
+
</label>
|
|
215
|
+
<input
|
|
216
|
+
id="postalCode"
|
|
217
|
+
type="text"
|
|
218
|
+
value={formData.postalCode}
|
|
219
|
+
onChange={(e) => updateField('postalCode', e.target.value)}
|
|
220
|
+
className={cn(
|
|
221
|
+
'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',
|
|
222
|
+
errors.postalCode ? 'border-destructive' : 'border-border'
|
|
223
|
+
)}
|
|
224
|
+
/>
|
|
225
|
+
{errors.postalCode && (
|
|
226
|
+
<p className="text-destructive mt-1 text-xs">{errors.postalCode}</p>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<div>
|
|
231
|
+
<label htmlFor="country" className="text-foreground mb-1 block text-sm font-medium">
|
|
232
|
+
Country <span className="text-destructive">*</span>
|
|
233
|
+
</label>
|
|
234
|
+
<input
|
|
235
|
+
id="country"
|
|
236
|
+
type="text"
|
|
237
|
+
value={formData.country}
|
|
238
|
+
onChange={(e) => updateField('country', e.target.value)}
|
|
239
|
+
className={cn(
|
|
240
|
+
'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',
|
|
241
|
+
errors.country ? 'border-destructive' : 'border-border'
|
|
242
|
+
)}
|
|
243
|
+
placeholder="e.g. US, IL, GB"
|
|
244
|
+
/>
|
|
245
|
+
{errors.country && <p className="text-destructive mt-1 text-xs">{errors.country}</p>}
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
{/* Phone */}
|
|
250
|
+
<div>
|
|
251
|
+
<label htmlFor="phone" className="text-foreground mb-1 block text-sm font-medium">
|
|
252
|
+
Phone
|
|
253
|
+
</label>
|
|
254
|
+
<input
|
|
255
|
+
id="phone"
|
|
256
|
+
type="tel"
|
|
257
|
+
value={formData.phone || ''}
|
|
258
|
+
onChange={(e) => updateField('phone', e.target.value)}
|
|
259
|
+
className="border-border 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"
|
|
260
|
+
placeholder="+1234567890 (optional)"
|
|
261
|
+
/>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<button
|
|
265
|
+
type="submit"
|
|
266
|
+
disabled={loading}
|
|
267
|
+
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"
|
|
268
|
+
>
|
|
269
|
+
{loading ? 'Saving...' : 'Continue to Shipping'}
|
|
270
|
+
</button>
|
|
271
|
+
</form>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import type { PaymentIntent } from 'brainerce';
|
|
5
|
+
import { getClient } from '@/lib/brainerce';
|
|
6
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
8
|
+
|
|
9
|
+
interface PaymentStepProps {
|
|
10
|
+
checkoutId: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
15
|
+
const [paymentIntent, setPaymentIntent] = useState<PaymentIntent | null>(null);
|
|
16
|
+
const [loading, setLoading] = useState(true);
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
async function createIntent() {
|
|
21
|
+
try {
|
|
22
|
+
setLoading(true);
|
|
23
|
+
setError(null);
|
|
24
|
+
const client = getClient();
|
|
25
|
+
|
|
26
|
+
const successUrl = `${window.location.origin}/order-confirmation?checkout_id=${checkoutId}`;
|
|
27
|
+
const cancelUrl = `${window.location.origin}/checkout?checkout_id=${checkoutId}&canceled=true`;
|
|
28
|
+
|
|
29
|
+
const intent = await client.createPaymentIntent(checkoutId, {
|
|
30
|
+
successUrl,
|
|
31
|
+
cancelUrl,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
setPaymentIntent(intent);
|
|
35
|
+
|
|
36
|
+
// Auto-redirect based on provider
|
|
37
|
+
if (intent.provider === 'stripe') {
|
|
38
|
+
// For Stripe with checkout sessions, clientSecret is the checkout URL
|
|
39
|
+
// Redirect to Stripe Checkout
|
|
40
|
+
window.location.href = intent.clientSecret;
|
|
41
|
+
} else if (intent.provider === 'grow') {
|
|
42
|
+
// Grow uses a payment page URL as clientSecret
|
|
43
|
+
window.location.href = intent.clientSecret;
|
|
44
|
+
}
|
|
45
|
+
} catch (err) {
|
|
46
|
+
const message = err instanceof Error ? err.message : 'Failed to create payment';
|
|
47
|
+
setError(message);
|
|
48
|
+
} finally {
|
|
49
|
+
setLoading(false);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
createIntent();
|
|
54
|
+
}, [checkoutId]);
|
|
55
|
+
|
|
56
|
+
if (loading) {
|
|
57
|
+
return (
|
|
58
|
+
<div className={cn('flex flex-col items-center justify-center py-12', className)}>
|
|
59
|
+
<LoadingSpinner size="lg" />
|
|
60
|
+
<p className="text-muted-foreground mt-4 text-sm">Preparing payment...</p>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (error) {
|
|
66
|
+
// Check if it's a "no payment provider" error
|
|
67
|
+
const isNotConfigured =
|
|
68
|
+
error.toLowerCase().includes('not configured') ||
|
|
69
|
+
error.toLowerCase().includes('no payment') ||
|
|
70
|
+
error.toLowerCase().includes('provider');
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className={cn('py-12 text-center', className)}>
|
|
74
|
+
<svg
|
|
75
|
+
className="text-muted-foreground mx-auto mb-4 h-12 w-12"
|
|
76
|
+
fill="none"
|
|
77
|
+
viewBox="0 0 24 24"
|
|
78
|
+
stroke="currentColor"
|
|
79
|
+
>
|
|
80
|
+
<path
|
|
81
|
+
strokeLinecap="round"
|
|
82
|
+
strokeLinejoin="round"
|
|
83
|
+
strokeWidth={1.5}
|
|
84
|
+
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
|
85
|
+
/>
|
|
86
|
+
</svg>
|
|
87
|
+
<h3 className="text-foreground mb-2 text-lg font-semibold">
|
|
88
|
+
{isNotConfigured ? 'Payment Not Configured' : 'Payment Error'}
|
|
89
|
+
</h3>
|
|
90
|
+
<p className="text-muted-foreground mx-auto max-w-md text-sm">
|
|
91
|
+
{isNotConfigured
|
|
92
|
+
? 'Payment has not been set up for this store yet. Please contact the store owner.'
|
|
93
|
+
: error}
|
|
94
|
+
</p>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Redirecting state
|
|
100
|
+
if (paymentIntent) {
|
|
101
|
+
const providerName =
|
|
102
|
+
paymentIntent.provider === 'stripe'
|
|
103
|
+
? 'Stripe'
|
|
104
|
+
: paymentIntent.provider === 'grow'
|
|
105
|
+
? 'payment provider'
|
|
106
|
+
: 'payment provider';
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className={cn('flex flex-col items-center justify-center py-12', className)}>
|
|
110
|
+
<LoadingSpinner size="lg" />
|
|
111
|
+
<p className="text-muted-foreground mt-4 text-sm">Redirecting to {providerName}...</p>
|
|
112
|
+
<p className="text-muted-foreground mt-2 text-xs">
|
|
113
|
+
If you are not redirected automatically,{' '}
|
|
114
|
+
<a href={paymentIntent.clientSecret} className="text-primary hover:underline">
|
|
115
|
+
click here
|
|
116
|
+
</a>
|
|
117
|
+
.
|
|
118
|
+
</p>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ShippingRate } from 'brainerce';
|
|
4
|
+
import { formatPrice } from 'brainerce';
|
|
5
|
+
import { useStoreInfo } from '@/providers/store-provider';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
|
|
8
|
+
interface ShippingStepProps {
|
|
9
|
+
rates: ShippingRate[];
|
|
10
|
+
selectedRateId: string | null;
|
|
11
|
+
onSelect: (rateId: string) => void;
|
|
12
|
+
loading?: boolean;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ShippingStep({
|
|
17
|
+
rates,
|
|
18
|
+
selectedRateId,
|
|
19
|
+
onSelect,
|
|
20
|
+
loading = false,
|
|
21
|
+
className,
|
|
22
|
+
}: ShippingStepProps) {
|
|
23
|
+
const { storeInfo } = useStoreInfo();
|
|
24
|
+
const currency = storeInfo?.currency || 'USD';
|
|
25
|
+
|
|
26
|
+
if (rates.length === 0) {
|
|
27
|
+
return (
|
|
28
|
+
<div className={cn('py-8 text-center', className)}>
|
|
29
|
+
<svg
|
|
30
|
+
className="text-muted-foreground mx-auto mb-3 h-10 w-10"
|
|
31
|
+
fill="none"
|
|
32
|
+
viewBox="0 0 24 24"
|
|
33
|
+
stroke="currentColor"
|
|
34
|
+
>
|
|
35
|
+
<path
|
|
36
|
+
strokeLinecap="round"
|
|
37
|
+
strokeLinejoin="round"
|
|
38
|
+
strokeWidth={1.5}
|
|
39
|
+
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
|
40
|
+
/>
|
|
41
|
+
</svg>
|
|
42
|
+
<p className="text-muted-foreground text-sm">
|
|
43
|
+
No shipping options available for this address.
|
|
44
|
+
</p>
|
|
45
|
+
<p className="text-muted-foreground mt-1 text-xs">
|
|
46
|
+
Please try a different address or contact support.
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className={cn('space-y-3', className)}>
|
|
54
|
+
{rates.map((rate) => {
|
|
55
|
+
const price = parseFloat(rate.price);
|
|
56
|
+
const isFree = price === 0;
|
|
57
|
+
const isSelected = selectedRateId === rate.id;
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<button
|
|
61
|
+
key={rate.id}
|
|
62
|
+
type="button"
|
|
63
|
+
onClick={() => onSelect(rate.id)}
|
|
64
|
+
disabled={loading}
|
|
65
|
+
className={cn(
|
|
66
|
+
'flex w-full items-center gap-4 rounded border px-4 py-3 text-start transition-colors',
|
|
67
|
+
isSelected
|
|
68
|
+
? 'border-primary bg-primary/5'
|
|
69
|
+
: 'border-border hover:border-muted-foreground',
|
|
70
|
+
loading && 'cursor-not-allowed opacity-60'
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
{/* Radio indicator */}
|
|
74
|
+
<div
|
|
75
|
+
className={cn(
|
|
76
|
+
'flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2',
|
|
77
|
+
isSelected ? 'border-primary' : 'border-muted-foreground/40'
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{isSelected && <div className="bg-primary h-2 w-2 rounded-full" />}
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* Rate info */}
|
|
84
|
+
<div className="min-w-0 flex-1">
|
|
85
|
+
<p className="text-foreground text-sm font-medium">{rate.name}</p>
|
|
86
|
+
{rate.description && (
|
|
87
|
+
<p className="text-muted-foreground mt-0.5 text-xs">{rate.description}</p>
|
|
88
|
+
)}
|
|
89
|
+
{rate.estimatedDays != null && (
|
|
90
|
+
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
91
|
+
Estimated delivery: {rate.estimatedDays}{' '}
|
|
92
|
+
{rate.estimatedDays === 1 ? 'day' : 'days'}
|
|
93
|
+
</p>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Price */}
|
|
98
|
+
<span
|
|
99
|
+
className={cn(
|
|
100
|
+
'flex-shrink-0 text-sm font-medium',
|
|
101
|
+
isFree ? 'text-primary' : 'text-foreground'
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
{isFree ? 'Free' : (formatPrice(price, { currency }) as string)}
|
|
105
|
+
</span>
|
|
106
|
+
</button>
|
|
107
|
+
);
|
|
108
|
+
})}
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { TaxBreakdown } from 'brainerce';
|
|
4
|
+
import { formatPrice } from 'brainerce';
|
|
5
|
+
import { useStoreInfo } from '@/providers/store-provider';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
|
|
8
|
+
interface TaxDisplayProps {
|
|
9
|
+
/** Whether shipping address has been set */
|
|
10
|
+
addressSet: boolean;
|
|
11
|
+
/** Tax amount string from checkout (only available after address is set) */
|
|
12
|
+
taxAmount?: string;
|
|
13
|
+
/** Detailed tax breakdown (optional) */
|
|
14
|
+
taxBreakdown?: TaxBreakdown | null;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function TaxDisplay({ addressSet, taxAmount, taxBreakdown, className }: TaxDisplayProps) {
|
|
19
|
+
const { storeInfo } = useStoreInfo();
|
|
20
|
+
const currency = storeInfo?.currency || 'USD';
|
|
21
|
+
|
|
22
|
+
// Before address is set
|
|
23
|
+
if (!addressSet) {
|
|
24
|
+
return (
|
|
25
|
+
<div className={cn('flex items-center justify-between text-sm', className)}>
|
|
26
|
+
<span className="text-muted-foreground">Tax</span>
|
|
27
|
+
<span className="text-muted-foreground text-xs">Calculated after address entry</span>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// After address, show tax amount
|
|
33
|
+
const tax = taxAmount ? parseFloat(taxAmount) : 0;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className={cn('space-y-1', className)}>
|
|
37
|
+
<div className="flex items-center justify-between text-sm">
|
|
38
|
+
<span className="text-muted-foreground">Tax</span>
|
|
39
|
+
<span className="text-foreground font-medium">
|
|
40
|
+
{tax > 0 ? (formatPrice(tax, { currency }) as string) : 'No tax'}
|
|
41
|
+
</span>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
{/* Tax breakdown details */}
|
|
45
|
+
{taxBreakdown && taxBreakdown.breakdown.length > 0 && tax > 0 && (
|
|
46
|
+
<div className="space-y-0.5 ps-4">
|
|
47
|
+
{taxBreakdown.breakdown.map((item, index) => (
|
|
48
|
+
<div
|
|
49
|
+
key={index}
|
|
50
|
+
className="text-muted-foreground flex items-center justify-between text-xs"
|
|
51
|
+
>
|
|
52
|
+
<span>
|
|
53
|
+
{item.name} ({(item.rate * 100).toFixed(1)}%)
|
|
54
|
+
</span>
|
|
55
|
+
<span>{formatPrice(item.amount, { currency }) as string}</span>
|
|
56
|
+
</div>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { useStoreInfo } from '@/providers/store-provider';
|
|
5
|
+
|
|
6
|
+
export function Footer() {
|
|
7
|
+
const { storeInfo } = useStoreInfo();
|
|
8
|
+
const year = new Date().getFullYear();
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<footer className="border-border bg-background border-t">
|
|
12
|
+
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
13
|
+
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
|
14
|
+
<p className="text-muted-foreground text-sm">
|
|
15
|
+
{year} {storeInfo?.name || 'Store'}. All rights reserved.
|
|
16
|
+
</p>
|
|
17
|
+
<nav className="flex items-center gap-4">
|
|
18
|
+
<Link
|
|
19
|
+
href="/products"
|
|
20
|
+
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
21
|
+
>
|
|
22
|
+
Products
|
|
23
|
+
</Link>
|
|
24
|
+
<Link
|
|
25
|
+
href="/account"
|
|
26
|
+
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
27
|
+
>
|
|
28
|
+
Account
|
|
29
|
+
</Link>
|
|
30
|
+
</nav>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</footer>
|
|
34
|
+
);
|
|
35
|
+
}
|