create-brainerce-store 1.86.1 → 1.88.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 (44) hide show
  1. package/dist/index.js +9 -2
  2. package/messages/en.json +15 -2
  3. package/messages/he.json +15 -2
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +296 -255
  6. package/templates/nextjs/base/src/app/checkout/page.tsx +89 -6
  7. package/templates/nextjs/base/src/app/order-confirmation/layout.tsx +8 -0
  8. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +6 -0
  9. package/templates/nextjs/base/src/app/products/layout.tsx.ejs +18 -0
  10. package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +37 -0
  11. package/templates/nextjs/base/src/components/auth/register-form.tsx +381 -375
  12. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +14 -8
  13. package/templates/nextjs/base/src/components/google-customer-reviews-optin.tsx +126 -0
  14. package/templates/nextjs/base/src/core/hooks/use-page-exists.ts +37 -0
  15. package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +27 -2
  16. package/templates/nextjs/base/src/core/lib/brainerce.server.ts +121 -81
  17. package/templates/nextjs/base/src/core/lib/coupon-error.ts +29 -0
  18. package/templates/nextjs/base/src/middleware.ts.ejs +22 -0
  19. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +6 -2
  20. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +6 -4
  21. package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +138 -134
  22. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +10 -3
  23. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +5 -1
  24. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +2 -2
  25. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +6 -2
  26. package/templates/nextjs/base/src/ui/product/product-card.tsx +6 -2
  27. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +4 -36
  28. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +6 -2
  29. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +6 -4
  30. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +146 -142
  31. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +10 -3
  32. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +6 -2
  33. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +6 -2
  34. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +3 -28
  35. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +6 -2
  36. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +6 -4
  37. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +123 -119
  38. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +4 -3
  39. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +5 -1
  40. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
  41. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +6 -2
  42. package/templates/nextjs/ui-canvas/product/product-card.tsx +6 -2
  43. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +3 -28
  44. package/templates/nextjs/base/src/app/products/layout.tsx +0 -18
@@ -1,134 +1,138 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import { Tag } from 'lucide-react';
5
- import type { Cart } from 'brainerce';
6
- import { getClient } from '@/core/lib/brainerce';
7
- import { useTranslations } from '@/core/lib/translations';
8
- import { LoadingSpinner } from '@/ui/shared/loading-spinner';
9
- import { Button } from '@/components/ui/button';
10
- import { Input } from '@/components/ui/input';
11
- import { cn } from '@/core/lib/utils';
12
-
13
- interface CouponInputProps {
14
- cart: Cart;
15
- /** When provided, uses applyCheckoutCoupon/removeCheckoutCoupon instead of
16
- * applyCoupon/removeCoupon. Always pass this when a checkout session exists. */
17
- checkoutId?: string;
18
- onUpdate: () => void;
19
- className?: string;
20
- }
21
-
22
- export function CouponInput({ cart, checkoutId, onUpdate, className }: CouponInputProps) {
23
- const t = useTranslations('coupon');
24
- const tc = useTranslations('common');
25
- const [code, setCode] = useState('');
26
- const [applying, setApplying] = useState(false);
27
- const [removing, setRemoving] = useState(false);
28
- const [error, setError] = useState<string | null>(null);
29
-
30
- const appliedCoupon = cart.couponCode || null;
31
-
32
- async function handleApply() {
33
- const trimmed = code.trim();
34
- if (!trimmed || applying) return;
35
-
36
- try {
37
- setApplying(true);
38
- setError(null);
39
- const client = getClient();
40
- if (checkoutId) {
41
- await client.applyCheckoutCoupon(checkoutId, trimmed);
42
- } else {
43
- await client.applyCoupon(cart.id, trimmed);
44
- }
45
- setCode('');
46
- onUpdate();
47
- } catch (err) {
48
- const message = err instanceof Error ? err.message : t('invalidCode');
49
- setError(message);
50
- } finally {
51
- setApplying(false);
52
- }
53
- }
54
-
55
- async function handleRemove() {
56
- if (removing) return;
57
-
58
- try {
59
- setRemoving(true);
60
- setError(null);
61
- const client = getClient();
62
- if (checkoutId) {
63
- await client.removeCheckoutCoupon(checkoutId);
64
- } else {
65
- await client.removeCoupon(cart.id);
66
- }
67
- onUpdate();
68
- } catch (err) {
69
- console.error('Failed to remove coupon:', err);
70
- } finally {
71
- setRemoving(false);
72
- }
73
- }
74
-
75
- // Show applied coupon
76
- if (appliedCoupon) {
77
- return (
78
- <div className={cn('space-y-2', className)}>
79
- <div className="bg-muted flex items-center justify-between rounded px-3 py-2">
80
- <div className="flex items-center gap-2">
81
- <Tag className="text-primary h-4 w-4 flex-shrink-0" aria-hidden="true" />
82
- <span className="text-foreground text-sm font-medium">{appliedCoupon}</span>
83
- </div>
84
- <button
85
- type="button"
86
- onClick={handleRemove}
87
- disabled={removing}
88
- className="text-destructive hover:text-destructive/80 text-xs transition-colors disabled:opacity-40"
89
- >
90
- {removing ? tc('removing') : tc('remove')}
91
- </button>
92
- </div>
93
- </div>
94
- );
95
- }
96
-
97
- return (
98
- <div className={cn('space-y-2', className)}>
99
- <div className="flex gap-2">
100
- <Input
101
- type="text"
102
- value={code}
103
- onChange={(e) => {
104
- setCode(e.target.value);
105
- if (error) setError(null);
106
- }}
107
- onKeyDown={(e) => {
108
- if (e.key === 'Enter') {
109
- e.preventDefault();
110
- handleApply();
111
- }
112
- }}
113
- placeholder={t('placeholder')}
114
- className={cn('h-9 flex-1 rounded', error && 'border-destructive')}
115
- />
116
- <Button
117
- type="button"
118
- variant="outline"
119
- onClick={handleApply}
120
- disabled={applying || !code.trim()}
121
- className="h-9 rounded px-4 hover:bg-muted"
122
- >
123
- {applying ? (
124
- <LoadingSpinner size="sm" className="border-muted-foreground/30 border-t-foreground" />
125
- ) : (
126
- tc('apply')
127
- )}
128
- </Button>
129
- </div>
130
-
131
- {error && <p className="text-destructive text-xs">{error}</p>}
132
- </div>
133
- );
134
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Tag } from 'lucide-react';
5
+ import type { Cart } from 'brainerce';
6
+ import { getClient } from '@/core/lib/brainerce';
7
+ import { couponErrorKey } from '@/core/lib/coupon-error';
8
+ import { useTranslations } from '@/core/lib/translations';
9
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
10
+ import { Button } from '@/components/ui/button';
11
+ import { Input } from '@/components/ui/input';
12
+ import { cn } from '@/core/lib/utils';
13
+
14
+ interface CouponInputProps {
15
+ cart: Cart;
16
+ /** When provided, uses applyCheckoutCoupon/removeCheckoutCoupon instead of
17
+ * applyCoupon/removeCoupon. Always pass this when a checkout session exists. */
18
+ checkoutId?: string;
19
+ onUpdate: () => void;
20
+ className?: string;
21
+ }
22
+
23
+ export function CouponInput({ cart, checkoutId, onUpdate, className }: CouponInputProps) {
24
+ const t = useTranslations('coupon');
25
+ const tc = useTranslations('common');
26
+ const [code, setCode] = useState('');
27
+ const [applying, setApplying] = useState(false);
28
+ const [removing, setRemoving] = useState(false);
29
+ const [error, setError] = useState<string | null>(null);
30
+
31
+ const appliedCoupon = cart.couponCode || null;
32
+
33
+ async function handleApply() {
34
+ const trimmed = code.trim();
35
+ if (!trimmed || applying) return;
36
+
37
+ try {
38
+ setApplying(true);
39
+ setError(null);
40
+ const client = getClient();
41
+ if (checkoutId) {
42
+ await client.applyCheckoutCoupon(checkoutId, trimmed);
43
+ } else {
44
+ await client.applyCoupon(cart.id, trimmed);
45
+ }
46
+ setCode('');
47
+ onUpdate();
48
+ } catch (err) {
49
+ // Known codes are shown from our own (translated) messages; the API's
50
+ // `message` is English. An uncoded refusal falls back to that message.
51
+ const key = couponErrorKey(err);
52
+ const message = key ? t(key) : err instanceof Error ? err.message : t('invalidCode');
53
+ setError(message);
54
+ } finally {
55
+ setApplying(false);
56
+ }
57
+ }
58
+
59
+ async function handleRemove() {
60
+ if (removing) return;
61
+
62
+ try {
63
+ setRemoving(true);
64
+ setError(null);
65
+ const client = getClient();
66
+ if (checkoutId) {
67
+ await client.removeCheckoutCoupon(checkoutId);
68
+ } else {
69
+ await client.removeCoupon(cart.id);
70
+ }
71
+ onUpdate();
72
+ } catch (err) {
73
+ console.error('Failed to remove coupon:', err);
74
+ } finally {
75
+ setRemoving(false);
76
+ }
77
+ }
78
+
79
+ // Show applied coupon
80
+ if (appliedCoupon) {
81
+ return (
82
+ <div className={cn('space-y-2', className)}>
83
+ <div className="bg-muted flex items-center justify-between rounded px-3 py-2">
84
+ <div className="flex items-center gap-2">
85
+ <Tag className="text-primary h-4 w-4 flex-shrink-0" aria-hidden="true" />
86
+ <span className="text-foreground text-sm font-medium">{appliedCoupon}</span>
87
+ </div>
88
+ <button
89
+ type="button"
90
+ onClick={handleRemove}
91
+ disabled={removing}
92
+ className="text-destructive hover:text-destructive/80 text-xs transition-colors disabled:opacity-40"
93
+ >
94
+ {removing ? tc('removing') : tc('remove')}
95
+ </button>
96
+ </div>
97
+ </div>
98
+ );
99
+ }
100
+
101
+ return (
102
+ <div className={cn('space-y-2', className)}>
103
+ <div className="flex gap-2">
104
+ <Input
105
+ type="text"
106
+ value={code}
107
+ onChange={(e) => {
108
+ setCode(e.target.value);
109
+ if (error) setError(null);
110
+ }}
111
+ onKeyDown={(e) => {
112
+ if (e.key === 'Enter') {
113
+ e.preventDefault();
114
+ handleApply();
115
+ }
116
+ }}
117
+ placeholder={t('placeholder')}
118
+ className={cn('h-9 flex-1 rounded', error && 'border-destructive')}
119
+ />
120
+ <Button
121
+ type="button"
122
+ variant="outline"
123
+ onClick={handleApply}
124
+ disabled={applying || !code.trim()}
125
+ className="hover:bg-muted h-9 rounded px-4"
126
+ >
127
+ {applying ? (
128
+ <LoadingSpinner size="sm" className="border-muted-foreground/30 border-t-foreground" />
129
+ ) : (
130
+ tc('apply')
131
+ )}
132
+ </Button>
133
+ </div>
134
+
135
+ {error && <p className="text-destructive text-xs">{error}</p>}
136
+ </div>
137
+ );
138
+ }
@@ -93,13 +93,20 @@ export function TaxEstimateLine() {
93
93
  return (
94
94
  <div className="space-y-1">
95
95
  <div className="flex items-center justify-between">
96
- <span className="text-muted-foreground">{label}</span>
96
+ <span className="text-muted-foreground">
97
+ {estimate.pricesIncludeTax ? `${label} ${tc('inclSuffix')}` : label}
98
+ </span>
97
99
  <span className="text-foreground font-medium">
98
100
  {formatPrice(estimate.estimatedTax, { currency: estimate.currency }) as string}
99
101
  </span>
100
102
  </div>
101
- {/* The caveat travels WITH the number, not in a footnote nobody reads. */}
102
- <p className="text-muted-foreground text-xs">{t('taxAtCheckout')}</p>
103
+ {/* The caveat travels WITH the number, not in a footnote nobody reads.
104
+ A VAT-inclusive store has nothing left to calculate: the tax is already
105
+ inside the prices above, so "calculated at checkout" would promise an
106
+ extra charge that never comes. */}
107
+ {estimate.pricesIncludeTax ? null : (
108
+ <p className="text-muted-foreground text-xs">{t('taxAtCheckout')}</p>
109
+ )}
103
110
  {/* Server-written and already localized. Never replace it with copy of
104
111
  your own, it is what explains a partial estimate. */}
105
112
  {estimate.note ? <p className="text-muted-foreground text-xs">{estimate.note}</p> : null}
@@ -71,7 +71,11 @@ export function NewsletterSignup() {
71
71
  getClient()
72
72
  .marketing.getBenefit(locale)
73
73
  .then((result) => {
74
- if (!cancelled) setOffer(result);
74
+ // Only a complete, enabled offer is ever shown. An empty or partial body
75
+ // (an older backend, a half-configured benefit) must read as "no offer",
76
+ // never as "undefined off your first order".
77
+ const usable = result && result.enabled === true && Number.isFinite(result.discountValue);
78
+ if (!cancelled) setOffer(usable ? result : null);
75
79
  })
76
80
  .catch(() => {
77
81
  // Silent on purpose. A failed offer read must not stop someone
@@ -71,7 +71,7 @@ export function SiteFooter({ footer, storeName }: SiteFooterProps) {
71
71
  <div className="mx-auto max-w-md text-start">
72
72
  <NewsletterSignup />
73
73
  </div>
74
- <div>© {year} {brandLabel}. All rights reserved.</div>
74
+ <div>© {year} {brandLabel}. <%- language === 'he' ? 'כל הזכויות שמורות.' : 'All rights reserved.' %></div>
75
75
  </div>
76
76
  </footer>
77
77
  );
@@ -152,7 +152,7 @@ export function SiteFooter({ footer, storeName }: SiteFooterProps) {
152
152
  </div>
153
153
 
154
154
  <div className="border-border text-muted-foreground mt-10 flex flex-col items-center justify-between gap-2 border-t pt-6 text-xs sm:flex-row">
155
- <span>{data.copyright ?? `© ${year} ${brandLabel}. All rights reserved.`}</span>
155
+ <span>{data.copyright ?? `© ${year} ${brandLabel}. <%- language === 'he' ? 'כל הזכויות שמורות.' : 'All rights reserved.' %>`}</span>
156
156
  </div>
157
157
  </div>
158
158
  </footer>
@@ -8,7 +8,11 @@ import { formatPrice } from 'brainerce';
8
8
  import { useCart, useStoreInfo } from '@/core/providers/store-provider';
9
9
  import { useCurrency } from '@/core/lib/use-currency';
10
10
  import { useTranslations } from '@/core/lib/translations';
11
- import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
11
+ import {
12
+ addToCartErrorMessageKey,
13
+ toAddToCartError,
14
+ type AddToCartError,
15
+ } from '@/core/lib/add-to-cart-error';
12
16
  import { Button } from '@/components/ui/button';
13
17
  import { Card } from '@/components/ui/card';
14
18
  import { Checkbox } from '@/components/ui/checkbox';
@@ -214,7 +218,7 @@ export function FrequentlyBoughtTogether({
214
218
  */}
215
219
  {addError && (
216
220
  <p role="alert" className="text-destructive mt-3 text-sm">
217
- {addError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}
221
+ {t(addToCartErrorMessageKey(addError))}
218
222
  </p>
219
223
  )}
220
224
  </Card>
@@ -18,7 +18,11 @@ import { Card } from '@/components/ui/card';
18
18
  import { useCart } from '@/core/providers/store-provider';
19
19
  import { useCurrency } from '@/core/lib/use-currency';
20
20
  import { trackAddToCart } from '@/core/lib/tracking';
21
- import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
21
+ import {
22
+ addToCartErrorMessageKey,
23
+ toAddToCartError,
24
+ type AddToCartError,
25
+ } from '@/core/lib/add-to-cart-error';
22
26
  import { cn } from '@/core/lib/utils';
23
27
 
24
28
  interface ProductCardProps {
@@ -238,7 +242,7 @@ export function ProductCard({ product, className }: ProductCardProps) {
238
242
  */}
239
243
  {addError && (
240
244
  <p className="text-destructive text-xs" role="alert">
241
- {addError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}
245
+ {tp(addToCartErrorMessageKey(addError))}
242
246
  </p>
243
247
  )}
244
248
  </div>
@@ -1,9 +1,10 @@
1
1
  'use client';
2
2
 
3
- import { Download, FileText, Image as ImageIcon } from 'lucide-react';
3
+ import { Download, Image as ImageIcon } from 'lucide-react';
4
4
  import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
- import type { Product, ProductMetafield, DownloadFile } from 'brainerce';
5
+ import type { Product, ProductMetafield } from 'brainerce';
6
6
  import { useProductPage } from '@/core/hooks/use-product-page';
7
+ import { addToCartErrorMessageKey } from '@/core/lib/add-to-cart-error';
7
8
  import { PriceDisplay } from '@/ui/product/price-display';
8
9
  import { LoadingSpinner } from '@/ui/shared/loading-spinner';
9
10
  import { VariantSelector } from '@/ui/product/variant-selector';
@@ -283,39 +284,6 @@ export function ProductClientSection({
283
284
  <StockBadge inventory={inventory} />
284
285
  )}
285
286
 
286
- {/* Downloadable files info */}
287
- {product.isDownloadable && product.downloads && product.downloads.length > 0 && (
288
- <div className="bg-muted/50 rounded-lg border p-4">
289
- <p className="text-foreground mb-2 text-sm font-medium">
290
- {t('filesIncluded')} ({product.downloads.length})
291
- </p>
292
- <ul className="space-y-1.5">
293
- {product.downloads.map((file: DownloadFile) => (
294
- <li
295
- key={file.id}
296
- className="text-muted-foreground flex items-center gap-2 text-sm"
297
- >
298
- <FileText
299
- className="h-4 w-4 flex-shrink-0"
300
- strokeWidth={1.5}
301
- aria-hidden="true"
302
- />
303
- <span className="truncate">{file.name}</span>
304
- {file.size && (
305
- <span className="flex-shrink-0 text-xs">
306
- (
307
- {file.size < 1024 * 1024
308
- ? `${(file.size / 1024).toFixed(0)} KB`
309
- : `${(file.size / (1024 * 1024)).toFixed(1)} MB`}
310
- )
311
- </span>
312
- )}
313
- </li>
314
- ))}
315
- </ul>
316
- </div>
317
- )}
318
-
319
287
  {/* Variant Selector */}
320
288
  {/* A KIT is bought as ONE line, but the shopper needs to see what is
321
289
  in the box before deciding. These rows are display only — never add
@@ -445,7 +413,7 @@ export function ProductClientSection({
445
413
  */}
446
414
  {addToCartError && (
447
415
  <p className="text-destructive text-sm" role="alert">
448
- {addToCartError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}
416
+ {t(addToCartErrorMessageKey(addToCartError))}
449
417
  </p>
450
418
  )}
451
419
 
@@ -7,7 +7,11 @@ import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
7
7
  import { formatPrice } from 'brainerce';
8
8
  import { useCurrency } from '@/core/lib/use-currency';
9
9
  import { useTranslations } from '@/core/lib/translations';
10
- import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
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
  import { IconBag } from '@/ui/shared/icons';
13
17
 
@@ -123,7 +127,7 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
123
127
  */}
124
128
  {addError && (
125
129
  <p role="alert" className="text-destructive mt-2 text-xs">
126
- {addError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}
130
+ {tp(addToCartErrorMessageKey(addError))}
127
131
  </p>
128
132
  )}
129
133
  </article>
@@ -8,7 +8,11 @@ import { formatPrice } from 'brainerce';
8
8
  import { getClient } from '@/core/lib/brainerce';
9
9
  import { useCurrency } from '@/core/lib/use-currency';
10
10
  import { useTranslations } from '@/core/lib/translations';
11
- import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
11
+ import {
12
+ addToCartErrorMessageKey,
13
+ toAddToCartError,
14
+ type AddToCartError,
15
+ } from '@/core/lib/add-to-cart-error';
12
16
  import { cn } from '@/core/lib/utils';
13
17
  import { IconX } from '@/ui/shared/icons';
14
18
 
@@ -191,9 +195,7 @@ export function CartUpgradeBanner({
191
195
  <p role="alert" className="text-destructive mt-1 text-xs font-medium">
192
196
  {upgradeError === 'ORIGINAL_NOT_REMOVED'
193
197
  ? t('upgradeOriginalNotRemoved')
194
- : upgradeError === 'CART_FULL'
195
- ? tp('cartFull')
196
- : tp('addToCartFailed')}
198
+ : tp(addToCartErrorMessageKey(upgradeError))}
197
199
  </p>
198
200
  )}
199
201
  </div>