create-brainerce-store 1.78.0 → 1.80.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 (36) hide show
  1. package/dist/index.js +27 -2
  2. package/messages/en.json +12 -3
  3. package/messages/he.json +12 -3
  4. package/package.json +10 -1
  5. package/templates/nextjs/base/.eslintrc.json +2 -0
  6. package/templates/nextjs/base/AGENTS.md.ejs +23 -5
  7. package/templates/nextjs/base/CLAUDE.md.ejs +23 -5
  8. package/templates/nextjs/base/src/app/checkout/page.tsx +19 -2
  9. package/templates/nextjs/base/src/app/order-status/page.tsx +18 -3
  10. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +220 -214
  11. package/templates/nextjs/base/src/components/account/order-history.tsx +11 -4
  12. package/templates/nextjs/base/src/components/account/profile-section.tsx +7 -1
  13. package/templates/nextjs/base/src/components/auth/register-form.tsx +18 -1
  14. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -2
  15. package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +1 -1
  16. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +343 -328
  17. package/templates/nextjs/base/src/core/lib/kit.ts +88 -0
  18. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +87 -12
  19. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +59 -12
  20. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +205 -197
  21. package/templates/nextjs/base/src/ui/product/product-card.tsx +230 -221
  22. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +524 -493
  23. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +117 -108
  24. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +23 -3
  25. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +210 -202
  26. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +251 -242
  27. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +540 -509
  28. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +110 -101
  29. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +22 -2
  30. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +41 -11
  31. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +50 -8
  32. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +182 -174
  33. package/templates/nextjs/ui-canvas/product/product-card.tsx +174 -165
  34. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +31 -0
  35. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +114 -105
  36. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +22 -2
@@ -0,0 +1,88 @@
1
+ import type { InventoryInfo, Product } from 'brainerce';
2
+
3
+ /**
4
+ * KIT stock, for every surface that shows availability.
5
+ *
6
+ * A `KIT` is one purchasable product assembled from other catalog products, and
7
+ * it carries **no `inventory` row of its own** — the component that runs out
8
+ * first decides how many kits can be sold, and the API reports that as
9
+ * `product.kitAvailable`:
10
+ *
11
+ * - `null` → unlimited (every component is untracked)
12
+ * - `0` → not sellable (a component is out, or the kit has none)
13
+ * - a number → that many kits
14
+ * - `undefined` → not a kit; the field is absent on SIMPLE / VARIABLE
15
+ *
16
+ * ⛔ `null` and `undefined` mean OPPOSITE things here. Treating `null` as "no
17
+ * stock" renders an unlimited kit as sold out, which is the same bug as the one
18
+ * this file exists to fix, only inverted.
19
+ *
20
+ * Why a helper rather than `product.inventory` at each call site: reading
21
+ * `inventory` alone gave every kit two contradictory answers on one page — a red
22
+ * "Out of stock" badge (because `!inventory`) beside an ENABLED add-to-cart
23
+ * button (because `inventory?.canPurchase !== false` is `true` when inventory is
24
+ * null). Both now read the same resolved value.
25
+ */
26
+
27
+ /** The subset of a product this module needs. Keeps it usable with list rows. */
28
+ type StockSource = Pick<Product, 'type'> & {
29
+ inventory?: InventoryInfo | null;
30
+ kitAvailable?: number | null;
31
+ };
32
+
33
+ /**
34
+ * Build the `InventoryInfo` a kit would have if it had one.
35
+ *
36
+ * Mirrors the shape the backend synthesizes for the sales-channel read, so a
37
+ * storefront cannot disagree with the API about the same kit.
38
+ */
39
+ export function kitInventory(kitAvailable: number | null): InventoryInfo {
40
+ const unlimited = kitAvailable === null;
41
+ const available = kitAvailable ?? 0;
42
+ return {
43
+ total: available,
44
+ reserved: 0,
45
+ available,
46
+ trackingMode: unlimited ? 'UNLIMITED' : 'TRACKED',
47
+ inStock: unlimited || available > 0,
48
+ canPurchase: unlimited || available > 0,
49
+ // A kit is never backorderable: its components decide, not the kit.
50
+ backorderMode: 'NONE',
51
+ };
52
+ }
53
+
54
+ /**
55
+ * The stock signal to render for a product — kit or not.
56
+ *
57
+ * Pass the selected variant's inventory as the second argument on a product
58
+ * page; it wins for VARIABLE products and is irrelevant for a kit, which takes
59
+ * no variant at all.
60
+ */
61
+ export function resolveStockInfo(
62
+ product: StockSource | null | undefined,
63
+ variantInventory?: InventoryInfo | null
64
+ ): InventoryInfo | null {
65
+ if (variantInventory) return variantInventory;
66
+ if (!product) return null;
67
+ if (product.type === 'KIT') {
68
+ // `kitAvailable` first. Some reads also synthesize an `inventory` block for
69
+ // kits, so fall back to it before giving up — and only treat the kit as
70
+ // unresolved (`null`) when neither field arrived.
71
+ if (product.kitAvailable !== undefined) return kitInventory(product.kitAvailable);
72
+ return product.inventory ?? null;
73
+ }
74
+ return product.inventory ?? null;
75
+ }
76
+
77
+ /**
78
+ * Can this product go in the cart right now?
79
+ *
80
+ * `!== false` rather than `=== true` on purpose: a missing stock signal must not
81
+ * disable the button on a store that does not track inventory at all.
82
+ */
83
+ export function canPurchaseProduct(
84
+ product: StockSource | null | undefined,
85
+ variantInventory?: InventoryInfo | null
86
+ ): boolean {
87
+ return resolveStockInfo(product, variantInventory)?.canPurchase !== false;
88
+ }
@@ -3,6 +3,7 @@
3
3
  import { useState } from 'react';
4
4
  import { Gift } from 'lucide-react';
5
5
  import type { Checkout } from 'brainerce';
6
+ import { BrainerceError } from 'brainerce';
6
7
  import { getClient } from '@/core/lib/brainerce';
7
8
  import { useTranslations } from '@/core/lib/translations';
8
9
  import { LoadingSpinner } from '@/ui/shared/loading-spinner';
@@ -24,6 +25,17 @@ interface GiftCardInputProps {
24
25
  * from being confused again.
25
26
  */
26
27
  tenders: NonNullable<Checkout['tenders']>;
28
+ /**
29
+ * Whether the checkout can still take a card.
30
+ *
31
+ * False from the moment a payment intent exists: the server refuses every
32
+ * tender change on a `PAYMENT_PENDING` / `PAYMENT_PROCESSING` checkout,
33
+ * because changing what a card pays underneath a created intent would leave
34
+ * the provider charging the wrong amount. That refusal is correct. What was
35
+ * wrong was leaving the field enabled in front of it, so a shopper typed a
36
+ * good code into a box that could never accept one.
37
+ */
38
+ locked: boolean;
27
39
  /** Formats an amount in the checkout's currency. */
28
40
  formatAmount: (value: string) => string;
29
41
  onUpdate: () => void;
@@ -60,12 +72,20 @@ interface GiftCardInputProps {
60
72
  *
61
73
  * The server answers identically for "no such code", "expired", "already spent"
62
74
  * and "wrong currency". That is deliberate: any response that distinguishes them
63
- * is an oracle someone walks the code space against. Show what the server said;
64
- * never guess a more specific reason.
75
+ * is an oracle someone walks the code space against. So this shows ONE message
76
+ * of its own for all of them, rather than forwarding the server's text -- which
77
+ * would put the API's English on a translated storefront and make the guarantee
78
+ * depend on the server never adding a more helpful sentence.
79
+ *
80
+ * Two refusals are NOT about the card and must not read as if they were: a 5xx
81
+ * or a dropped connection, and a checkout already locked for payment. Telling
82
+ * someone their card is bad in either case sends them to support over a card
83
+ * that works.
65
84
  */
66
85
  export function GiftCardInput({
67
86
  checkoutId,
68
87
  tenders,
88
+ locked,
69
89
  formatAmount,
70
90
  onUpdate,
71
91
  className,
@@ -77,6 +97,17 @@ export function GiftCardInput({
77
97
  const [removingId, setRemovingId] = useState<string | null>(null);
78
98
  const [error, setError] = useState<string | null>(null);
79
99
 
100
+ /**
101
+ * `CHECKOUT_LOCKED` is the one 4xx that is not a statement about the card.
102
+ * The code travels in the response body, which the SDK hands over whole as
103
+ * `details`; read it defensively, since an older backend may not send one.
104
+ */
105
+ function isCheckoutLocked(err: unknown): boolean {
106
+ if (!(err instanceof BrainerceError)) return false;
107
+ const details = err.details as { code?: unknown } | null | undefined;
108
+ return details?.code === 'CHECKOUT_LOCKED';
109
+ }
110
+
80
111
  async function handleApply() {
81
112
  const trimmed = code.trim();
82
113
  if (!trimmed || applying) return;
@@ -90,7 +121,25 @@ export function GiftCardInput({
90
121
  setCode('');
91
122
  onUpdate();
92
123
  } catch (err) {
93
- setError(err instanceof Error ? err.message : t('invalidCode'));
124
+ // ONE message for every rejected code, written by this store.
125
+ //
126
+ // Echoing the server's text put raw English on a Hebrew storefront — the
127
+ // class-validator string for a too-short code came through verbatim. The
128
+ // deeper reason is that a refusal must not vary: the backend answers
129
+ // "expired", "revoked", "wrong currency" and "no such code" with a single
130
+ // sentence on purpose, because a response that tells them apart is an
131
+ // oracle for walking the code space. Forwarding whatever arrives makes
132
+ // that guarantee depend on the server never adding a more helpful
133
+ // message, which is not a guarantee at all.
134
+ //
135
+ // A 5xx or a dropped connection is NOT a refusal and must not read as
136
+ // one — telling someone their card is bad when the network failed sends
137
+ // them to support over a working card. Neither is a locked checkout,
138
+ // which is a 400 and says nothing whatever about the code typed in.
139
+ const refused = err instanceof BrainerceError && err.statusCode < 500;
140
+ setError(
141
+ isCheckoutLocked(err) ? t('locked') : refused ? t('invalidCode') : t('applyFailed')
142
+ );
94
143
  } finally {
95
144
  setApplying(false);
96
145
  }
@@ -104,7 +153,9 @@ export function GiftCardInput({
104
153
  await getClient().removeGiftCard(checkoutId, tenderId);
105
154
  onUpdate();
106
155
  } catch (err) {
107
- setError(err instanceof Error ? err.message : t('removeFailed'));
156
+ // Same three cases as apply, and for the same reason: the server's own
157
+ // text is English, and a locked checkout is not a failed removal.
158
+ setError(isCheckoutLocked(err) ? t('locked') : t('removeFailed'));
108
159
  } finally {
109
160
  setRemovingId(null);
110
161
  }
@@ -128,19 +179,42 @@ export function GiftCardInput({
128
179
  {t('applied', { amount: formatAmount(tender.amountApplied) })}
129
180
  </span>
130
181
  </div>
131
- <button
132
- type="button"
133
- onClick={() => handleRemove(tender.tenderId)}
134
- disabled={removingId === tender.tenderId}
135
- className="text-destructive hover:text-destructive/80 text-xs transition-colors disabled:opacity-40"
136
- >
137
- {removingId === tender.tenderId ? tc('removing') : tc('remove')}
138
- </button>
182
+ {/* Gone rather than disabled once the checkout is locked. The
183
+ server refuses a removal at that point too, and a dimmed
184
+ button still reads as something to press. */}
185
+ {!locked && (
186
+ <button
187
+ type="button"
188
+ onClick={() => handleRemove(tender.tenderId)}
189
+ disabled={removingId === tender.tenderId}
190
+ className="text-destructive hover:text-destructive/80 text-xs transition-colors disabled:opacity-40"
191
+ >
192
+ {removingId === tender.tenderId ? tc('removing') : tc('remove')}
193
+ </button>
194
+ )}
139
195
  </li>
140
196
  ))}
141
197
  </ul>
142
198
  )}
143
199
 
200
+ {/* The field is GONE once the checkout is locked, not disabled and not
201
+ silently broken.
202
+
203
+ Payment starts the moment the shopper picks a shipping rate, and from
204
+ then on the server refuses every tender change — correctly, since the
205
+ provider is already holding an amount. The box stayed on screen and
206
+ accepted typing anyway, so a shopper at the payment step could enter a
207
+ perfectly good code and be told the card was no good. A card is added
208
+ before payment or not at all, and saying so is kinder than a field
209
+ that cannot work. */}
210
+ {locked ? (
211
+ // Only to someone who has a card on this order. With nothing applied
212
+ // there is nothing to explain, and unprompted gift-card copy at the
213
+ // payment step is just noise.
214
+ tenders.length > 0 ? (
215
+ <p className="text-muted-foreground text-xs">{t('lockedHint')}</p>
216
+ ) : null
217
+ ) : (
144
218
  <div className="flex gap-2">
145
219
  <Input
146
220
  type="text"
@@ -180,6 +254,7 @@ export function GiftCardInput({
180
254
  )}
181
255
  </Button>
182
256
  </div>
257
+ )}
183
258
 
184
259
  {error && (
185
260
  <p role="alert" className="text-destructive text-xs">
@@ -21,17 +21,26 @@
21
21
  * commonest reason a signup never becomes a subscriber, and the resend cooldown
22
22
  * means no second copy arrives for 24 hours.
23
23
  *
24
- * **Discount codes.** Subscribing mints nothing. For a "10% off your first
25
- * order" offer, the merchant creates a coupon in the dashboard with the
26
- * `customer_first_order` condition and you render that fixed code in the
27
- * success state — see the commented line below. Show it immediately: it must
28
- * not wait for the confirmation click, or the shopper loses the reason they
29
- * filled the form in.
24
+ * **The welcome offer.** `marketing.getBenefit()` returns what the merchant is
25
+ * offering people who confirm, or `null` when they offer nothing. Show its
26
+ * terms beside the field so the shopper knows what they are signing up for.
27
+ *
28
+ * DO NOT RENDER A COUPON CODE HERE, now or after the success state. No coupon
29
+ * exists at either moment. It is minted when the recipient clicks the link in
30
+ * their own mailbox and is emailed to them there — which is exactly what stops a
31
+ * forwarded link handing the discount to someone who never asked. The
32
+ * confirmation page is served by the API and already shows the code, its expiry
33
+ * and its terms, so there is nothing here to poll for either.
34
+ *
35
+ * ⛔ There is no eligibility check to call, deliberately: a per-address answer
36
+ * would let anyone test who already subscribed. The offer is one per address per
37
+ * store, forever. Say so in the terms rather than trying to detect it.
30
38
  *
31
39
  * Rate limited to 3 requests / 60s per IP.
32
40
  */
33
41
 
34
- import { useMemo, useState } from 'react';
42
+ import { useEffect, useMemo, useState } from 'react';
43
+ import type { PublicNewsletterBenefitOffer } from 'brainerce';
35
44
  import { getClient } from '@/core/lib/brainerce';
36
45
  import { useTranslations } from '@/core/lib/translations';
37
46
 
@@ -42,6 +51,10 @@ export function NewsletterSignup() {
42
51
  const [loading, setLoading] = useState(false);
43
52
  const [done, setDone] = useState(false);
44
53
  const [error, setError] = useState<string | null>(null);
54
+ // null covers both "no offer configured" and "the read failed". Neither is
55
+ // worth an error state on a footer signup: the form still works, it just
56
+ // promises nothing, and promising nothing is always safe.
57
+ const [offer, setOffer] = useState<PublicNewsletterBenefitOffer | null>(null);
45
58
 
46
59
  // Same source the contact form uses — <html lang> is set from the active
47
60
  // locale, and passing it is what decides the language of the confirmation
@@ -51,6 +64,31 @@ export function NewsletterSignup() {
51
64
  return undefined;
52
65
  }, []);
53
66
 
67
+ // Read once per mount. The offer belongs to the store, not to the visitor,
68
+ // so re-reading it per keystroke or per submit is wasted work.
69
+ useEffect(() => {
70
+ let cancelled = false;
71
+ getClient()
72
+ .marketing.getBenefit(locale)
73
+ .then((result) => {
74
+ if (!cancelled) setOffer(result);
75
+ })
76
+ .catch(() => {
77
+ // Silent on purpose. A failed offer read must not stop someone
78
+ // subscribing, and there is nothing the shopper could do about it.
79
+ });
80
+ return () => {
81
+ cancelled = true;
82
+ };
83
+ }, [locale]);
84
+
85
+ const offerHeadline = offer
86
+ ? offer.headline?.trim() ||
87
+ (offer.discountType === 'PERCENTAGE'
88
+ ? t('offerPercent', { value: String(offer.discountValue) })
89
+ : t('offerAmount', { value: String(offer.discountValue) }))
90
+ : null;
91
+
54
92
  async function handleSubmit(e: React.FormEvent) {
55
93
  e.preventDefault();
56
94
  if (loading || !email.trim()) return;
@@ -80,11 +118,10 @@ export function NewsletterSignup() {
80
118
  <div className="space-y-1">
81
119
  <p className="text-sm font-medium">{t('checkEmailTitle')}</p>
82
120
  <p className="text-sm opacity-70">{t('checkEmailBody')}</p>
83
- {/*
84
- Merchant offering a signup discount? Render the coupon code here, now
85
- not after the confirmation click, which may never come:
86
- <p className="text-sm font-medium">{t('discountCode', { code: 'WELCOME10' })}</p>
87
- */}
121
+ {/* Says where the code comes from, without ever showing one. The coupon
122
+ is created by the confirmation click and arrives in the same inbox,
123
+ so "check your email" is the whole instruction. */}
124
+ {offer ? <p className="text-sm opacity-70">{t('offerByEmail')}</p> : null}
88
125
  </div>
89
126
  );
90
127
  }
@@ -96,6 +133,16 @@ export function NewsletterSignup() {
96
133
  </label>
97
134
  <p className="text-sm opacity-70">{t('subtitle')}</p>
98
135
 
136
+ {/* The merchant's own words when they wrote any, a plain statement of the
137
+ discount when they did not. Both are the promise the shopper is
138
+ agreeing to, so they belong above the input, not under the button. */}
139
+ {offerHeadline ? (
140
+ <div className="space-y-0.5">
141
+ <p className="text-sm font-medium">{offerHeadline}</p>
142
+ {offer?.terms ? <p className="text-xs opacity-70">{offer.terms}</p> : null}
143
+ </div>
144
+ ) : null}
145
+
99
146
  <div className="flex flex-wrap gap-2">
100
147
  <input
101
148
  id="newsletter-email"