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.
Files changed (41) hide show
  1. package/dist/index.js +9 -2
  2. package/messages/en.json +10 -1
  3. package/messages/he.json +10 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/.eslintrc.json +1 -0
  6. package/templates/nextjs/base/AI-GUIDE.md +224 -221
  7. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +296 -296
  8. package/templates/nextjs/base/src/app/checkout/page.tsx +89 -6
  9. package/templates/nextjs/base/src/app/products/layout.tsx.ejs +18 -0
  10. package/templates/nextjs/base/src/components/auth/register-form.tsx +381 -375
  11. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +14 -8
  12. package/templates/nextjs/base/src/core/hooks/use-page-exists.ts +37 -0
  13. package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +27 -2
  14. package/templates/nextjs/base/src/core/lib/brainerce.server.ts +121 -121
  15. package/templates/nextjs/base/src/core/lib/coupon-error.ts +29 -0
  16. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +6 -2
  17. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +6 -4
  18. package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +138 -134
  19. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +10 -3
  20. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +5 -1
  21. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +2 -2
  22. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +6 -2
  23. package/templates/nextjs/base/src/ui/product/product-card.tsx +6 -2
  24. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +4 -36
  25. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +6 -2
  26. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +6 -4
  27. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +146 -142
  28. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +10 -3
  29. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +6 -2
  30. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +6 -2
  31. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +3 -28
  32. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +6 -2
  33. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +6 -4
  34. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +123 -119
  35. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +4 -3
  36. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +5 -1
  37. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
  38. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +6 -2
  39. package/templates/nextjs/ui-canvas/product/product-card.tsx +6 -2
  40. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +3 -28
  41. package/templates/nextjs/base/src/app/products/layout.tsx +0 -18
@@ -1,142 +1,146 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import type { Cart } from 'brainerce';
5
- import { getClient } from '@/core/lib/brainerce';
6
- import { useTranslations } from '@/core/lib/translations';
7
- import { LoadingSpinner } from '@/ui/shared/loading-spinner';
8
- import { cn } from '@/core/lib/utils';
9
- import { IconTag, IconX } from '@/ui/shared/icons';
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
- * Coupon apply/remove control used on the cart page AND the checkout page
22
- * (via `checkoutId`); the `cart` prop comes from useCartPage(). Keeps the
23
- * applied-coupon state and the error message.
24
- */
25
- export function CouponInput({ cart, checkoutId, onUpdate, className }: CouponInputProps) {
26
- const t = useTranslations('coupon');
27
- const tc = useTranslations('common');
28
- const [code, setCode] = useState('');
29
- const [applying, setApplying] = useState(false);
30
- const [removing, setRemoving] = useState(false);
31
- const [error, setError] = useState<string | null>(null);
32
-
33
- const appliedCoupon = cart.couponCode || null;
34
-
35
- async function handleApply() {
36
- const trimmed = code.trim();
37
- if (!trimmed || applying) return;
38
-
39
- try {
40
- setApplying(true);
41
- setError(null);
42
- const client = getClient();
43
- if (checkoutId) {
44
- await client.applyCheckoutCoupon(checkoutId, trimmed);
45
- } else {
46
- await client.applyCoupon(cart.id, trimmed);
47
- }
48
- setCode('');
49
- onUpdate();
50
- } catch (err) {
51
- const message = err instanceof Error ? err.message : t('invalidCode');
52
- setError(message);
53
- } finally {
54
- setApplying(false);
55
- }
56
- }
57
-
58
- async function handleRemove() {
59
- if (removing) return;
60
-
61
- try {
62
- setRemoving(true);
63
- setError(null);
64
- const client = getClient();
65
- if (checkoutId) {
66
- await client.removeCheckoutCoupon(checkoutId);
67
- } else {
68
- await client.removeCoupon(cart.id);
69
- }
70
- onUpdate();
71
- } catch (err) {
72
- console.error('Failed to remove coupon:', err);
73
- } finally {
74
- setRemoving(false);
75
- }
76
- }
77
-
78
- // Show applied coupon
79
- if (appliedCoupon) {
80
- return (
81
- <div
82
- className={cn(
83
- 'inline-flex items-center gap-2 rounded-full bg-secondary py-1.5 pe-2 ps-4',
84
- className
85
- )}
86
- >
87
- <span aria-hidden="true" className="text-primary">
88
- <IconTag size={16} />
89
- </span>
90
- <span className="text-sm font-semibold text-foreground">{appliedCoupon}</span>
91
- <button
92
- type="button"
93
- onClick={handleRemove}
94
- disabled={removing}
95
- aria-label={tc('remove')}
96
- className="flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
97
- >
98
- {removing ? <LoadingSpinner size="sm" /> : <IconX size={16} />}
99
- </button>
100
- </div>
101
- );
102
- }
103
-
104
- return (
105
- <div className={cn(className)}>
106
- <div className="flex max-w-sm gap-2">
107
- <input
108
- type="text"
109
- value={code}
110
- onChange={(e) => {
111
- setCode(e.target.value);
112
- if (error) setError(null);
113
- }}
114
- onKeyDown={(e) => {
115
- if (e.key === 'Enter') {
116
- e.preventDefault();
117
- handleApply();
118
- }
119
- }}
120
- placeholder={t('placeholder')}
121
- aria-label={t('placeholder')}
122
- aria-invalid={error ? true : undefined}
123
- className="input"
124
- />
125
- <button
126
- type="button"
127
- onClick={handleApply}
128
- disabled={applying || !code.trim()}
129
- className="btn-soft btn-md shrink-0"
130
- >
131
- {applying ? <LoadingSpinner size="sm" /> : tc('apply')}
132
- </button>
133
- </div>
134
-
135
- {error && (
136
- <p role="alert" className="mt-2 text-sm font-medium text-destructive">
137
- {error}
138
- </p>
139
- )}
140
- </div>
141
- );
142
- }
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
+ import { IconTag, IconX } from '@/ui/shared/icons';
11
+
12
+ interface CouponInputProps {
13
+ cart: Cart;
14
+ /** When provided, uses applyCheckoutCoupon/removeCheckoutCoupon instead of
15
+ * applyCoupon/removeCoupon. Always pass this when a checkout session exists. */
16
+ checkoutId?: string;
17
+ onUpdate: () => void;
18
+ className?: string;
19
+ }
20
+
21
+ /**
22
+ * Coupon apply/remove control used on the cart page AND the checkout page
23
+ * (via `checkoutId`); the `cart` prop comes from useCartPage(). Keeps the
24
+ * applied-coupon state and the error message.
25
+ */
26
+ export function CouponInput({ cart, checkoutId, onUpdate, className }: CouponInputProps) {
27
+ const t = useTranslations('coupon');
28
+ const tc = useTranslations('common');
29
+ const [code, setCode] = useState('');
30
+ const [applying, setApplying] = useState(false);
31
+ const [removing, setRemoving] = useState(false);
32
+ const [error, setError] = useState<string | null>(null);
33
+
34
+ const appliedCoupon = cart.couponCode || null;
35
+
36
+ async function handleApply() {
37
+ const trimmed = code.trim();
38
+ if (!trimmed || applying) return;
39
+
40
+ try {
41
+ setApplying(true);
42
+ setError(null);
43
+ const client = getClient();
44
+ if (checkoutId) {
45
+ await client.applyCheckoutCoupon(checkoutId, trimmed);
46
+ } else {
47
+ await client.applyCoupon(cart.id, trimmed);
48
+ }
49
+ setCode('');
50
+ onUpdate();
51
+ } catch (err) {
52
+ // Known codes are shown from our own (translated) messages; the API's
53
+ // `message` is English. An uncoded refusal falls back to that message.
54
+ const key = couponErrorKey(err);
55
+ const message = key ? t(key) : err instanceof Error ? err.message : t('invalidCode');
56
+ setError(message);
57
+ } finally {
58
+ setApplying(false);
59
+ }
60
+ }
61
+
62
+ async function handleRemove() {
63
+ if (removing) return;
64
+
65
+ try {
66
+ setRemoving(true);
67
+ setError(null);
68
+ const client = getClient();
69
+ if (checkoutId) {
70
+ await client.removeCheckoutCoupon(checkoutId);
71
+ } else {
72
+ await client.removeCoupon(cart.id);
73
+ }
74
+ onUpdate();
75
+ } catch (err) {
76
+ console.error('Failed to remove coupon:', err);
77
+ } finally {
78
+ setRemoving(false);
79
+ }
80
+ }
81
+
82
+ // Show applied coupon
83
+ if (appliedCoupon) {
84
+ return (
85
+ <div
86
+ className={cn(
87
+ 'bg-secondary inline-flex items-center gap-2 rounded-full py-1.5 pe-2 ps-4',
88
+ className
89
+ )}
90
+ >
91
+ <span aria-hidden="true" className="text-primary">
92
+ <IconTag size={16} />
93
+ </span>
94
+ <span className="text-foreground text-sm font-semibold">{appliedCoupon}</span>
95
+ <button
96
+ type="button"
97
+ onClick={handleRemove}
98
+ disabled={removing}
99
+ aria-label={tc('remove')}
100
+ className="text-muted-foreground hover:bg-accent hover:text-foreground flex h-7 w-7 items-center justify-center rounded-full transition-colors disabled:opacity-50"
101
+ >
102
+ {removing ? <LoadingSpinner size="sm" /> : <IconX size={16} />}
103
+ </button>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ return (
109
+ <div className={cn(className)}>
110
+ <div className="flex max-w-sm gap-2">
111
+ <input
112
+ type="text"
113
+ value={code}
114
+ onChange={(e) => {
115
+ setCode(e.target.value);
116
+ if (error) setError(null);
117
+ }}
118
+ onKeyDown={(e) => {
119
+ if (e.key === 'Enter') {
120
+ e.preventDefault();
121
+ handleApply();
122
+ }
123
+ }}
124
+ placeholder={t('placeholder')}
125
+ aria-label={t('placeholder')}
126
+ aria-invalid={error ? true : undefined}
127
+ className="input"
128
+ />
129
+ <button
130
+ type="button"
131
+ onClick={handleApply}
132
+ disabled={applying || !code.trim()}
133
+ className="btn-soft btn-md shrink-0"
134
+ >
135
+ {applying ? <LoadingSpinner size="sm" /> : tc('apply')}
136
+ </button>
137
+ </div>
138
+
139
+ {error && (
140
+ <p role="alert" className="text-destructive mt-2 text-sm font-medium">
141
+ {error}
142
+ </p>
143
+ )}
144
+ </div>
145
+ );
146
+ }
@@ -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 gap-4">
96
- <dt className="text-muted-foreground">{label}</dt>
96
+ <dt className="text-muted-foreground">
97
+ {estimate.pricesIncludeTax ? `${label} ${tc('inclSuffix')}` : label}
98
+ </dt>
97
99
  <dd className="text-foreground font-medium">
98
100
  {formatPrice(estimate.estimatedTax, { currency: estimate.currency }) as string}
99
101
  </dd>
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}
@@ -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 { cn } from '@/core/lib/utils';
13
17
  import { IconPlus, IconBag } from '@/ui/shared/icons';
14
18
 
@@ -224,7 +228,7 @@ export function FrequentlyBoughtTogether({
224
228
  */}
225
229
  {addError && (
226
230
  <p role="alert" className="text-destructive mt-3 text-sm font-medium">
227
- {addError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}
231
+ {t(addToCartErrorMessageKey(addError))}
228
232
  </p>
229
233
  )}
230
234
  </section>
@@ -16,7 +16,11 @@ import { flyToCart } from '@/ui/shared/fly-to-cart';
16
16
  import { openCartDrawer } from '@/ui/cart/cart-drawer';
17
17
  import { useCurrency } from '@/core/lib/use-currency';
18
18
  import { trackAddToCart } from '@/core/lib/tracking';
19
- import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
19
+ import {
20
+ addToCartErrorMessageKey,
21
+ toAddToCartError,
22
+ type AddToCartError,
23
+ } from '@/core/lib/add-to-cart-error';
20
24
  import { cn } from '@/core/lib/utils';
21
25
  import { NoPhotoTile } from '@/ui/shared/no-photo-tile';
22
26
  import { IconBag, IconCheck, IconDownload, IconStar } from '@/ui/shared/icons';
@@ -262,7 +266,7 @@ export function ProductCard({ product, className }: ProductCardProps) {
262
266
  */}
263
267
  {addError && (
264
268
  <p role="alert" className="text-destructive mt-2 text-xs">
265
- {addError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}
269
+ {tp(addToCartErrorMessageKey(addError))}
266
270
  </p>
267
271
  )}
268
272
  </div>
@@ -3,8 +3,9 @@
3
3
  import { useEffect } from 'react';
4
4
  import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
5
  import { NoPhotoTile } from '@/ui/shared/no-photo-tile';
6
- import type { Product, ProductMetafield, DownloadFile } from 'brainerce';
6
+ import type { Product, ProductMetafield } from 'brainerce';
7
7
  import { useProductPage } from '@/core/hooks/use-product-page';
8
+ import { addToCartErrorMessageKey } from '@/core/lib/add-to-cart-error';
8
9
  import { PriceDisplay } from '@/ui/product/price-display';
9
10
  import { VariantSelector } from '@/ui/product/variant-selector';
10
11
  import { StockBadge } from '@/ui/product/stock-badge';
@@ -289,32 +290,6 @@ export function ProductClientSection({
289
290
  )}
290
291
  </div>
291
292
 
292
- {/* Downloadable files info */}
293
- {product.isDownloadable && product.downloads && product.downloads.length > 0 && (
294
- <section className="bg-secondary/60 mt-6 rounded-xl border p-4">
295
- <h2 className="text-sm font-semibold">
296
- {t('filesIncluded').replace('{count}', String(product.downloads.length))}
297
- </h2>
298
- <ul className="text-muted-foreground mt-2 space-y-1.5 text-sm">
299
- {product.downloads.map((file: DownloadFile) => (
300
- <li key={file.id} className="flex items-center gap-2">
301
- <IconDownload size={16} />
302
- <span>{file.name}</span>
303
- {file.size && (
304
- <span className="text-xs">
305
- (
306
- {file.size < 1024 * 1024
307
- ? `${(file.size / 1024).toFixed(0)} KB`
308
- : `${(file.size / (1024 * 1024)).toFixed(1)} MB`}
309
- )
310
- </span>
311
- )}
312
- </li>
313
- ))}
314
- </ul>
315
- </section>
316
- )}
317
-
318
293
  {/* Variant Selector */}
319
294
  {/* A KIT is bought as ONE line, but the shopper needs to see what is
320
295
  in the box before deciding. These rows are display only — never add
@@ -447,7 +422,7 @@ export function ProductClientSection({
447
422
  */}
448
423
  {addToCartError && (
449
424
  <p role="alert" className="text-destructive mt-3 text-sm font-medium">
450
- {addToCartError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}
425
+ {t(addToCartErrorMessageKey(addToCartError))}
451
426
  </p>
452
427
  )}
453
428
 
@@ -6,7 +6,11 @@ import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
6
6
  import { formatPrice } from 'brainerce';
7
7
  import { useCurrency } from '@/core/lib/use-currency';
8
8
  import { useTranslations } from '@/core/lib/translations';
9
- import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
9
+ import {
10
+ addToCartErrorMessageKey,
11
+ toAddToCartError,
12
+ type AddToCartError,
13
+ } from '@/core/lib/add-to-cart-error';
10
14
  import { cn } from '@/core/lib/utils';
11
15
 
12
16
  interface CartBundleOfferCardProps {
@@ -98,7 +102,7 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
98
102
  nothing is added, which reads as a broken button.
99
103
  */}
100
104
  {addError && (
101
- <p role="alert">{addError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}</p>
105
+ <p role="alert">{tp(addToCartErrorMessageKey(addError))}</p>
102
106
  )}
103
107
  </article>
104
108
  );
@@ -7,7 +7,11 @@ import { formatPrice } from 'brainerce';
7
7
  import { getClient } from '@/core/lib/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
 
13
17
  interface CartUpgradeBannerProps {
@@ -188,9 +192,7 @@ export function CartUpgradeBanner({
188
192
  <p role="alert">
189
193
  {upgradeError === 'ORIGINAL_NOT_REMOVED'
190
194
  ? t('upgradeOriginalNotRemoved')
191
- : upgradeError === 'CART_FULL'
192
- ? tp('cartFull')
193
- : tp('addToCartFailed')}
195
+ : tp(addToCartErrorMessageKey(upgradeError))}
194
196
  </p>
195
197
  )}
196
198
  </div>