create-brainerce-store 1.18.0 → 1.19.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.js +31 -9
- package/messages/en.json +366 -362
- package/messages/he.json +366 -362
- package/package.json +45 -45
- package/templates/nextjs/base/next.config.ts +31 -31
- package/templates/nextjs/base/scripts/fetch-store-info.mjs +81 -81
- package/templates/nextjs/base/src/app/.well-known/apple-developer-merchantid-domain-association/route.ts +26 -26
- package/templates/nextjs/base/src/app/account/layout.tsx +9 -9
- package/templates/nextjs/base/src/app/account/page.tsx +122 -122
- package/templates/nextjs/base/src/app/api/auth/logout/route.ts +14 -14
- package/templates/nextjs/base/src/app/api/auth/me/route.ts +56 -56
- package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +59 -59
- package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +41 -41
- package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +77 -77
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +198 -198
- package/templates/nextjs/base/src/app/auth/callback/page.tsx +92 -92
- package/templates/nextjs/base/src/app/cart/layout.tsx +9 -9
- package/templates/nextjs/base/src/app/cart/page.tsx +204 -204
- package/templates/nextjs/base/src/app/checkout/layout.tsx +9 -9
- package/templates/nextjs/base/src/app/checkout/page.tsx +860 -860
- package/templates/nextjs/base/src/app/forgot-password/page.tsx +112 -112
- package/templates/nextjs/base/src/app/layout.tsx.ejs +75 -0
- package/templates/nextjs/base/src/app/login/layout.tsx +9 -9
- package/templates/nextjs/base/src/app/login/page.tsx +59 -59
- package/templates/nextjs/base/src/app/order-confirmation/layout.tsx +9 -9
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +254 -254
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +67 -67
- package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +486 -486
- package/templates/nextjs/base/src/app/products/layout.tsx +18 -18
- package/templates/nextjs/base/src/app/products/page.tsx +431 -431
- package/templates/nextjs/base/src/app/register/layout.tsx +9 -9
- package/templates/nextjs/base/src/app/register/page.tsx +65 -65
- package/templates/nextjs/base/src/app/reset-password/page.tsx +132 -132
- package/templates/nextjs/base/src/app/robots.ts +14 -14
- package/templates/nextjs/base/src/app/sitemap.ts +25 -25
- package/templates/nextjs/base/src/app/verify-email/page.tsx +258 -258
- package/templates/nextjs/base/src/components/account/address-book.tsx +432 -432
- package/templates/nextjs/base/src/components/account/order-history.tsx +350 -350
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +137 -137
- package/templates/nextjs/base/src/components/auth/register-form.tsx +232 -232
- package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +247 -111
- package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
- package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +142 -142
- package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +59 -59
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +415 -415
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +243 -83
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +519 -519
- package/templates/nextjs/base/src/components/layout/footer.tsx +41 -41
- package/templates/nextjs/base/src/components/layout/header.tsx +336 -336
- package/templates/nextjs/base/src/components/layout/language-switcher.tsx.ejs +63 -0
- package/templates/nextjs/base/src/components/products/discount-badge.tsx +22 -22
- package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +202 -202
- package/templates/nextjs/base/src/components/products/product-card.tsx +218 -218
- package/templates/nextjs/base/src/components/products/recommendation-section.tsx +107 -107
- package/templates/nextjs/base/src/components/products/stock-badge.tsx +63 -63
- package/templates/nextjs/base/src/components/products/variant-selector.tsx +292 -292
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +72 -72
- package/templates/nextjs/base/src/i18n.ts.ejs +21 -0
- package/templates/nextjs/base/src/lib/auth.ts +149 -149
- package/templates/nextjs/base/src/lib/brainerce.ts.ejs +9 -0
- package/templates/nextjs/base/src/lib/translations.ts.ejs +31 -0
- package/templates/nextjs/base/src/middleware.ts.ejs +81 -0
- package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +41 -0
- package/templates/nextjs/base/src/lib/translations.ts +0 -11
- package/templates/nextjs/base/src/middleware.ts +0 -25
|
@@ -1,111 +1,247 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import Image from 'next/image';
|
|
5
|
-
import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
|
|
6
|
-
import { formatPrice } from 'brainerce';
|
|
7
|
-
import { useStoreInfo } from '@/providers/store-provider';
|
|
8
|
-
import { useTranslations } from '@/lib/translations';
|
|
9
|
-
import { cn } from '@/lib/utils';
|
|
10
|
-
|
|
11
|
-
interface CartBundleOfferCardProps {
|
|
12
|
-
offer: CartBundleOfferType;
|
|
13
|
-
cartId: string;
|
|
14
|
-
onAdd: () => void;
|
|
15
|
-
className?: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBundleOfferCardProps) {
|
|
19
|
-
const { storeInfo } = useStoreInfo();
|
|
20
|
-
const t = useTranslations('cart');
|
|
21
|
-
const currency = storeInfo?.currency || 'USD';
|
|
22
|
-
const [adding, setAdding] = useState(false);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo } from 'react';
|
|
4
|
+
import Image from 'next/image';
|
|
5
|
+
import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
|
|
6
|
+
import { formatPrice, getVariantOptions } from 'brainerce';
|
|
7
|
+
import { useStoreInfo } from '@/providers/store-provider';
|
|
8
|
+
import { useTranslations } from '@/lib/translations';
|
|
9
|
+
import { cn } from '@/lib/utils';
|
|
10
|
+
|
|
11
|
+
interface CartBundleOfferCardProps {
|
|
12
|
+
offer: CartBundleOfferType;
|
|
13
|
+
cartId: string;
|
|
14
|
+
onAdd: () => void;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBundleOfferCardProps) {
|
|
19
|
+
const { storeInfo } = useStoreInfo();
|
|
20
|
+
const t = useTranslations('cart');
|
|
21
|
+
const currency = storeInfo?.currency || 'USD';
|
|
22
|
+
const [adding, setAdding] = useState(false);
|
|
23
|
+
const [selectedVariantId, setSelectedVariantId] = useState<string | null>(null);
|
|
24
|
+
|
|
25
|
+
const product = offer.bundleProduct;
|
|
26
|
+
const variants = product.variants;
|
|
27
|
+
const requiresSelection = offer.requiresVariantSelection && variants && variants.length > 0;
|
|
28
|
+
|
|
29
|
+
// Build attribute groups from variants
|
|
30
|
+
const attributeGroups = useMemo(() => {
|
|
31
|
+
if (!requiresSelection || !variants) return [];
|
|
32
|
+
const groups = new Map<string, Set<string>>();
|
|
33
|
+
for (const v of variants) {
|
|
34
|
+
const opts = getVariantOptions(v as any);
|
|
35
|
+
for (const opt of opts) {
|
|
36
|
+
if (!groups.has(opt.name)) groups.set(opt.name, new Set());
|
|
37
|
+
groups.get(opt.name)!.add(opt.value);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return Array.from(groups.entries()).map(([name, values]) => ({
|
|
41
|
+
name,
|
|
42
|
+
values: Array.from(values),
|
|
43
|
+
}));
|
|
44
|
+
}, [requiresSelection, variants]);
|
|
45
|
+
|
|
46
|
+
const [selectedAttrs, setSelectedAttrs] = useState<Record<string, string>>({});
|
|
47
|
+
|
|
48
|
+
const selectedVariant = useMemo(() => {
|
|
49
|
+
if (!requiresSelection || !variants) return null;
|
|
50
|
+
return (
|
|
51
|
+
variants.find((v) => {
|
|
52
|
+
const opts = getVariantOptions(v as any);
|
|
53
|
+
return attributeGroups.every((group) => {
|
|
54
|
+
const opt = opts.find((o) => o.name === group.name);
|
|
55
|
+
return opt && selectedAttrs[group.name] === opt.value;
|
|
56
|
+
});
|
|
57
|
+
}) ?? null
|
|
58
|
+
);
|
|
59
|
+
}, [requiresSelection, variants, selectedAttrs, attributeGroups]);
|
|
60
|
+
|
|
61
|
+
const effectiveVariantId = selectedVariant?.id ?? selectedVariantId;
|
|
62
|
+
|
|
63
|
+
function handleAttrSelect(attrName: string, value: string) {
|
|
64
|
+
const next = { ...selectedAttrs, [attrName]: value };
|
|
65
|
+
setSelectedAttrs(next);
|
|
66
|
+
if (variants) {
|
|
67
|
+
const match = variants.find((v) => {
|
|
68
|
+
const opts = getVariantOptions(v as any);
|
|
69
|
+
return attributeGroups.every((group) => {
|
|
70
|
+
const opt = opts.find((o) => o.name === group.name);
|
|
71
|
+
return opt && next[group.name] === opt.value;
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
setSelectedVariantId(match?.id ?? null);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Compute display prices
|
|
79
|
+
const { displayOriginal, displayDiscounted, discountLabel } = useMemo(() => {
|
|
80
|
+
let effectivePrice: number;
|
|
81
|
+
if (selectedVariant) {
|
|
82
|
+
const vSale = selectedVariant.salePrice ? parseFloat(selectedVariant.salePrice) : null;
|
|
83
|
+
const vPrice = selectedVariant.price ? parseFloat(selectedVariant.price) : null;
|
|
84
|
+
effectivePrice = vSale ?? vPrice ?? parseFloat(offer.originalPrice);
|
|
85
|
+
} else {
|
|
86
|
+
effectivePrice = parseFloat(offer.originalPrice);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let discounted: number;
|
|
90
|
+
if (offer.discountType === 'PERCENTAGE') {
|
|
91
|
+
discounted = effectivePrice * (1 - parseFloat(offer.discountValue) / 100);
|
|
92
|
+
} else {
|
|
93
|
+
discounted = Math.max(0, effectivePrice - parseFloat(offer.discountValue));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const label =
|
|
97
|
+
offer.discountType === 'PERCENTAGE'
|
|
98
|
+
? `${offer.discountValue}%`
|
|
99
|
+
: (formatPrice(parseFloat(offer.discountValue), { currency }) as string);
|
|
100
|
+
|
|
101
|
+
return { displayOriginal: effectivePrice, displayDiscounted: discounted, discountLabel: label };
|
|
102
|
+
}, [selectedVariant, offer.originalPrice, offer.discountType, offer.discountValue, currency]);
|
|
103
|
+
|
|
104
|
+
const isOos =
|
|
105
|
+
selectedVariant?.inventory?.trackingMode !== 'NOT_TRACKED' &&
|
|
106
|
+
selectedVariant?.inventory?.available != null &&
|
|
107
|
+
selectedVariant.inventory.available <= 0;
|
|
108
|
+
|
|
109
|
+
const lockedLabel =
|
|
110
|
+
offer.lockedVariant?.name ??
|
|
111
|
+
(offer.lockedVariant?.attributes
|
|
112
|
+
? Object.values(offer.lockedVariant.attributes).join(' / ')
|
|
113
|
+
: null);
|
|
114
|
+
|
|
115
|
+
const firstImage = product.images?.[0];
|
|
116
|
+
const imageUrl = firstImage
|
|
117
|
+
? typeof firstImage === 'string'
|
|
118
|
+
? firstImage
|
|
119
|
+
: firstImage.url
|
|
120
|
+
: null;
|
|
121
|
+
|
|
122
|
+
const canAdd = !requiresSelection || !!effectiveVariantId;
|
|
123
|
+
|
|
124
|
+
async function handleAdd() {
|
|
125
|
+
if (adding || !canAdd || isOos) return;
|
|
126
|
+
try {
|
|
127
|
+
setAdding(true);
|
|
128
|
+
const { getClient } = await import('@/lib/brainerce');
|
|
129
|
+
const client = getClient();
|
|
130
|
+
await client.addBundleToCart(cartId, offer.id, effectiveVariantId ?? undefined);
|
|
131
|
+
onAdd();
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.error('Failed to add bundle item:', err);
|
|
134
|
+
} finally {
|
|
135
|
+
setAdding(false);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className={cn('bg-background border-border rounded-lg border p-4', className)}>
|
|
141
|
+
<div className="flex items-center gap-4">
|
|
142
|
+
{/* Product image */}
|
|
143
|
+
<div className="bg-muted relative h-16 w-16 flex-shrink-0 overflow-hidden rounded">
|
|
144
|
+
{imageUrl ? (
|
|
145
|
+
<Image src={imageUrl} alt={product.name} fill sizes="64px" className="object-cover" />
|
|
146
|
+
) : (
|
|
147
|
+
<div className="text-muted-foreground flex h-full w-full items-center justify-center">
|
|
148
|
+
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
149
|
+
<path
|
|
150
|
+
strokeLinecap="round"
|
|
151
|
+
strokeLinejoin="round"
|
|
152
|
+
strokeWidth={1.5}
|
|
153
|
+
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"
|
|
154
|
+
/>
|
|
155
|
+
</svg>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{/* Details */}
|
|
161
|
+
<div className="min-w-0 flex-1">
|
|
162
|
+
<p className="text-foreground text-sm font-medium">{offer.name}</p>
|
|
163
|
+
{offer.description && (
|
|
164
|
+
<p className="text-muted-foreground mt-0.5 text-xs">{offer.description}</p>
|
|
165
|
+
)}
|
|
166
|
+
{lockedLabel && <p className="text-muted-foreground mt-0.5 text-xs">{lockedLabel}</p>}
|
|
167
|
+
<div className="mt-1 flex items-center gap-2">
|
|
168
|
+
<span className="text-muted-foreground text-sm line-through">
|
|
169
|
+
{formatPrice(displayOriginal, { currency }) as string}
|
|
170
|
+
</span>
|
|
171
|
+
<span className="text-foreground text-sm font-semibold">
|
|
172
|
+
{formatPrice(displayDiscounted, { currency }) as string}
|
|
173
|
+
</span>
|
|
174
|
+
<span className="bg-destructive/10 text-destructive rounded px-1.5 py-0.5 text-xs font-medium">
|
|
175
|
+
-{discountLabel}
|
|
176
|
+
</span>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{/* Add button */}
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
onClick={handleAdd}
|
|
184
|
+
disabled={adding || !canAdd || isOos}
|
|
185
|
+
className={cn(
|
|
186
|
+
'bg-primary text-primary-foreground flex-shrink-0 rounded px-4 py-2 text-xs font-medium transition-opacity hover:opacity-90',
|
|
187
|
+
'disabled:cursor-not-allowed disabled:opacity-50'
|
|
188
|
+
)}
|
|
189
|
+
>
|
|
190
|
+
{adding
|
|
191
|
+
? t('addingBundle')
|
|
192
|
+
: requiresSelection && !canAdd
|
|
193
|
+
? t('selectOptions') || 'Select options'
|
|
194
|
+
: t('addBundleItem')}
|
|
195
|
+
</button>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
{/* Compact variant selector */}
|
|
199
|
+
{requiresSelection && (
|
|
200
|
+
<div className="mt-3 space-y-1.5 ps-20">
|
|
201
|
+
{attributeGroups.map((group) => (
|
|
202
|
+
<div key={group.name} className="flex flex-wrap items-center gap-1.5">
|
|
203
|
+
<span className="text-muted-foreground text-xs">{group.name}:</span>
|
|
204
|
+
{group.values.map((value) => {
|
|
205
|
+
const isSelected = selectedAttrs[group.name] === value;
|
|
206
|
+
const variantForValue = variants?.find((v) => {
|
|
207
|
+
const opts = getVariantOptions(v as any);
|
|
208
|
+
const matchesValue = opts.some((o) => o.name === group.name && o.value === value);
|
|
209
|
+
if (!matchesValue) return false;
|
|
210
|
+
return Object.entries(selectedAttrs).every(([k, sv]) => {
|
|
211
|
+
if (k === group.name) return true;
|
|
212
|
+
return opts.some((o) => o.name === k && o.value === sv);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
const isVariantOos =
|
|
216
|
+
variantForValue?.inventory?.trackingMode !== 'NOT_TRACKED' &&
|
|
217
|
+
variantForValue?.inventory?.available != null &&
|
|
218
|
+
variantForValue.inventory.available <= 0;
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<button
|
|
222
|
+
key={value}
|
|
223
|
+
type="button"
|
|
224
|
+
onClick={() => handleAttrSelect(group.name, value)}
|
|
225
|
+
disabled={isVariantOos}
|
|
226
|
+
className={cn(
|
|
227
|
+
'rounded-full border px-2.5 py-0.5 text-xs transition-colors',
|
|
228
|
+
isSelected
|
|
229
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
230
|
+
: 'border-border text-foreground hover:border-primary/50',
|
|
231
|
+
isVariantOos && 'cursor-not-allowed line-through opacity-40'
|
|
232
|
+
)}
|
|
233
|
+
>
|
|
234
|
+
{value}
|
|
235
|
+
</button>
|
|
236
|
+
);
|
|
237
|
+
})}
|
|
238
|
+
</div>
|
|
239
|
+
))}
|
|
240
|
+
{isOos && effectiveVariantId && (
|
|
241
|
+
<p className="text-destructive text-xs">{t('outOfStock') || 'Out of stock'}</p>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|