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