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.
Files changed (53) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.js +502 -0
  3. package/package.json +44 -0
  4. package/templates/nextjs/base/.env.local.ejs +3 -0
  5. package/templates/nextjs/base/.eslintrc.json +3 -0
  6. package/templates/nextjs/base/gitignore +30 -0
  7. package/templates/nextjs/base/next.config.ts +9 -0
  8. package/templates/nextjs/base/package.json.ejs +30 -0
  9. package/templates/nextjs/base/postcss.config.mjs +9 -0
  10. package/templates/nextjs/base/src/app/account/page.tsx +105 -0
  11. package/templates/nextjs/base/src/app/auth/callback/page.tsx +99 -0
  12. package/templates/nextjs/base/src/app/cart/page.tsx +263 -0
  13. package/templates/nextjs/base/src/app/checkout/page.tsx +463 -0
  14. package/templates/nextjs/base/src/app/globals.css +30 -0
  15. package/templates/nextjs/base/src/app/layout.tsx.ejs +33 -0
  16. package/templates/nextjs/base/src/app/login/page.tsx +56 -0
  17. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +191 -0
  18. package/templates/nextjs/base/src/app/page.tsx +95 -0
  19. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +346 -0
  20. package/templates/nextjs/base/src/app/products/page.tsx +243 -0
  21. package/templates/nextjs/base/src/app/register/page.tsx +66 -0
  22. package/templates/nextjs/base/src/app/verify-email/page.tsx +291 -0
  23. package/templates/nextjs/base/src/components/account/order-history.tsx +184 -0
  24. package/templates/nextjs/base/src/components/account/profile-section.tsx +73 -0
  25. package/templates/nextjs/base/src/components/auth/login-form.tsx +92 -0
  26. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +134 -0
  27. package/templates/nextjs/base/src/components/auth/register-form.tsx +177 -0
  28. package/templates/nextjs/base/src/components/cart/cart-item.tsx +150 -0
  29. package/templates/nextjs/base/src/components/cart/cart-nudges.tsx +39 -0
  30. package/templates/nextjs/base/src/components/cart/cart-summary.tsx +67 -0
  31. package/templates/nextjs/base/src/components/cart/coupon-input.tsx +131 -0
  32. package/templates/nextjs/base/src/components/cart/reservation-countdown.tsx +100 -0
  33. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +273 -0
  34. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +124 -0
  35. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +111 -0
  36. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +62 -0
  37. package/templates/nextjs/base/src/components/layout/footer.tsx +35 -0
  38. package/templates/nextjs/base/src/components/layout/header.tsx +329 -0
  39. package/templates/nextjs/base/src/components/products/discount-badge.tsx +36 -0
  40. package/templates/nextjs/base/src/components/products/product-card.tsx +94 -0
  41. package/templates/nextjs/base/src/components/products/product-grid.tsx +33 -0
  42. package/templates/nextjs/base/src/components/products/stock-badge.tsx +34 -0
  43. package/templates/nextjs/base/src/components/products/variant-selector.tsx +147 -0
  44. package/templates/nextjs/base/src/components/shared/loading-spinner.tsx +30 -0
  45. package/templates/nextjs/base/src/components/shared/price-display.tsx +62 -0
  46. package/templates/nextjs/base/src/hooks/use-search.ts +77 -0
  47. package/templates/nextjs/base/src/lib/brainerce.ts.ejs +59 -0
  48. package/templates/nextjs/base/src/lib/utils.ts +6 -0
  49. package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +168 -0
  50. package/templates/nextjs/base/tailwind.config.ts +30 -0
  51. package/templates/nextjs/base/tsconfig.json +23 -0
  52. package/templates/nextjs/themes/minimal/globals.css +30 -0
  53. 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
+ }