create-brainerce-store 1.43.0 → 1.43.2

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 (25) hide show
  1. package/dist/index.js +11 -8
  2. package/messages/en.json +1 -0
  3. package/messages/he.json +1 -0
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/next.config.ts +68 -69
  6. package/templates/nextjs/base/scripts/fetch-store-info.mjs +98 -93
  7. package/templates/nextjs/base/src/app/checkout/page.tsx +1004 -982
  8. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +118 -118
  9. package/templates/nextjs/base/src/components/account/order-history.tsx +368 -368
  10. package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +111 -111
  11. package/templates/nextjs/base/src/components/cart/cart-item.tsx +152 -152
  12. package/templates/nextjs/base/src/components/cart/cart-summary.tsx +108 -108
  13. package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +141 -141
  14. package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +62 -62
  15. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -242
  16. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -198
  17. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +109 -109
  18. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +74 -64
  19. package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +203 -203
  20. package/templates/nextjs/base/src/components/products/product-card.tsx +46 -1
  21. package/templates/nextjs/base/src/components/products/variant-selector.tsx +291 -291
  22. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +125 -129
  23. package/templates/nextjs/base/src/components/shared/price-display.tsx +61 -61
  24. package/templates/nextjs/base/src/lib/resolve-currency.ts +1 -6
  25. package/templates/nextjs/base/src/lib/use-currency.ts +1 -6
@@ -1,198 +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 { 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
+ '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,109 +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 { 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
- }
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
+ }