create-brainerce-store 1.79.0 → 1.81.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 +168 -10
- package/messages/en.json +13 -4
- package/messages/he.json +13 -4
- package/package.json +10 -1
- package/templates/nextjs/base/.eslintrc.json +2 -0
- package/templates/nextjs/base/AI-GUIDE.md +32 -2
- package/templates/nextjs/base/package.json.ejs +53 -52
- package/templates/nextjs/base/scripts/connect.mjs +149 -0
- package/templates/nextjs/base/src/app/checkout/page.tsx +18 -0
- 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/tracking-bootstrap.tsx +1 -1
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +22 -0
- package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +49 -0
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +18 -0
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +49 -0
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +96 -2
- package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +59 -12
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +17 -0
- package/templates/nextjs/base/src/ui/product/product-card.tsx +17 -0
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +13 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +18 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +49 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +101 -5
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +17 -0
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +17 -0
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +13 -0
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +16 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +45 -0
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +95 -2
- package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +50 -8
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +15 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +15 -0
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +13 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Connect this storefront to a Brainerce sales channel.
|
|
4
|
+
*
|
|
5
|
+
* Run it when the build is finished, not before:
|
|
6
|
+
*
|
|
7
|
+
* npm run connect
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS IS A SEPARATE STEP. Nothing in a storefront's code depends on WHICH
|
|
10
|
+
* store it points at — the pages, the routing, the cart, the checkout and every
|
|
11
|
+
* SDK call are identical either way. Only one environment variable differs. So
|
|
12
|
+
* a run scaffolded with `--defer-connection` builds the whole storefront
|
|
13
|
+
* uninterrupted and comes here at the end, instead of stopping for an approval
|
|
14
|
+
* before a single file exists.
|
|
15
|
+
*
|
|
16
|
+
* One click in a browser, and this writes the id into `.env.local`. A store and
|
|
17
|
+
* a sales channel are created for you if you have neither.
|
|
18
|
+
*
|
|
19
|
+
* ⛔ NEVER PROMPTS. This is run by coding agents in shells with no TTY, where a
|
|
20
|
+
* prompt does not fail — it hangs. Everything is printed and polled.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
24
|
+
import { spawn } from 'node:child_process';
|
|
25
|
+
import { resolve } from 'node:path';
|
|
26
|
+
|
|
27
|
+
const API_BASE = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(/\/$/, '');
|
|
28
|
+
const ENV_PATH = resolve(process.cwd(), '.env.local');
|
|
29
|
+
const KEYS = ['NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID', 'NEXT_PUBLIC_BRAINERCE_CONNECTION_ID'];
|
|
30
|
+
|
|
31
|
+
/** Only ever hand the OS a URL on the host we asked. It arrives in a network
|
|
32
|
+
* response, so it is untrusted input on the way to a process launch. */
|
|
33
|
+
function openable(value) {
|
|
34
|
+
try {
|
|
35
|
+
const url = new URL(value);
|
|
36
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return false;
|
|
37
|
+
const base = new URL(API_BASE).hostname.replace(/^api(-staging)?\./, '');
|
|
38
|
+
return url.hostname === base || url.hostname.endsWith(`.${base}`);
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function openBrowser(url) {
|
|
45
|
+
const [cmd, args] =
|
|
46
|
+
process.platform === 'win32'
|
|
47
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
48
|
+
: process.platform === 'darwin'
|
|
49
|
+
? ['open', [url]]
|
|
50
|
+
: ['xdg-open', [url]];
|
|
51
|
+
try {
|
|
52
|
+
// No shell, argument array: the URL cannot become a command.
|
|
53
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
54
|
+
child.on('error', () => {});
|
|
55
|
+
child.unref();
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Rewrite in place so hand-added variables survive. A blind overwrite here
|
|
63
|
+
* would silently drop a payment key someone pasted in an hour earlier. */
|
|
64
|
+
function writeEnv(connectionId) {
|
|
65
|
+
let body = existsSync(ENV_PATH) ? readFileSync(ENV_PATH, 'utf8') : '';
|
|
66
|
+
for (const key of KEYS) {
|
|
67
|
+
const line = `${key}=${connectionId}`;
|
|
68
|
+
const pattern = new RegExp(`^${key}=.*$`, 'm');
|
|
69
|
+
body = pattern.test(body) ? body.replace(pattern, line) : `${body.trimEnd()}\n${line}\n`;
|
|
70
|
+
}
|
|
71
|
+
writeFileSync(ENV_PATH, body.startsWith('\n') ? body.slice(1) : body);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function main() {
|
|
75
|
+
if (process.env.BRAINERCE_CONNECTION_ID) {
|
|
76
|
+
// Already known (CI, or a channel someone created by hand). Nothing to approve.
|
|
77
|
+
writeEnv(process.env.BRAINERCE_CONNECTION_ID.trim());
|
|
78
|
+
console.log('Wrote BRAINERCE_CONNECTION_ID from the environment into .env.local.');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let start;
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch(`${API_BASE}/api/device-auth/start`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'Content-Type': 'application/json' },
|
|
87
|
+
body: JSON.stringify({ clientName: process.env.BRAINERCE_CLIENT_NAME || 'this storefront' }),
|
|
88
|
+
});
|
|
89
|
+
if (!res.ok) {
|
|
90
|
+
console.error(`Could not start the approval (HTTP ${res.status}).`);
|
|
91
|
+
console.error('Create a sales channel in the dashboard, then set the id in .env.local.');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
start = await res.json();
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error(`Could not reach Brainerce: ${error?.message ?? error}`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const opened = openable(start.verificationUriComplete) && openBrowser(start.verificationUriComplete);
|
|
101
|
+
console.log('');
|
|
102
|
+
console.log(opened ? 'Opened your browser to approve this connection.' : 'Open this link to approve:');
|
|
103
|
+
console.log(` ${start.verificationUriComplete}`);
|
|
104
|
+
// The code is the only thing a person can actually check: it proves the
|
|
105
|
+
// request came from this run and not from someone else's.
|
|
106
|
+
console.log(` The page should show the code ${start.userCode}. Check that it matches.`);
|
|
107
|
+
console.log('');
|
|
108
|
+
console.log('Waiting for approval. A store and a sales channel are created if you have none.');
|
|
109
|
+
|
|
110
|
+
const deadline = Date.now() + start.expiresIn * 1000;
|
|
111
|
+
let interval = (start.interval || 5) * 1000;
|
|
112
|
+
|
|
113
|
+
while (Date.now() < deadline) {
|
|
114
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
115
|
+
let result;
|
|
116
|
+
try {
|
|
117
|
+
const res = await fetch(`${API_BASE}/api/device-auth/poll`, {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
headers: { 'Content-Type': 'application/json' },
|
|
120
|
+
body: JSON.stringify({ deviceCode: start.deviceCode }),
|
|
121
|
+
});
|
|
122
|
+
result = await res.json();
|
|
123
|
+
} catch {
|
|
124
|
+
continue; // A blip mid-poll is not fatal; the deadline is the real bound.
|
|
125
|
+
}
|
|
126
|
+
if (result.status === 'approved' && result.connectionId) {
|
|
127
|
+
writeEnv(result.connectionId);
|
|
128
|
+
console.log('');
|
|
129
|
+
console.log('Connected. The sales channel id is in .env.local.');
|
|
130
|
+
console.log('Start the storefront with: npm run dev');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (result.status === 'denied') {
|
|
134
|
+
console.error('The connection request was declined. Nothing was created.');
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
if (result.status === 'expired') {
|
|
138
|
+
console.error('The code expired before it was approved. Run this again.');
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
// Polled inside the interval: back off rather than hammering.
|
|
142
|
+
if (result.status === 'slow_down') interval += 1000;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.error('Timed out waiting for approval. Run this again for a fresh code.');
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
main();
|
|
@@ -31,6 +31,7 @@ import { GiftCardInput } from '@/ui/cart/gift-card-input';
|
|
|
31
31
|
import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
|
|
32
32
|
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
33
33
|
import { useTranslations } from '@/core/lib/translations';
|
|
34
|
+
import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
|
|
34
35
|
import { cn } from '@/core/lib/utils';
|
|
35
36
|
import { isValidCheckoutId } from '@/core/lib/safe-redirect';
|
|
36
37
|
import { trackBeginCheckout } from '@/core/lib/tracking';
|
|
@@ -52,6 +53,7 @@ function CheckoutContent() {
|
|
|
52
53
|
const t = useTranslations('checkout');
|
|
53
54
|
const tc = useTranslations('common');
|
|
54
55
|
const tr = useTranslations('reservation');
|
|
56
|
+
const tp = useTranslations('productDetail');
|
|
55
57
|
|
|
56
58
|
const [step, setStep] = useState<CheckoutStep>('address');
|
|
57
59
|
const [checkout, setCheckout] = useState<Checkout | null>(null);
|
|
@@ -77,6 +79,7 @@ function CheckoutContent() {
|
|
|
77
79
|
const [orderBumps, setOrderBumps] = useState<CheckoutBumpsResponse | null>(null);
|
|
78
80
|
const [addedBumpIds, setAddedBumpIds] = useState<Set<string>>(new Set());
|
|
79
81
|
const [bumpLoading, setBumpLoading] = useState<string | null>(null);
|
|
82
|
+
const [bumpError, setBumpError] = useState<AddToCartError | null>(null);
|
|
80
83
|
const [customFields, setCustomFields] = useState<CheckoutCustomFieldDefinition[]>([]);
|
|
81
84
|
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({});
|
|
82
85
|
const [customFieldsLoading, setCustomFieldsLoading] = useState(false);
|
|
@@ -354,6 +357,7 @@ function CheckoutContent() {
|
|
|
354
357
|
// Handle bump toggle
|
|
355
358
|
async function handleBumpToggle(bumpId: string, add: boolean, variantId?: string) {
|
|
356
359
|
if (!cart?.id || bumpLoading) return;
|
|
360
|
+
setBumpError(null);
|
|
357
361
|
try {
|
|
358
362
|
setBumpLoading(bumpId);
|
|
359
363
|
const client = getClient();
|
|
@@ -370,6 +374,10 @@ function CheckoutContent() {
|
|
|
370
374
|
}
|
|
371
375
|
await refreshCart();
|
|
372
376
|
} catch (err) {
|
|
377
|
+
// A bump is an extra cart line, so it can be refused by the 50-line cap
|
|
378
|
+
// like any other add. Logging alone left the checkbox springing back with
|
|
379
|
+
// no explanation, at the least forgiving point in the flow.
|
|
380
|
+
setBumpError(toAddToCartError(err));
|
|
373
381
|
console.error('Failed to toggle order bump:', err);
|
|
374
382
|
} finally {
|
|
375
383
|
setBumpLoading(null);
|
|
@@ -1044,6 +1052,16 @@ function CheckoutContent() {
|
|
|
1044
1052
|
loading={bumpLoading === bump.id}
|
|
1045
1053
|
/>
|
|
1046
1054
|
))}
|
|
1055
|
+
|
|
1056
|
+
{/*
|
|
1057
|
+
Why the bump was refused. Without it the checkbox just springs
|
|
1058
|
+
back and the shopper is left guessing at checkout.
|
|
1059
|
+
*/}
|
|
1060
|
+
{bumpError && (
|
|
1061
|
+
<p className="text-destructive text-xs" role="alert">
|
|
1062
|
+
{bumpError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}
|
|
1063
|
+
</p>
|
|
1064
|
+
)}
|
|
1047
1065
|
</div>
|
|
1048
1066
|
)}
|
|
1049
1067
|
|
|
@@ -7,6 +7,7 @@ import { useTranslations } from '@/core/lib/translations';
|
|
|
7
7
|
import { cn } from '@/core/lib/utils';
|
|
8
8
|
import { birthMonthKey, toBirthdayNumber } from '@/core/lib/birthday';
|
|
9
9
|
import { BirthdayPicker } from '@/components/shared/birthday-picker';
|
|
10
|
+
import { useStoreCapabilities } from '@/core/providers/store-provider';
|
|
10
11
|
|
|
11
12
|
interface ProfileSectionProps {
|
|
12
13
|
profile: CustomerProfile;
|
|
@@ -17,6 +18,9 @@ interface ProfileSectionProps {
|
|
|
17
18
|
export function ProfileSection({ profile, onProfileUpdate, className }: ProfileSectionProps) {
|
|
18
19
|
const t = useTranslations('account');
|
|
19
20
|
const tc = useTranslations('common');
|
|
21
|
+
const { capabilities } = useStoreCapabilities();
|
|
22
|
+
/** Explicit `true` only — see the note on the same gate in `register-form.tsx`. */
|
|
23
|
+
const birthdayGiftOn = capabilities?.features.hasBirthdayRewards === true;
|
|
20
24
|
const [editing, setEditing] = useState(false);
|
|
21
25
|
const [saving, setSaving] = useState(false);
|
|
22
26
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
@@ -182,7 +186,9 @@ export function ProfileSection({ profile, onProfileUpdate, className }: ProfileS
|
|
|
182
186
|
onChange={selectBirthday}
|
|
183
187
|
triggerClassName="rounded-md"
|
|
184
188
|
/>
|
|
185
|
-
|
|
189
|
+
{birthdayGiftOn && (
|
|
190
|
+
<p className="text-muted-foreground mt-1 text-xs">{tc('birthdayGiftNote')}</p>
|
|
191
|
+
)}
|
|
186
192
|
</div>
|
|
187
193
|
<p className="text-muted-foreground truncate text-sm">{profile.email}</p>
|
|
188
194
|
<div className="flex items-center gap-2">
|
|
@@ -9,6 +9,7 @@ import { useStoreInfo } from '@/core/providers/store-provider';
|
|
|
9
9
|
import { toBirthdayNumber } from '@/core/lib/birthday';
|
|
10
10
|
import { BirthdayPicker } from '@/components/shared/birthday-picker';
|
|
11
11
|
import { readReferralCookie } from '@/core/lib/referral';
|
|
12
|
+
import { useStoreCapabilities } from '@/core/providers/store-provider';
|
|
12
13
|
|
|
13
14
|
interface RegisterData {
|
|
14
15
|
firstName: string;
|
|
@@ -66,6 +67,20 @@ export function RegisterForm({ onSubmit, error, className }: RegisterFormProps)
|
|
|
66
67
|
const [privacyError, setPrivacyError] = useState(false);
|
|
67
68
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
|
68
69
|
const [acceptsMarketing, setAcceptsMarketing] = useState(false);
|
|
70
|
+
const { capabilities } = useStoreCapabilities();
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether to promise a birthday gift beside the picker.
|
|
74
|
+
*
|
|
75
|
+
* The gate is inverted relative to `ReferralGreeting`, and deliberately so.
|
|
76
|
+
* There, an undecided capability must not suppress a real referral, so only
|
|
77
|
+
* an explicit `false` hides anything. Here the text makes a PROMISE, and a
|
|
78
|
+
* promise may only be printed on an explicit `true`: a store with birthday
|
|
79
|
+
* rewards switched off — which is the default — would otherwise tell every
|
|
80
|
+
* customer a gift is coming and send nothing, and `requireBirthday` can make
|
|
81
|
+
* handing over the date compulsory on top of that.
|
|
82
|
+
*/
|
|
83
|
+
const birthdayGiftOn = capabilities?.features.hasBirthdayRewards === true;
|
|
69
84
|
const [birthMonth, setBirthMonth] = useState('');
|
|
70
85
|
const [birthDay, setBirthDay] = useState('');
|
|
71
86
|
const [birthdayError, setBirthdayError] = useState<string | null>(null);
|
|
@@ -293,7 +308,9 @@ export function RegisterForm({ onSubmit, error, className }: RegisterFormProps)
|
|
|
293
308
|
required={birthdayRequired}
|
|
294
309
|
invalid={birthdayError !== null}
|
|
295
310
|
/>
|
|
296
|
-
|
|
311
|
+
{birthdayGiftOn && (
|
|
312
|
+
<p className="text-muted-foreground mt-1.5 text-xs">{tc('birthdayGiftNote')}</p>
|
|
313
|
+
)}
|
|
297
314
|
{birthdayError && <p className="text-destructive mt-1 text-xs">{birthdayError}</p>}
|
|
298
315
|
</div>
|
|
299
316
|
|
|
@@ -12,7 +12,7 @@ import { useStoreInfo } from '@/core/providers/store-provider';
|
|
|
12
12
|
*
|
|
13
13
|
* There is nothing to configure here and no environment variable to set. The
|
|
14
14
|
* tag ids arrive inside `storeInfo.tracking`, which the backend resolves from
|
|
15
|
-
* the merchant's connected marketplace apps: connecting the Google
|
|
15
|
+
* the merchant's connected marketplace apps: connecting the Google
|
|
16
16
|
* app runs GA4 discovery and the measurement id shows up on its own, likewise
|
|
17
17
|
* the Meta and TikTok pixels. Connect an app in the dashboard and this file
|
|
18
18
|
* starts loading its tag within a minute — no redeploy, no code change.
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
} from 'brainerce';
|
|
13
13
|
import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
|
|
14
14
|
import { resolveDisplayPrice, type DisplayPrice } from '@/core/lib/display-price';
|
|
15
|
+
import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
|
|
15
16
|
import { resolveStockInfo } from '@/core/lib/kit';
|
|
16
17
|
import { useCart, useStoreInfo } from '@/core/providers/store-provider';
|
|
17
18
|
import { trackAddToCart, trackProductView } from '@/core/lib/tracking';
|
|
@@ -74,6 +75,16 @@ export interface UseProductPageResult {
|
|
|
74
75
|
/** Add-to-cart state machine. */
|
|
75
76
|
addingToCart: boolean;
|
|
76
77
|
addedMessage: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Set when the server refused the add, cleared on the next attempt.
|
|
80
|
+
*
|
|
81
|
+
* ⛔ Render this OUTSIDE any `modifierGroups.length > 0` block. `modifierError`
|
|
82
|
+
* below lives inside one in every design, which is correct for a modifier
|
|
83
|
+
* message and useless here: most products carry no modifier groups, so a
|
|
84
|
+
* refusal shown there would render nothing at all and the shopper would tap
|
|
85
|
+
* "Add to cart" and watch it silently reset.
|
|
86
|
+
*/
|
|
87
|
+
addToCartError: AddToCartError | null;
|
|
77
88
|
handleAddToCart: () => Promise<void>;
|
|
78
89
|
/** Customization (buyer input) sub-contract. */
|
|
79
90
|
customizationFields: ProductCustomizationField[];
|
|
@@ -115,6 +126,7 @@ export function useProductPage(initialProduct: Product): UseProductPageResult {
|
|
|
115
126
|
const [quantity, setQuantity] = useState(1);
|
|
116
127
|
const [addingToCart, setAddingToCart] = useState(false);
|
|
117
128
|
const [addedMessage, setAddedMessage] = useState(false);
|
|
129
|
+
const [addToCartError, setAddToCartError] = useState<AddToCartError | null>(null);
|
|
118
130
|
const customizationFields = product.customizationFields ?? [];
|
|
119
131
|
const [customizationValues, setCustomizationValues] = useState<CustomizationValues>(() => {
|
|
120
132
|
const initial: CustomizationValues = {};
|
|
@@ -250,6 +262,10 @@ export function useProductPage(initialProduct: Product): UseProductPageResult {
|
|
|
250
262
|
async function handleAddToCart() {
|
|
251
263
|
if (!product || addingToCart) return;
|
|
252
264
|
|
|
265
|
+
// Clear the previous refusal before re-trying, so a stale "cart is full"
|
|
266
|
+
// does not sit under a button that has since succeeded.
|
|
267
|
+
setAddToCartError(null);
|
|
268
|
+
|
|
253
269
|
if (customizationFields.length > 0) {
|
|
254
270
|
const errs = validateCustomization(customizationFields, customizationValues);
|
|
255
271
|
if (Object.keys(errs).length > 0) {
|
|
@@ -305,6 +321,11 @@ export function useProductPage(initialProduct: Product): UseProductPageResult {
|
|
|
305
321
|
if (e?.details?.code === 'MODIFIER_VALIDATION_FAILED' && validationErrors?.length) {
|
|
306
322
|
setModifierError(validationErrors.map((v) => v.message).join('; '));
|
|
307
323
|
} else {
|
|
324
|
+
// Everything else used to stop at `console.error`, which the shopper
|
|
325
|
+
// cannot read: the button spun, reset, and the item never appeared.
|
|
326
|
+
// The commonest cause is now the 50-line cart cap, which is entirely
|
|
327
|
+
// actionable, so give the UI a code to render and keep the log for us.
|
|
328
|
+
setAddToCartError(toAddToCartError(err));
|
|
308
329
|
console.error('Failed to add to cart:', err);
|
|
309
330
|
}
|
|
310
331
|
} finally {
|
|
@@ -330,6 +351,7 @@ export function useProductPage(initialProduct: Product): UseProductPageResult {
|
|
|
330
351
|
setQuantity,
|
|
331
352
|
addingToCart,
|
|
332
353
|
addedMessage,
|
|
354
|
+
addToCartError,
|
|
333
355
|
handleAddToCart,
|
|
334
356
|
customizationFields,
|
|
335
357
|
customizationValues,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Add-to-cart refusals the shopper has to be told about.
|
|
3
|
+
*
|
|
4
|
+
* Lives in `core/` and returns a CODE, never a message: `ui/` owns the copy, so
|
|
5
|
+
* each design maps this to its own `productDetail.*` string rather than showing
|
|
6
|
+
* the raw API sentence. Shared by the product page hook and the grid quick-add.
|
|
7
|
+
*/
|
|
8
|
+
export type AddToCartError = 'CART_FULL' | 'FAILED';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The API's stable, machine-readable error code for a failed call, or
|
|
12
|
+
* `undefined` when the failure carried none (a network drop, a timeout, an
|
|
13
|
+
* older backend).
|
|
14
|
+
*
|
|
15
|
+
* Where it comes from: the API answers an error as
|
|
16
|
+
* `{ statusCode, code, message, details?, timestamp, path }`, and the SDK hands
|
|
17
|
+
* that whole body back as `BrainerceError.details`. So the code lives one level
|
|
18
|
+
* in, at `err.details.code` — not on the error itself. Read it rather than the
|
|
19
|
+
* message: the message is human-readable prose that changes freely (and is
|
|
20
|
+
* English regardless of the shopper's language), the code does not.
|
|
21
|
+
*/
|
|
22
|
+
export function getErrorCode(err: unknown): string | undefined {
|
|
23
|
+
const body = (err as { details?: { code?: unknown } } | null)?.details;
|
|
24
|
+
return typeof body?.code === 'string' ? body.code : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A storefront cart holds at most 50 DISTINCT product lines. The 51st add is
|
|
29
|
+
* refused with `400 CART_LINE_LIMIT_REACHED`.
|
|
30
|
+
*
|
|
31
|
+
* The cap applies to storefront traffic only (`storeId` and `vc_*` modes). It
|
|
32
|
+
* does NOT block quantity changes or removals, so "remove something first" is
|
|
33
|
+
* advice the shopper can actually act on.
|
|
34
|
+
*
|
|
35
|
+
* The message match is a FALLBACK, kept only for the window where a storefront
|
|
36
|
+
* built from this template talks to a backend older than the code. Once the
|
|
37
|
+
* backend carrying `CART_LINE_LIMIT_REACHED` is live everywhere, the regex can
|
|
38
|
+
* go: every storefront talks to the current API, so the code is always there.
|
|
39
|
+
* It cannot produce a false `CART_FULL` in the meantime, which is why it costs
|
|
40
|
+
* nothing to keep until then.
|
|
41
|
+
*
|
|
42
|
+
* Anything unrecognised falls through to `FAILED`, which is still a visible
|
|
43
|
+
* message rather than the silence this replaced.
|
|
44
|
+
*/
|
|
45
|
+
export function toAddToCartError(err: unknown): AddToCartError {
|
|
46
|
+
if (getErrorCode(err) === 'CART_LINE_LIMIT_REACHED') return 'CART_FULL';
|
|
47
|
+
const message = (err as { message?: string } | null)?.message ?? '';
|
|
48
|
+
return /cart can hold at most/i.test(message) ? 'CART_FULL' : 'FAILED';
|
|
49
|
+
}
|
|
@@ -6,6 +6,7 @@ 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
10
|
import { Badge } from '@/components/ui/badge';
|
|
10
11
|
import { Button } from '@/components/ui/button';
|
|
11
12
|
import { Card } from '@/components/ui/card';
|
|
@@ -21,8 +22,10 @@ interface CartBundleOfferCardProps {
|
|
|
21
22
|
|
|
22
23
|
export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBundleOfferCardProps) {
|
|
23
24
|
const t = useTranslations('cart');
|
|
25
|
+
const tp = useTranslations('productDetail');
|
|
24
26
|
const currency = useCurrency();
|
|
25
27
|
const [adding, setAdding] = useState(false);
|
|
28
|
+
const [addError, setAddError] = useState<AddToCartError | null>(null);
|
|
26
29
|
|
|
27
30
|
const offered = offer.offeredProducts;
|
|
28
31
|
|
|
@@ -35,6 +38,7 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
|
|
|
35
38
|
|
|
36
39
|
async function handleAdd() {
|
|
37
40
|
if (adding) return;
|
|
41
|
+
setAddError(null);
|
|
38
42
|
try {
|
|
39
43
|
setAdding(true);
|
|
40
44
|
const { getClient } = await import('@/core/lib/brainerce');
|
|
@@ -44,6 +48,10 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
|
|
|
44
48
|
await client.addBundleToCart(cartId, offer.id);
|
|
45
49
|
onAdd();
|
|
46
50
|
} catch (err) {
|
|
51
|
+
// The cart page is where a shopper is most likely to be AT the 50-line
|
|
52
|
+
// cap, and a bundle adds one line per offered product. Logging alone
|
|
53
|
+
// left the button spinning back to idle with nothing added.
|
|
54
|
+
setAddError(toAddToCartError(err));
|
|
47
55
|
console.error('Failed to add bundle:', err);
|
|
48
56
|
} finally {
|
|
49
57
|
setAdding(false);
|
|
@@ -112,6 +120,16 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
|
|
|
112
120
|
{adding ? t('addingBundle') : t('addBundleItem')}
|
|
113
121
|
</Button>
|
|
114
122
|
</div>
|
|
123
|
+
|
|
124
|
+
{/*
|
|
125
|
+
Why the bundle was refused. Without this the button resets and
|
|
126
|
+
nothing is added, which reads as a broken button.
|
|
127
|
+
*/}
|
|
128
|
+
{addError && (
|
|
129
|
+
<p role="alert" className="text-destructive mt-2 text-xs">
|
|
130
|
+
{addError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}
|
|
131
|
+
</p>
|
|
132
|
+
)}
|
|
115
133
|
</Card>
|
|
116
134
|
);
|
|
117
135
|
}
|
|
@@ -10,6 +10,7 @@ import { useTranslations } from '@/core/lib/translations';
|
|
|
10
10
|
import { useCurrency } from '@/core/lib/use-currency';
|
|
11
11
|
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
12
12
|
import { cn } from '@/core/lib/utils';
|
|
13
|
+
import { getErrorCode } from '@/core/lib/add-to-cart-error';
|
|
13
14
|
import { OrderCustomizations } from '@/components/account/order-customizations';
|
|
14
15
|
|
|
15
16
|
interface CartItemProps {
|
|
@@ -18,12 +19,28 @@ interface CartItemProps {
|
|
|
18
19
|
className?: string;
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Which `cart.*` string each refusal shows. A map rather than a ternary chain:
|
|
24
|
+
* four outcomes nest badly, and this keeps the copy next to the reason.
|
|
25
|
+
*/
|
|
26
|
+
const LINE_ERROR_KEYS = {
|
|
27
|
+
NOT_ENOUGH_STOCK: 'notEnoughStock',
|
|
28
|
+
ITEM_UNAVAILABLE: 'itemUnavailable',
|
|
29
|
+
UPDATE_FAILED: 'quantityUpdateFailed',
|
|
30
|
+
REMOVE_FAILED: 'removeFailed',
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
21
33
|
export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
22
34
|
const t = useTranslations('common');
|
|
23
35
|
const td = useTranslations('productDetail');
|
|
36
|
+
const tc = useTranslations('cart');
|
|
24
37
|
const currency = useCurrency();
|
|
25
38
|
const [updating, setUpdating] = useState(false);
|
|
26
39
|
const [removing, setRemoving] = useState(false);
|
|
40
|
+
// Why the last quantity change or removal did not take. Both calls used to
|
|
41
|
+
// fail into console.error alone: the row snapped back to the quantity it
|
|
42
|
+
// already had, or stayed put after Remove, and nothing on screen said why.
|
|
43
|
+
const [lineError, setLineError] = useState<keyof typeof LINE_ERROR_KEYS | null>(null);
|
|
27
44
|
|
|
28
45
|
const productName = item.product.name;
|
|
29
46
|
const imageUrl = getCartItemImage(item);
|
|
@@ -45,12 +62,28 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
45
62
|
async function handleQuantityChange(newQuantity: number) {
|
|
46
63
|
if (newQuantity < 1 || updating) return;
|
|
47
64
|
|
|
65
|
+
setLineError(null);
|
|
48
66
|
try {
|
|
49
67
|
setUpdating(true);
|
|
50
68
|
const client = getClient();
|
|
51
69
|
await client.smartUpdateCartItem(item.productId, newQuantity, item.variantId || undefined);
|
|
52
70
|
onUpdate();
|
|
53
71
|
} catch (err) {
|
|
72
|
+
// Two of these refusals are PERMANENT, and "please try again" would send
|
|
73
|
+
// the shopper round a loop that cannot succeed: INSUFFICIENT_STOCK (more
|
|
74
|
+
// than the inventory left) and PRODUCT_UNAVAILABLE (the line cannot be
|
|
75
|
+
// bought at all any more). Both get their own sentence; everything else
|
|
76
|
+
// is a blip worth one more tap. The 50-line cart cap is NOT a cause here
|
|
77
|
+
// at all, it only ever refuses a brand new line. Keep the log: the
|
|
78
|
+
// shopper gets the sentence, the developer still needs the detail.
|
|
79
|
+
const code = getErrorCode(err);
|
|
80
|
+
setLineError(
|
|
81
|
+
code === 'INSUFFICIENT_STOCK'
|
|
82
|
+
? 'NOT_ENOUGH_STOCK'
|
|
83
|
+
: code === 'PRODUCT_UNAVAILABLE'
|
|
84
|
+
? 'ITEM_UNAVAILABLE'
|
|
85
|
+
: 'UPDATE_FAILED'
|
|
86
|
+
);
|
|
54
87
|
console.error('Failed to update quantity:', err);
|
|
55
88
|
} finally {
|
|
56
89
|
setUpdating(false);
|
|
@@ -60,12 +93,17 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
60
93
|
async function handleRemove() {
|
|
61
94
|
if (removing) return;
|
|
62
95
|
|
|
96
|
+
setLineError(null);
|
|
63
97
|
try {
|
|
64
98
|
setRemoving(true);
|
|
65
99
|
const client = getClient();
|
|
66
100
|
await client.smartRemoveFromCart(item.productId, item.variantId || undefined);
|
|
67
101
|
onUpdate();
|
|
68
102
|
} catch (err) {
|
|
103
|
+
// Nothing is lost when a remove fails, so the line is still there and
|
|
104
|
+
// still correct. Say so anyway: a Remove that visibly does nothing reads
|
|
105
|
+
// as a broken button.
|
|
106
|
+
setLineError('REMOVE_FAILED');
|
|
69
107
|
console.error('Failed to remove item:', err);
|
|
70
108
|
} finally {
|
|
71
109
|
setRemoving(false);
|
|
@@ -161,6 +199,17 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
161
199
|
{removing ? t('removing') : t('remove')}
|
|
162
200
|
</button>
|
|
163
201
|
</div>
|
|
202
|
+
|
|
203
|
+
{/*
|
|
204
|
+
Why the quantity change or the removal did not take. Keep it inside
|
|
205
|
+
this line's own column: it describes THIS row, and a shopper with ten
|
|
206
|
+
rows on screen has to be able to tell which one refused.
|
|
207
|
+
*/}
|
|
208
|
+
{lineError && (
|
|
209
|
+
<p role="alert" className="text-destructive mt-2 text-xs">
|
|
210
|
+
{tc(LINE_ERROR_KEYS[lineError])}
|
|
211
|
+
</p>
|
|
212
|
+
)}
|
|
164
213
|
</div>
|
|
165
214
|
|
|
166
215
|
{/* Line total */}
|