create-brainerce-store 1.41.0 → 1.42.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 (30) hide show
  1. package/dist/index.js +41 -12
  2. package/messages/en.json +441 -441
  3. package/messages/he.json +441 -441
  4. package/package.json +2 -2
  5. package/templates/nextjs/base/TRANSLATIONS.md +200 -0
  6. package/templates/nextjs/base/next.config.ts +22 -0
  7. package/templates/nextjs/base/package.json.ejs +3 -0
  8. package/templates/nextjs/base/src/app/checkout/page.tsx +1 -1
  9. package/templates/nextjs/base/src/app/layout.tsx.ejs +14 -6
  10. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +9 -4
  11. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +45 -5
  12. package/templates/nextjs/base/src/components/account/order-history.tsx +367 -367
  13. package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +112 -112
  14. package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
  15. package/templates/nextjs/base/src/components/cart/cart-summary.tsx +108 -108
  16. package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +142 -142
  17. package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +59 -59
  18. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +243 -243
  19. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +199 -199
  20. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +110 -110
  21. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +65 -65
  22. package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +202 -202
  23. package/templates/nextjs/base/src/components/products/product-card.tsx +226 -226
  24. package/templates/nextjs/base/src/components/products/variant-selector.tsx +292 -292
  25. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +5 -1
  26. package/templates/nextjs/base/src/components/shared/price-display.tsx +65 -62
  27. package/templates/nextjs/base/src/lib/brainerce.ts.ejs +42 -0
  28. package/templates/nextjs/base/src/lib/store-info.ts +48 -0
  29. package/templates/nextjs/base/src/lib/utils.ts +21 -6
  30. package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +37 -14
@@ -1,65 +1,65 @@
1
- 'use client';
2
-
3
- import type { TaxBreakdown } 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 TaxDisplayProps {
10
- /** Whether shipping address has been set */
11
- addressSet: boolean;
12
- /** Tax amount string from checkout (only available after address is set) */
13
- taxAmount?: string;
14
- /** Detailed tax breakdown (optional) */
15
- taxBreakdown?: TaxBreakdown | null;
16
- className?: string;
17
- }
18
-
19
- export function TaxDisplay({ addressSet, taxAmount, taxBreakdown, className }: TaxDisplayProps) {
20
- const t = useTranslations('checkout');
21
- const tc = useTranslations('common');
22
- const { storeInfo } = useStoreInfo();
23
- const currency = storeInfo?.currency || 'USD';
24
-
25
- // Before address is set
26
- if (!addressSet) {
27
- return (
28
- <div className={cn('flex items-center justify-between text-sm', className)}>
29
- <span className="text-muted-foreground">{tc('tax')}</span>
30
- <span className="text-muted-foreground text-xs">{t('calculatedAfterAddress')}</span>
31
- </div>
32
- );
33
- }
34
-
35
- // After address, show tax amount
36
- const tax = taxAmount ? parseFloat(taxAmount) : 0;
37
-
38
- return (
39
- <div className={cn('space-y-1', className)}>
40
- <div className="flex items-center justify-between text-sm">
41
- <span className="text-muted-foreground">{tc('tax')}</span>
42
- <span className="text-foreground font-medium">
43
- {tax > 0 ? (formatPrice(tax, { currency }) as string) : t('noTax')}
44
- </span>
45
- </div>
46
-
47
- {/* Tax breakdown details */}
48
- {taxBreakdown && taxBreakdown.breakdown?.length > 0 && tax > 0 && (
49
- <div className="space-y-0.5 ps-4">
50
- {taxBreakdown.breakdown.map((item, index) => (
51
- <div
52
- key={index}
53
- className="text-muted-foreground flex items-center justify-between text-xs"
54
- >
55
- <span>
56
- {item.name} ({(item.rate * 100).toFixed(1)}%)
57
- </span>
58
- <span>{formatPrice(item.amount, { currency }) as string}</span>
59
- </div>
60
- ))}
61
- </div>
62
- )}
63
- </div>
64
- );
65
- }
1
+ 'use client';
2
+
3
+ import type { TaxBreakdown } 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 TaxDisplayProps {
10
+ /** Whether shipping address has been set */
11
+ addressSet: boolean;
12
+ /** Tax amount string from checkout (only available after address is set) */
13
+ taxAmount?: string;
14
+ /** Detailed tax breakdown (optional) */
15
+ taxBreakdown?: TaxBreakdown | null;
16
+ className?: string;
17
+ }
18
+
19
+ export function TaxDisplay({ addressSet, taxAmount, taxBreakdown, className }: TaxDisplayProps) {
20
+ const t = useTranslations('checkout');
21
+ const tc = useTranslations('common');
22
+ const { storeInfo } = useStoreInfo();
23
+ const currency = storeInfo?.currency || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
24
+
25
+ // Before address is set
26
+ if (!addressSet) {
27
+ return (
28
+ <div className={cn('flex items-center justify-between text-sm', className)}>
29
+ <span className="text-muted-foreground">{tc('tax')}</span>
30
+ <span className="text-muted-foreground text-xs">{t('calculatedAfterAddress')}</span>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ // After address, show tax amount
36
+ const tax = taxAmount ? parseFloat(taxAmount) : 0;
37
+
38
+ return (
39
+ <div className={cn('space-y-1', className)}>
40
+ <div className="flex items-center justify-between text-sm">
41
+ <span className="text-muted-foreground">{tc('tax')}</span>
42
+ <span className="text-foreground font-medium">
43
+ {tax > 0 ? (formatPrice(tax, { currency }) as string) : t('noTax')}
44
+ </span>
45
+ </div>
46
+
47
+ {/* Tax breakdown details */}
48
+ {taxBreakdown && taxBreakdown.breakdown?.length > 0 && tax > 0 && (
49
+ <div className="space-y-0.5 ps-4">
50
+ {taxBreakdown.breakdown.map((item, index) => (
51
+ <div
52
+ key={index}
53
+ className="text-muted-foreground flex items-center justify-between text-xs"
54
+ >
55
+ <span>
56
+ {item.name} ({(item.rate * 100).toFixed(1)}%)
57
+ </span>
58
+ <span>{formatPrice(item.amount, { currency }) as string}</span>
59
+ </div>
60
+ ))}
61
+ </div>
62
+ )}
63
+ </div>
64
+ );
65
+ }
@@ -1,202 +1,202 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import Image from 'next/image';
5
- import type { Product, ProductRecommendation } from 'brainerce';
6
- import { formatPrice } from 'brainerce';
7
- import { useCart } from '@/providers/store-provider';
8
- import { useStoreInfo } from '@/providers/store-provider';
9
- import { useTranslations } from '@/lib/translations';
10
- import { cn } from '@/lib/utils';
11
-
12
- interface FrequentlyBoughtTogetherProps {
13
- items: ProductRecommendation[];
14
- currentProduct: Product;
15
- className?: string;
16
- }
17
-
18
- function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
19
- const sale = item.salePrice ? parseFloat(item.salePrice) : null;
20
- const base = parseFloat(item.basePrice);
21
- return sale != null && sale < base ? sale : base;
22
- }
23
-
24
- function ProductThumb({
25
- name,
26
- imageUrl,
27
- price,
28
- currency,
29
- checked,
30
- onToggle,
31
- disabled,
32
- }: {
33
- name: string;
34
- imageUrl: string | null;
35
- price: number;
36
- currency: string;
37
- checked: boolean;
38
- onToggle?: () => void;
39
- disabled?: boolean;
40
- }) {
41
- return (
42
- <label
43
- className={cn(
44
- 'border-border bg-background relative flex cursor-pointer flex-col items-center rounded-lg border p-3 transition-all',
45
- checked ? 'ring-primary ring-2' : 'opacity-60',
46
- disabled && 'pointer-events-none'
47
- )}
48
- >
49
- {onToggle && (
50
- <input
51
- type="checkbox"
52
- checked={checked}
53
- onChange={onToggle}
54
- className="absolute start-2 top-2 h-4 w-4 rounded"
55
- />
56
- )}
57
- <div className="bg-muted relative mb-2 h-20 w-20 overflow-hidden rounded">
58
- {imageUrl ? (
59
- <Image src={imageUrl} alt={name} fill sizes="80px" className="object-cover" />
60
- ) : (
61
- <div className="flex h-full w-full items-center justify-center">
62
- <svg
63
- className="text-muted-foreground h-8 w-8"
64
- fill="none"
65
- viewBox="0 0 24 24"
66
- stroke="currentColor"
67
- >
68
- <path
69
- strokeLinecap="round"
70
- strokeLinejoin="round"
71
- strokeWidth={1.5}
72
- d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
73
- />
74
- </svg>
75
- </div>
76
- )}
77
- </div>
78
- <span className="text-foreground line-clamp-2 text-center text-xs font-medium">{name}</span>
79
- <span className="text-muted-foreground mt-1 text-xs">
80
- {formatPrice(price, { currency }) as string}
81
- </span>
82
- </label>
83
- );
84
- }
85
-
86
- export function FrequentlyBoughtTogether({
87
- items,
88
- currentProduct,
89
- className,
90
- }: FrequentlyBoughtTogetherProps) {
91
- const { storeInfo } = useStoreInfo();
92
- const { refreshCart } = useCart();
93
- const t = useTranslations('productDetail');
94
-
95
- // Only show up to 3 cross-sells
96
- const crossSells = items.slice(0, 3);
97
-
98
- const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
99
- const [adding, setAdding] = useState(false);
100
-
101
- if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
102
- if (crossSells.length === 0) return null;
103
-
104
- const currency = storeInfo.currency || 'USD';
105
-
106
- const currentPrice = getEffectivePrice(currentProduct);
107
- const currentImage = currentProduct.images?.[0];
108
- const currentImageUrl = currentImage
109
- ? typeof currentImage === 'string'
110
- ? currentImage
111
- : currentImage.url
112
- : null;
113
-
114
- const totalPrice = crossSells
115
- .filter((item) => selected.has(item.id))
116
- .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
117
-
118
- const toggleItem = (id: string) => {
119
- setSelected((prev) => {
120
- const next = new Set(prev);
121
- if (next.has(id)) {
122
- next.delete(id);
123
- } else {
124
- next.add(id);
125
- }
126
- return next;
127
- });
128
- };
129
-
130
- async function handleAddAll() {
131
- if (adding || selected.size === 0) return;
132
- try {
133
- setAdding(true);
134
- const { getClient } = await import('@/lib/brainerce');
135
- const client = getClient();
136
- const selectedItems = crossSells.filter((item) => selected.has(item.id));
137
- for (const item of selectedItems) {
138
- await client.smartAddToCart({ productId: item.id, quantity: 1 });
139
- }
140
- await refreshCart();
141
- } catch (err) {
142
- console.error('Failed to add items to cart:', err);
143
- } finally {
144
- setAdding(false);
145
- }
146
- }
147
-
148
- return (
149
- <div className={cn('border-border rounded-lg border p-6', className)}>
150
- <h2 className="text-foreground mb-4 text-xl font-semibold">
151
- {t('frequentlyBoughtTogether')}
152
- </h2>
153
-
154
- <div className="flex flex-wrap items-center gap-3">
155
- {/* Current product (always included, no checkbox) */}
156
- <ProductThumb
157
- name={currentProduct.name}
158
- imageUrl={currentImageUrl}
159
- price={currentPrice}
160
- currency={currency}
161
- checked={true}
162
- disabled
163
- />
164
-
165
- {crossSells.map((item) => {
166
- const img = item.images?.[0];
167
- const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
168
- return (
169
- <div key={item.id} className="flex items-center gap-3">
170
- <span className="text-muted-foreground text-lg font-light">+</span>
171
- <ProductThumb
172
- name={item.name}
173
- imageUrl={imgUrl}
174
- price={getEffectivePrice(item)}
175
- currency={currency}
176
- checked={selected.has(item.id)}
177
- onToggle={() => toggleItem(item.id)}
178
- />
179
- </div>
180
- );
181
- })}
182
- </div>
183
-
184
- {/* Total + Add button */}
185
- <div className="mt-4 flex flex-wrap items-center gap-4">
186
- <span className="text-foreground text-lg font-semibold">
187
- {t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}
188
- </span>
189
- <button
190
- onClick={handleAddAll}
191
- disabled={adding || selected.size === 0}
192
- className={cn(
193
- 'bg-primary text-primary-foreground rounded px-5 py-2.5 text-sm font-medium transition-opacity hover:opacity-90',
194
- 'disabled:cursor-not-allowed disabled:opacity-50'
195
- )}
196
- >
197
- {adding ? t('addingAll') : t('addSelectedToCart')}
198
- </button>
199
- </div>
200
- </div>
201
- );
202
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import Image from 'next/image';
5
+ import type { Product, ProductRecommendation } from 'brainerce';
6
+ import { formatPrice } from 'brainerce';
7
+ import { useCart } from '@/providers/store-provider';
8
+ import { useStoreInfo } from '@/providers/store-provider';
9
+ import { useTranslations } from '@/lib/translations';
10
+ import { cn } from '@/lib/utils';
11
+
12
+ interface FrequentlyBoughtTogetherProps {
13
+ items: ProductRecommendation[];
14
+ currentProduct: Product;
15
+ className?: string;
16
+ }
17
+
18
+ function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
19
+ const sale = item.salePrice ? parseFloat(item.salePrice) : null;
20
+ const base = parseFloat(item.basePrice);
21
+ return sale != null && sale < base ? sale : base;
22
+ }
23
+
24
+ function ProductThumb({
25
+ name,
26
+ imageUrl,
27
+ price,
28
+ currency,
29
+ checked,
30
+ onToggle,
31
+ disabled,
32
+ }: {
33
+ name: string;
34
+ imageUrl: string | null;
35
+ price: number;
36
+ currency: string;
37
+ checked: boolean;
38
+ onToggle?: () => void;
39
+ disabled?: boolean;
40
+ }) {
41
+ return (
42
+ <label
43
+ className={cn(
44
+ 'border-border bg-background relative flex cursor-pointer flex-col items-center rounded-lg border p-3 transition-all',
45
+ checked ? 'ring-primary ring-2' : 'opacity-60',
46
+ disabled && 'pointer-events-none'
47
+ )}
48
+ >
49
+ {onToggle && (
50
+ <input
51
+ type="checkbox"
52
+ checked={checked}
53
+ onChange={onToggle}
54
+ className="absolute start-2 top-2 h-4 w-4 rounded"
55
+ />
56
+ )}
57
+ <div className="bg-muted relative mb-2 h-20 w-20 overflow-hidden rounded">
58
+ {imageUrl ? (
59
+ <Image src={imageUrl} alt={name} fill sizes="80px" className="object-cover" />
60
+ ) : (
61
+ <div className="flex h-full w-full items-center justify-center">
62
+ <svg
63
+ className="text-muted-foreground h-8 w-8"
64
+ fill="none"
65
+ viewBox="0 0 24 24"
66
+ stroke="currentColor"
67
+ >
68
+ <path
69
+ strokeLinecap="round"
70
+ strokeLinejoin="round"
71
+ strokeWidth={1.5}
72
+ d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
73
+ />
74
+ </svg>
75
+ </div>
76
+ )}
77
+ </div>
78
+ <span className="text-foreground line-clamp-2 text-center text-xs font-medium">{name}</span>
79
+ <span className="text-muted-foreground mt-1 text-xs">
80
+ {formatPrice(price, { currency }) as string}
81
+ </span>
82
+ </label>
83
+ );
84
+ }
85
+
86
+ export function FrequentlyBoughtTogether({
87
+ items,
88
+ currentProduct,
89
+ className,
90
+ }: FrequentlyBoughtTogetherProps) {
91
+ const { storeInfo } = useStoreInfo();
92
+ const { refreshCart } = useCart();
93
+ const t = useTranslations('productDetail');
94
+
95
+ // Only show up to 3 cross-sells
96
+ const crossSells = items.slice(0, 3);
97
+
98
+ const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
99
+ const [adding, setAdding] = useState(false);
100
+
101
+ if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
102
+ if (crossSells.length === 0) return null;
103
+
104
+ const currency = storeInfo.currency || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
105
+
106
+ const currentPrice = getEffectivePrice(currentProduct);
107
+ const currentImage = currentProduct.images?.[0];
108
+ const currentImageUrl = currentImage
109
+ ? typeof currentImage === 'string'
110
+ ? currentImage
111
+ : currentImage.url
112
+ : null;
113
+
114
+ const totalPrice = crossSells
115
+ .filter((item) => selected.has(item.id))
116
+ .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
117
+
118
+ const toggleItem = (id: string) => {
119
+ setSelected((prev) => {
120
+ const next = new Set(prev);
121
+ if (next.has(id)) {
122
+ next.delete(id);
123
+ } else {
124
+ next.add(id);
125
+ }
126
+ return next;
127
+ });
128
+ };
129
+
130
+ async function handleAddAll() {
131
+ if (adding || selected.size === 0) return;
132
+ try {
133
+ setAdding(true);
134
+ const { getClient } = await import('@/lib/brainerce');
135
+ const client = getClient();
136
+ const selectedItems = crossSells.filter((item) => selected.has(item.id));
137
+ for (const item of selectedItems) {
138
+ await client.smartAddToCart({ productId: item.id, quantity: 1 });
139
+ }
140
+ await refreshCart();
141
+ } catch (err) {
142
+ console.error('Failed to add items to cart:', err);
143
+ } finally {
144
+ setAdding(false);
145
+ }
146
+ }
147
+
148
+ return (
149
+ <div className={cn('border-border rounded-lg border p-6', className)}>
150
+ <h2 className="text-foreground mb-4 text-xl font-semibold">
151
+ {t('frequentlyBoughtTogether')}
152
+ </h2>
153
+
154
+ <div className="flex flex-wrap items-center gap-3">
155
+ {/* Current product (always included, no checkbox) */}
156
+ <ProductThumb
157
+ name={currentProduct.name}
158
+ imageUrl={currentImageUrl}
159
+ price={currentPrice}
160
+ currency={currency}
161
+ checked={true}
162
+ disabled
163
+ />
164
+
165
+ {crossSells.map((item) => {
166
+ const img = item.images?.[0];
167
+ const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
168
+ return (
169
+ <div key={item.id} className="flex items-center gap-3">
170
+ <span className="text-muted-foreground text-lg font-light">+</span>
171
+ <ProductThumb
172
+ name={item.name}
173
+ imageUrl={imgUrl}
174
+ price={getEffectivePrice(item)}
175
+ currency={currency}
176
+ checked={selected.has(item.id)}
177
+ onToggle={() => toggleItem(item.id)}
178
+ />
179
+ </div>
180
+ );
181
+ })}
182
+ </div>
183
+
184
+ {/* Total + Add button */}
185
+ <div className="mt-4 flex flex-wrap items-center gap-4">
186
+ <span className="text-foreground text-lg font-semibold">
187
+ {t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}
188
+ </span>
189
+ <button
190
+ onClick={handleAddAll}
191
+ disabled={adding || selected.size === 0}
192
+ className={cn(
193
+ 'bg-primary text-primary-foreground rounded px-5 py-2.5 text-sm font-medium transition-opacity hover:opacity-90',
194
+ 'disabled:cursor-not-allowed disabled:opacity-50'
195
+ )}
196
+ >
197
+ {adding ? t('addingAll') : t('addSelectedToCart')}
198
+ </button>
199
+ </div>
200
+ </div>
201
+ );
202
+ }