create-brainerce-store 1.87.0 → 1.88.1
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 +9 -2
- package/messages/en.json +10 -1
- package/messages/he.json +10 -1
- package/package.json +1 -1
- package/templates/nextjs/base/.eslintrc.json +1 -0
- package/templates/nextjs/base/AI-GUIDE.md +224 -221
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +296 -296
- package/templates/nextjs/base/src/app/checkout/page.tsx +89 -6
- package/templates/nextjs/base/src/app/products/layout.tsx.ejs +18 -0
- package/templates/nextjs/base/src/components/auth/register-form.tsx +381 -375
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +14 -8
- package/templates/nextjs/base/src/core/hooks/use-page-exists.ts +37 -0
- package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +27 -2
- package/templates/nextjs/base/src/core/lib/brainerce.server.ts +121 -121
- package/templates/nextjs/base/src/core/lib/coupon-error.ts +29 -0
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +6 -2
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +6 -4
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +138 -134
- package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +10 -3
- package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +5 -1
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +2 -2
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +6 -2
- package/templates/nextjs/base/src/ui/product/product-card.tsx +6 -2
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +4 -36
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +6 -2
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +6 -4
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +146 -142
- package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +10 -3
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +6 -2
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +6 -2
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +3 -28
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +6 -2
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +6 -4
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +123 -119
- package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +4 -3
- package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +5 -1
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +6 -2
- package/templates/nextjs/ui-canvas/product/product-card.tsx +6 -2
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +3 -28
- package/templates/nextjs/base/src/app/products/layout.tsx +0 -18
|
@@ -1,119 +1,123 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import type { Cart } from 'brainerce';
|
|
5
|
-
import { getClient } from '@/core/lib/brainerce';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const [
|
|
31
|
-
const [
|
|
32
|
-
const [
|
|
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
|
-
|
|
112
|
-
{
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import type { Cart } from 'brainerce';
|
|
5
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
6
|
+
import { couponErrorKey } from '@/core/lib/coupon-error';
|
|
7
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
8
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
9
|
+
import { cn } from '@/core/lib/utils';
|
|
10
|
+
|
|
11
|
+
interface CouponInputProps {
|
|
12
|
+
cart: Cart;
|
|
13
|
+
/** When provided, uses applyCheckoutCoupon/removeCheckoutCoupon instead of
|
|
14
|
+
* applyCoupon/removeCoupon. Always pass this when a checkout session exists. */
|
|
15
|
+
checkoutId?: string;
|
|
16
|
+
onUpdate: () => void;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* DESIGN ME — coupon apply/remove control used on the cart page AND the
|
|
22
|
+
* checkout page (via `checkoutId`); the `cart` prop comes from useCartPage().
|
|
23
|
+
* Keep the applied-coupon state and the error message.
|
|
24
|
+
*
|
|
25
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
26
|
+
*/
|
|
27
|
+
export function CouponInput({ cart, checkoutId, onUpdate, className }: CouponInputProps) {
|
|
28
|
+
const t = useTranslations('coupon');
|
|
29
|
+
const tc = useTranslations('common');
|
|
30
|
+
const [code, setCode] = useState('');
|
|
31
|
+
const [applying, setApplying] = useState(false);
|
|
32
|
+
const [removing, setRemoving] = useState(false);
|
|
33
|
+
const [error, setError] = useState<string | null>(null);
|
|
34
|
+
|
|
35
|
+
const appliedCoupon = cart.couponCode || null;
|
|
36
|
+
|
|
37
|
+
async function handleApply() {
|
|
38
|
+
const trimmed = code.trim();
|
|
39
|
+
if (!trimmed || applying) return;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
setApplying(true);
|
|
43
|
+
setError(null);
|
|
44
|
+
const client = getClient();
|
|
45
|
+
if (checkoutId) {
|
|
46
|
+
await client.applyCheckoutCoupon(checkoutId, trimmed);
|
|
47
|
+
} else {
|
|
48
|
+
await client.applyCoupon(cart.id, trimmed);
|
|
49
|
+
}
|
|
50
|
+
setCode('');
|
|
51
|
+
onUpdate();
|
|
52
|
+
} catch (err) {
|
|
53
|
+
// Known codes are shown from our own (translated) messages; the API's
|
|
54
|
+
// `message` is English. An uncoded refusal falls back to that message.
|
|
55
|
+
const key = couponErrorKey(err);
|
|
56
|
+
const message = key ? t(key) : err instanceof Error ? err.message : t('invalidCode');
|
|
57
|
+
setError(message);
|
|
58
|
+
} finally {
|
|
59
|
+
setApplying(false);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function handleRemove() {
|
|
64
|
+
if (removing) return;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
setRemoving(true);
|
|
68
|
+
setError(null);
|
|
69
|
+
const client = getClient();
|
|
70
|
+
if (checkoutId) {
|
|
71
|
+
await client.removeCheckoutCoupon(checkoutId);
|
|
72
|
+
} else {
|
|
73
|
+
await client.removeCoupon(cart.id);
|
|
74
|
+
}
|
|
75
|
+
onUpdate();
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.error('Failed to remove coupon:', err);
|
|
78
|
+
} finally {
|
|
79
|
+
setRemoving(false);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Show applied coupon
|
|
84
|
+
if (appliedCoupon) {
|
|
85
|
+
return (
|
|
86
|
+
<div className={cn('flex items-center gap-2', className)}>
|
|
87
|
+
<span>{appliedCoupon}</span>
|
|
88
|
+
<button type="button" onClick={handleRemove} disabled={removing}>
|
|
89
|
+
{removing ? tc('removing') : tc('remove')}
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className={cn(className)}>
|
|
97
|
+
<div className="flex gap-2">
|
|
98
|
+
<input
|
|
99
|
+
type="text"
|
|
100
|
+
value={code}
|
|
101
|
+
onChange={(e) => {
|
|
102
|
+
setCode(e.target.value);
|
|
103
|
+
if (error) setError(null);
|
|
104
|
+
}}
|
|
105
|
+
onKeyDown={(e) => {
|
|
106
|
+
if (e.key === 'Enter') {
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
handleApply();
|
|
109
|
+
}
|
|
110
|
+
}}
|
|
111
|
+
placeholder={t('placeholder')}
|
|
112
|
+
aria-label={t('placeholder')}
|
|
113
|
+
aria-invalid={error ? true : undefined}
|
|
114
|
+
/>
|
|
115
|
+
<button type="button" onClick={handleApply} disabled={applying || !code.trim()}>
|
|
116
|
+
{applying ? <LoadingSpinner size="sm" /> : tc('apply')}
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
{error && <p role="alert">{error}</p>}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -71,7 +71,7 @@ export function useTaxEstimate(): TaxEstimateResponse | null {
|
|
|
71
71
|
/* DESIGN ME — unstyled on purpose (canvas mode). The rules that are NOT yours
|
|
72
72
|
to change: no estimate is rendered when `appliesTax` is false or the call
|
|
73
73
|
failed, `note` is rendered verbatim, and the "calculated at checkout" caveat
|
|
74
|
-
stays beside the figure. */
|
|
74
|
+
stays beside the figure unless the store's prices already include tax. */
|
|
75
75
|
export function TaxEstimateLine() {
|
|
76
76
|
const t = useTranslations('cart');
|
|
77
77
|
const tc = useTranslations('common');
|
|
@@ -94,10 +94,11 @@ export function TaxEstimateLine() {
|
|
|
94
94
|
return (
|
|
95
95
|
<div>
|
|
96
96
|
<div className="flex items-center justify-between gap-4">
|
|
97
|
-
<dt>{label}</dt>
|
|
97
|
+
<dt>{estimate.pricesIncludeTax ? `${label} ${tc('inclSuffix')}` : label}</dt>
|
|
98
98
|
<dd>{formatPrice(estimate.estimatedTax, { currency: estimate.currency }) as string}</dd>
|
|
99
99
|
</div>
|
|
100
|
-
|
|
100
|
+
{/* VAT-inclusive: the tax is already inside the prices, nothing is added later. */}
|
|
101
|
+
{estimate.pricesIncludeTax ? null : <p>{t('taxAtCheckout')}</p>}
|
|
101
102
|
{estimate.note ? <p>{estimate.note}</p> : null}
|
|
102
103
|
</div>
|
|
103
104
|
);
|
|
@@ -56,7 +56,11 @@ export function NewsletterSignup() {
|
|
|
56
56
|
getClient()
|
|
57
57
|
.marketing.getBenefit(locale)
|
|
58
58
|
.then((result) => {
|
|
59
|
-
|
|
59
|
+
// Only a complete, enabled offer is ever shown. An empty or partial body
|
|
60
|
+
// (an older backend, a half-configured benefit) must read as "no offer",
|
|
61
|
+
// never as "undefined off your first order".
|
|
62
|
+
const usable = result && result.enabled === true && Number.isFinite(result.discountValue);
|
|
63
|
+
if (!cancelled) setOffer(usable ? result : null);
|
|
60
64
|
})
|
|
61
65
|
.catch(() => {
|
|
62
66
|
// Silent: a failed offer read must not stop anyone subscribing.
|
|
@@ -37,7 +37,7 @@ export function SiteFooter({ footer, storeName }: SiteFooterProps) {
|
|
|
37
37
|
<footer>
|
|
38
38
|
{/* DESIGN ME — footer (fallback branch): copyright only; Content<'FOOTER'> pre-fetched in the layout. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
|
|
39
39
|
<p>
|
|
40
|
-
© {year} {brandLabel}. All rights reserved.
|
|
40
|
+
© {year} {brandLabel}. <%- language === 'he' ? 'כל הזכויות שמורות.' : 'All rights reserved.' %>
|
|
41
41
|
</p>
|
|
42
42
|
<NewsletterSignup />
|
|
43
43
|
</footer>
|
|
@@ -81,7 +81,7 @@ export function SiteFooter({ footer, storeName }: SiteFooterProps) {
|
|
|
81
81
|
|
|
82
82
|
<NewsletterSignup />
|
|
83
83
|
|
|
84
|
-
<p>{data.copyright ?? `© ${year} ${brandLabel}. All rights reserved
|
|
84
|
+
<p>{data.copyright ?? `© ${year} ${brandLabel}. <%- language === 'he' ? 'כל הזכויות שמורות.' : 'All rights reserved.' %>`}</p>
|
|
85
85
|
</footer>
|
|
86
86
|
);
|
|
87
87
|
}
|
|
@@ -7,7 +7,11 @@ import { formatPrice } from 'brainerce';
|
|
|
7
7
|
import { useCart, useStoreInfo } from '@/core/providers/store-provider';
|
|
8
8
|
import { useCurrency } from '@/core/lib/use-currency';
|
|
9
9
|
import { useTranslations } from '@/core/lib/translations';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
addToCartErrorMessageKey,
|
|
12
|
+
toAddToCartError,
|
|
13
|
+
type AddToCartError,
|
|
14
|
+
} from '@/core/lib/add-to-cart-error';
|
|
11
15
|
import { cn } from '@/core/lib/utils';
|
|
12
16
|
|
|
13
17
|
interface FrequentlyBoughtTogetherProps {
|
|
@@ -190,7 +194,7 @@ export function FrequentlyBoughtTogether({
|
|
|
190
194
|
the loop above adds one at a time and stops at the first refusal.
|
|
191
195
|
*/}
|
|
192
196
|
{addError && (
|
|
193
|
-
<p role="alert">{
|
|
197
|
+
<p role="alert">{t(addToCartErrorMessageKey(addError))}</p>
|
|
194
198
|
)}
|
|
195
199
|
</section>
|
|
196
200
|
);
|
|
@@ -14,7 +14,11 @@ import { DiscountBadge } from '@/ui/product/discount-badge';
|
|
|
14
14
|
import { useCart } from '@/core/providers/store-provider';
|
|
15
15
|
import { useCurrency } from '@/core/lib/use-currency';
|
|
16
16
|
import { trackAddToCart } from '@/core/lib/tracking';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
addToCartErrorMessageKey,
|
|
19
|
+
toAddToCartError,
|
|
20
|
+
type AddToCartError,
|
|
21
|
+
} from '@/core/lib/add-to-cart-error';
|
|
18
22
|
import { cn } from '@/core/lib/utils';
|
|
19
23
|
|
|
20
24
|
interface ProductCardProps {
|
|
@@ -182,7 +186,7 @@ export function ProductCard({ product, className }: ProductCardProps) {
|
|
|
182
186
|
this the shopper taps, the button resets, and nothing appears.
|
|
183
187
|
*/}
|
|
184
188
|
{addError && (
|
|
185
|
-
<p role="alert">{
|
|
189
|
+
<p role="alert">{tp(addToCartErrorMessageKey(addError))}</p>
|
|
186
190
|
)}
|
|
187
191
|
</article>
|
|
188
192
|
);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
4
|
-
import type { Product, ProductMetafield
|
|
4
|
+
import type { Product, ProductMetafield } from 'brainerce';
|
|
5
5
|
import { useProductPage } from '@/core/hooks/use-product-page';
|
|
6
|
+
import { addToCartErrorMessageKey } from '@/core/lib/add-to-cart-error';
|
|
6
7
|
import { PriceDisplay } from '@/ui/product/price-display';
|
|
7
8
|
import { VariantSelector } from '@/ui/product/variant-selector';
|
|
8
9
|
import { StockBadge } from '@/ui/product/stock-badge';
|
|
@@ -225,32 +226,6 @@ export function ProductClientSection({
|
|
|
225
226
|
<StockBadge inventory={inventory} />
|
|
226
227
|
)}
|
|
227
228
|
|
|
228
|
-
{/* Downloadable files info */}
|
|
229
|
-
{product.isDownloadable && product.downloads && product.downloads.length > 0 && (
|
|
230
|
-
<section>
|
|
231
|
-
<h2>
|
|
232
|
-
{t('filesIncluded')} ({product.downloads.length})
|
|
233
|
-
</h2>
|
|
234
|
-
<ul>
|
|
235
|
-
{product.downloads.map((file: DownloadFile) => (
|
|
236
|
-
<li key={file.id}>
|
|
237
|
-
<span>{file.name}</span>
|
|
238
|
-
{file.size && (
|
|
239
|
-
<span>
|
|
240
|
-
{' '}
|
|
241
|
-
(
|
|
242
|
-
{file.size < 1024 * 1024
|
|
243
|
-
? `${(file.size / 1024).toFixed(0)} KB`
|
|
244
|
-
: `${(file.size / (1024 * 1024)).toFixed(1)} MB`}
|
|
245
|
-
)
|
|
246
|
-
</span>
|
|
247
|
-
)}
|
|
248
|
-
</li>
|
|
249
|
-
))}
|
|
250
|
-
</ul>
|
|
251
|
-
</section>
|
|
252
|
-
)}
|
|
253
|
-
|
|
254
229
|
{/* Variant Selector */}
|
|
255
230
|
{/* A KIT is bought as ONE line, but the shopper needs to see what is
|
|
256
231
|
in the box before deciding. These rows are display only — never add
|
|
@@ -355,7 +330,7 @@ export function ProductClientSection({
|
|
|
355
330
|
*/}
|
|
356
331
|
{addToCartError && (
|
|
357
332
|
<p role="alert">
|
|
358
|
-
{
|
|
333
|
+
{t(addToCartErrorMessageKey(addToCartError))}
|
|
359
334
|
</p>
|
|
360
335
|
)}
|
|
361
336
|
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import type { Metadata } from 'next';
|
|
2
|
-
|
|
3
|
-
export const metadata: Metadata = {
|
|
4
|
-
title: 'Products',
|
|
5
|
-
description: 'Browse our full collection of products.',
|
|
6
|
-
alternates: {
|
|
7
|
-
canonical: '/products',
|
|
8
|
-
},
|
|
9
|
-
openGraph: {
|
|
10
|
-
title: 'Products',
|
|
11
|
-
description: 'Browse our full collection of products.',
|
|
12
|
-
type: 'website',
|
|
13
|
-
},
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
|
|
17
|
-
return <>{children}</>;
|
|
18
|
-
}
|