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.
- package/dist/index.js +27 -2
- package/messages/en.json +12 -3
- package/messages/he.json +12 -3
- package/package.json +10 -1
- package/templates/nextjs/base/.eslintrc.json +2 -0
- package/templates/nextjs/base/AGENTS.md.ejs +23 -5
- package/templates/nextjs/base/CLAUDE.md.ejs +23 -5
- package/templates/nextjs/base/src/app/checkout/page.tsx +19 -2
- package/templates/nextjs/base/src/app/order-status/page.tsx +18 -3
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +220 -214
- package/templates/nextjs/base/src/components/account/order-history.tsx +11 -4
- package/templates/nextjs/base/src/components/account/profile-section.tsx +7 -1
- package/templates/nextjs/base/src/components/auth/register-form.tsx +18 -1
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -2
- package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +1 -1
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +343 -328
- package/templates/nextjs/base/src/core/lib/kit.ts +88 -0
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +87 -12
- package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +59 -12
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +205 -197
- package/templates/nextjs/base/src/ui/product/product-card.tsx +230 -221
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +524 -493
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +117 -108
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +23 -3
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +210 -202
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +251 -242
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +540 -509
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +110 -101
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +22 -2
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +41 -11
- package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +50 -8
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +182 -174
- package/templates/nextjs/ui-canvas/product/product-card.tsx +174 -165
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +31 -0
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +114 -105
- 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.
|
|
64
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
* **
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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"
|