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.
Files changed (34) hide show
  1. package/dist/index.js +168 -10
  2. package/messages/en.json +13 -4
  3. package/messages/he.json +13 -4
  4. package/package.json +10 -1
  5. package/templates/nextjs/base/.eslintrc.json +2 -0
  6. package/templates/nextjs/base/AI-GUIDE.md +32 -2
  7. package/templates/nextjs/base/package.json.ejs +53 -52
  8. package/templates/nextjs/base/scripts/connect.mjs +149 -0
  9. package/templates/nextjs/base/src/app/checkout/page.tsx +18 -0
  10. package/templates/nextjs/base/src/components/account/profile-section.tsx +7 -1
  11. package/templates/nextjs/base/src/components/auth/register-form.tsx +18 -1
  12. package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +1 -1
  13. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +22 -0
  14. package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +49 -0
  15. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +18 -0
  16. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +49 -0
  17. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +96 -2
  18. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +59 -12
  19. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +17 -0
  20. package/templates/nextjs/base/src/ui/product/product-card.tsx +17 -0
  21. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +13 -0
  22. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +18 -0
  23. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +49 -0
  24. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +101 -5
  25. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +17 -0
  26. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +17 -0
  27. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +13 -0
  28. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +16 -0
  29. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +45 -0
  30. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +95 -2
  31. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +50 -8
  32. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +15 -0
  33. package/templates/nextjs/ui-canvas/product/product-card.tsx +15 -0
  34. 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
- <p className="text-muted-foreground mt-1 text-xs">{tc('birthdayGiftNote')}</p>
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
- <p className="text-muted-foreground mt-1.5 text-xs">{tc('birthdayGiftNote')}</p>
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 & YouTube
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 */}