create-brainerce-store 1.87.0 → 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 (39) 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/src/app/api/store/[...path]/route.ts +296 -296
  6. package/templates/nextjs/base/src/app/checkout/page.tsx +89 -6
  7. package/templates/nextjs/base/src/app/products/layout.tsx.ejs +18 -0
  8. package/templates/nextjs/base/src/components/auth/register-form.tsx +381 -375
  9. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +14 -8
  10. package/templates/nextjs/base/src/core/hooks/use-page-exists.ts +37 -0
  11. package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +27 -2
  12. package/templates/nextjs/base/src/core/lib/brainerce.server.ts +121 -121
  13. package/templates/nextjs/base/src/core/lib/coupon-error.ts +29 -0
  14. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +6 -2
  15. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +6 -4
  16. package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +138 -134
  17. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +10 -3
  18. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +5 -1
  19. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +2 -2
  20. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +6 -2
  21. package/templates/nextjs/base/src/ui/product/product-card.tsx +6 -2
  22. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +4 -36
  23. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +6 -2
  24. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +6 -4
  25. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +146 -142
  26. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +10 -3
  27. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +6 -2
  28. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +6 -2
  29. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +3 -28
  30. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +6 -2
  31. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +6 -4
  32. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +123 -119
  33. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +4 -3
  34. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +5 -1
  35. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
  36. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +6 -2
  37. package/templates/nextjs/ui-canvas/product/product-card.tsx +6 -2
  38. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +3 -28
  39. 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 { useTranslations } from '@/core/lib/translations';
7
- import { LoadingSpinner } from '@/ui/shared/loading-spinner';
8
- import { cn } from '@/core/lib/utils';
9
-
10
- interface CouponInputProps {
11
- cart: Cart;
12
- /** When provided, uses applyCheckoutCoupon/removeCheckoutCoupon instead of
13
- * applyCoupon/removeCoupon. Always pass this when a checkout session exists. */
14
- checkoutId?: string;
15
- onUpdate: () => void;
16
- className?: string;
17
- }
18
-
19
- /**
20
- * DESIGN ME — coupon apply/remove control used on the cart page AND the
21
- * checkout page (via `checkoutId`); the `cart` prop comes from useCartPage().
22
- * Keep the applied-coupon state and the error message.
23
- *
24
- * 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.
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
- const message = 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('flex items-center gap-2', className)}>
83
- <span>{appliedCoupon}</span>
84
- <button type="button" onClick={handleRemove} disabled={removing}>
85
- {removing ? tc('removing') : tc('remove')}
86
- </button>
87
- </div>
88
- );
89
- }
90
-
91
- return (
92
- <div className={cn(className)}>
93
- <div className="flex gap-2">
94
- <input
95
- type="text"
96
- value={code}
97
- onChange={(e) => {
98
- setCode(e.target.value);
99
- if (error) setError(null);
100
- }}
101
- onKeyDown={(e) => {
102
- if (e.key === 'Enter') {
103
- e.preventDefault();
104
- handleApply();
105
- }
106
- }}
107
- placeholder={t('placeholder')}
108
- aria-label={t('placeholder')}
109
- aria-invalid={error ? true : undefined}
110
- />
111
- <button type="button" onClick={handleApply} disabled={applying || !code.trim()}>
112
- {applying ? <LoadingSpinner size="sm" /> : tc('apply')}
113
- </button>
114
- </div>
115
-
116
- {error && <p role="alert">{error}</p>}
117
- </div>
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
- <p>{t('taxAtCheckout')}</p>
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
- if (!cancelled) setOffer(result);
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.`}</p>
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 { 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 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">{addError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}</p>
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 { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
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">{addError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}</p>
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, DownloadFile } from 'brainerce';
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
- {addToCartError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}
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
- }