create-brainerce-store 1.87.0 → 1.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/index.js +9 -2
  2. package/messages/en.json +10 -1
  3. package/messages/he.json +10 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +296 -296
  6. package/templates/nextjs/base/src/app/checkout/page.tsx +89 -6
  7. package/templates/nextjs/base/src/app/products/layout.tsx.ejs +18 -0
  8. package/templates/nextjs/base/src/components/auth/register-form.tsx +381 -375
  9. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +14 -8
  10. package/templates/nextjs/base/src/core/hooks/use-page-exists.ts +37 -0
  11. package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +27 -2
  12. package/templates/nextjs/base/src/core/lib/brainerce.server.ts +121 -121
  13. package/templates/nextjs/base/src/core/lib/coupon-error.ts +29 -0
  14. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +6 -2
  15. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +6 -4
  16. package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +138 -134
  17. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +10 -3
  18. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +5 -1
  19. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +2 -2
  20. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +6 -2
  21. package/templates/nextjs/base/src/ui/product/product-card.tsx +6 -2
  22. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +4 -36
  23. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +6 -2
  24. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +6 -4
  25. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +146 -142
  26. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +10 -3
  27. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +6 -2
  28. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +6 -2
  29. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +3 -28
  30. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +6 -2
  31. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +6 -4
  32. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +123 -119
  33. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +4 -3
  34. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +5 -1
  35. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
  36. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +6 -2
  37. package/templates/nextjs/ui-canvas/product/product-card.tsx +6 -2
  38. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +3 -28
  39. package/templates/nextjs/base/src/app/products/layout.tsx +0 -18
@@ -7,6 +7,7 @@ import { cn } from '@/core/lib/utils';
7
7
  import { isValidEmail } from '@/core/lib/validation';
8
8
  import { getClient } from '@/core/lib/brainerce';
9
9
  import { useRegion } from '@/core/providers/store-provider';
10
+ import { usePageExists } from '@/core/hooks/use-page-exists';
10
11
 
11
12
  // Debounce address-suggestion calls rather than firing on every keystroke —
12
13
  // both to avoid a jumpy dropdown and to keep autocomplete-session call volume
@@ -65,6 +66,7 @@ export function CheckoutForm({
65
66
  });
66
67
  const [errors, setErrors] = useState<Record<string, string>>({});
67
68
  const [privacyAccepted, setPrivacyAccepted] = useState(false);
69
+ const hasPrivacyPage = usePageExists('privacy');
68
70
  const [acceptsMarketing, setAcceptsMarketing] = useState(false);
69
71
  const [saveDetails, setSaveDetails] = useState(true);
70
72
  const t = useTranslations('checkoutForm');
@@ -568,14 +570,18 @@ export function CheckoutForm({
568
570
  />
569
571
  <span className="text-muted-foreground text-sm">
570
572
  {t('privacyAcceptPrefix')}{' '}
571
- <a
572
- href="/privacy"
573
- target="_blank"
574
- rel="noopener noreferrer"
575
- className="text-primary underline underline-offset-2"
576
- >
577
- {t('privacyPolicyLink')}
578
- </a>{' '}
573
+ {hasPrivacyPage === false ? (
574
+ <span className="text-foreground">{t('privacyPolicyLink')}</span>
575
+ ) : (
576
+ <a
577
+ href="/pages/privacy"
578
+ target="_blank"
579
+ rel="noopener noreferrer"
580
+ className="text-primary underline underline-offset-2"
581
+ >
582
+ {t('privacyPolicyLink')}
583
+ </a>
584
+ )}{' '}
579
585
  <span className="text-destructive">*</span>
580
586
  </span>
581
587
  </label>
@@ -0,0 +1,37 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { getClient } from '@/core/lib/brainerce';
5
+
6
+ /**
7
+ * Whether the merchant has published a PAGE with this slug (Content → Page).
8
+ *
9
+ * `false` only on a genuine 404. While loading, or if the lookup fails for any
10
+ * other reason, it stays `null`, so callers keep rendering the link: a flaky
11
+ * network must not strip the policy link off a consent checkbox.
12
+ *
13
+ * Why it exists: a brand-new store has no `privacy` page, and the required
14
+ * privacy checkbox at register and checkout linked straight to a 404
15
+ * (2026-10-04 QA). Callers show the policy name as plain text until the
16
+ * merchant creates the page.
17
+ */
18
+ export function usePageExists(slug: string): boolean | null {
19
+ const [exists, setExists] = useState<boolean | null>(null);
20
+
21
+ useEffect(() => {
22
+ let cancelled = false;
23
+ getClient()
24
+ .content.page.getBySlug(slug)
25
+ .then((page) => {
26
+ if (!cancelled) setExists(page !== null);
27
+ })
28
+ .catch(() => {
29
+ // Not a 404: unknown, keep the link.
30
+ });
31
+ return () => {
32
+ cancelled = true;
33
+ };
34
+ }, [slug]);
35
+
36
+ return exists;
37
+ }
@@ -5,7 +5,7 @@
5
5
  * each design maps this to its own `productDetail.*` string rather than showing
6
6
  * the raw API sentence. Shared by the product page hook and the grid quick-add.
7
7
  */
8
- export type AddToCartError = 'CART_FULL' | 'FAILED';
8
+ export type AddToCartError = 'CART_FULL' | 'NOT_ENOUGH_STOCK' | 'ITEM_UNAVAILABLE' | 'FAILED';
9
9
 
10
10
  /**
11
11
  * The API's stable, machine-readable error code for a failed call, or
@@ -43,7 +43,32 @@ export function getErrorCode(err: unknown): string | undefined {
43
43
  * message rather than the silence this replaced.
44
44
  */
45
45
  export function toAddToCartError(err: unknown): AddToCartError {
46
- if (getErrorCode(err) === 'CART_LINE_LIMIT_REACHED') return 'CART_FULL';
46
+ const code = getErrorCode(err);
47
+ if (code === 'CART_LINE_LIMIT_REACHED') return 'CART_FULL';
48
+ // Two PERMANENT refusals: "please try again" would send the shopper round a
49
+ // loop that cannot succeed, so each gets its own sentence.
50
+ if (code === 'INSUFFICIENT_STOCK') return 'NOT_ENOUGH_STOCK';
51
+ if (code === 'PRODUCT_UNAVAILABLE') return 'ITEM_UNAVAILABLE';
47
52
  const message = (err as { message?: string } | null)?.message ?? '';
48
53
  return /cart can hold at most/i.test(message) ? 'CART_FULL' : 'FAILED';
49
54
  }
55
+
56
+ /**
57
+ * The `productDetail.*` message key for an add-to-cart refusal. Every surface
58
+ * renders `t(addToCartErrorMessageKey(error))`, so a new member of
59
+ * {@link AddToCartError} cannot silently fall through to "please try again".
60
+ */
61
+ export function addToCartErrorMessageKey(
62
+ error: AddToCartError
63
+ ): 'cartFull' | 'addNotEnoughStock' | 'addItemUnavailable' | 'addToCartFailed' {
64
+ switch (error) {
65
+ case 'CART_FULL':
66
+ return 'cartFull';
67
+ case 'NOT_ENOUGH_STOCK':
68
+ return 'addNotEnoughStock';
69
+ case 'ITEM_UNAVAILABLE':
70
+ return 'addItemUnavailable';
71
+ default:
72
+ return 'addToCartFailed';
73
+ }
74
+ }
@@ -1,121 +1,121 @@
1
- import { BrainerceClient } from 'brainerce';
2
- import { cache } from 'react';
3
- import { SALES_CHANNEL_ID } from '@/core/lib/brainerce';
4
- import { pickPublicStoreInfo, type PublicStoreInfo } from '@/core/lib/store-info';
5
- import { getRequestOrigin } from '@/core/lib/site-url';
6
-
7
- /**
8
- * SERVER-ONLY half of the SDK wiring. Kept apart from `brainerce.ts` on
9
- * purpose: everything here reaches `next/headers` (to resolve the request's
10
- * origin), and ~30 client components import `getClient` from `brainerce.ts`.
11
- * A single shared module would drag `next/headers` into the client bundle and
12
- * fail the build outright — which is exactly what happened when these two
13
- * functions still lived there.
14
- *
15
- * Import from here in Server Components, `generateMetadata`, route handlers,
16
- * `sitemap.ts` / `robots.ts`. Never from a `'use client'` file.
17
- */
18
-
19
- /**
20
- * Server-side client — calls the backend directly (no proxy needed for public
21
- * data). Used by Server Components for SSR data fetching.
22
- *
23
- * ⛔ ASYNC on purpose. The backend compares the `Origin` we send against the
24
- * domain configured on the sales channel, and a LIVE channel rejects both a
25
- * mismatched Origin AND a missing one with 403. The correct value is only
26
- * knowable from the request when no `SITE_URL` is configured — the norm for
27
- * AI-builder deploys — so resolving it needs an await. This previously read
28
- * `process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'`, which sent
29
- * localhost from every such deploy, so every SSR call failed the moment the
30
- * channel went Live.
31
- */
32
- export async function getServerClient(locale?: string): Promise<BrainerceClient> {
33
- const apiUrl = process.env.BRAINERCE_API_URL || 'https://api.brainerce.com';
34
- const client = new BrainerceClient({
35
- salesChannelId: SALES_CHANNEL_ID,
36
- baseUrl: apiUrl,
37
- origin: await getRequestOrigin(),
38
- });
39
- if (locale) {
40
- client.setLocale(locale);
41
- }
42
- return client;
43
- }
44
-
45
- /**
46
- * Reads a fetch `Response` body as text, self-healing an observed runtime bug:
47
- * on Next.js 15.5.26, a `fetch()` made from a Route Handler or Server
48
- * Component can return a Response whose body is still gzip-compressed bytes,
49
- * even though an identical request (same URL, same headers) made via plain
50
- * Node `fetch()` outside Next decompresses correctly — confirmed by diffing
51
- * the raw response bytes both ways. The exact point inside Next's
52
- * server-side fetch instrumentation that causes this isn't confirmed, only
53
- * the externally observable symptom is. Calling `.text()`/`.json()` on a
54
- * Response like that UTF-8-decodes still-gzipped bytes — lossy (replacement
55
- * characters) and unparseable as JSON.
56
- *
57
- * A correctly-behaving fetch (browsers; plain Node; this bug's absence) never
58
- * hands back bytes that still start with gzip's magic number (`1f 8b`) — real
59
- * compression has already been undone by the time a body reaches here — so
60
- * checking for it before ever decoding as text can't misfire into
61
- * double-decompressing an already-plain body.
62
- *
63
- * This only works because the fetch below pins `Accept-Encoding: gzip`
64
- * instead of the runtime default (which also offers Brotli) — Brotli has no
65
- * reliable magic-number signature and no Web Streams API decoder, so this
66
- * self-healing has no fallback for it. That header is correctness-critical
67
- * here, not a tuning choice: reverting it to the runtime default reintroduces
68
- * this exact corruption, just for Brotli-compressed responses instead of
69
- * gzip ones.
70
- */
71
- async function readBackendBody(response: Response): Promise<string> {
72
- const buffer = new Uint8Array(await response.arrayBuffer());
73
- if (buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b) {
74
- const decompressed = new Blob([buffer])
75
- .stream()
76
- .pipeThrough(new DecompressionStream('gzip'));
77
- return new TextDecoder('utf-8').decode(await new Response(decompressed).arrayBuffer());
78
- }
79
- return new TextDecoder('utf-8').decode(buffer);
80
- }
81
-
82
- /**
83
- * Server-side cached fetch of store info, used to seed `<StoreProvider>` at
84
- * SSR so client components don't render with `null` storeInfo and bake the
85
- * wrong currency symbol into the HTML Googlebot indexes.
86
- *
87
- * Two cache layers:
88
- * - React `cache()` dedupes calls within a single request render so the
89
- * layout + `generateMetadata` share one fetch.
90
- * - Next.js `fetch({ next: { revalidate, tags } })` caches the response
91
- * across requests for ~60s and lets the dashboard bust the cache via
92
- * `revalidateTag('store-info')` on store-config save.
93
- *
94
- * Returns `null` on hard failure so the provider's client-side `useEffect`
95
- * fallback can take over — behaviour identical to pre-SSR-hydration.
96
- *
97
- * Bypasses {@link getServerClient} on purpose: the SDK's internal `fetch`
98
- * does not forward `next:` options, so it cannot participate in the Next.js
99
- * Data Cache today. Switch back to the SDK once it accepts `fetchOptions`.
100
- */
101
- export const fetchStoreInfo = cache(async (locale?: string): Promise<PublicStoreInfo | null> => {
102
- const apiUrl = process.env.BRAINERCE_API_URL || 'https://api.brainerce.com';
103
- const siteOrigin = await getRequestOrigin();
104
- const url = `${apiUrl}/api/vc/${encodeURIComponent(SALES_CHANNEL_ID)}/info`;
105
- try {
106
- const res = await fetch(url, {
107
- headers: {
108
- Origin: siteOrigin,
109
- // Pinned to gzip — see readBackendBody() for why.
110
- 'Accept-Encoding': 'gzip',
111
- ...(locale ? { 'Accept-Language': locale } : {}),
112
- },
113
- next: { revalidate: 60, tags: ['store-info'] },
114
- });
115
- if (!res.ok) return null;
116
- const text = await readBackendBody(res);
117
- return pickPublicStoreInfo(text ? JSON.parse(text) : {});
118
- } catch {
119
- return null;
120
- }
121
- });
1
+ import { BrainerceClient } from 'brainerce';
2
+ import { cache } from 'react';
3
+ import { SALES_CHANNEL_ID } from '@/core/lib/brainerce';
4
+ import { pickPublicStoreInfo, type PublicStoreInfo } from '@/core/lib/store-info';
5
+ import { getRequestOrigin } from '@/core/lib/site-url';
6
+
7
+ /**
8
+ * SERVER-ONLY half of the SDK wiring. Kept apart from `brainerce.ts` on
9
+ * purpose: everything here reaches `next/headers` (to resolve the request's
10
+ * origin), and ~30 client components import `getClient` from `brainerce.ts`.
11
+ * A single shared module would drag `next/headers` into the client bundle and
12
+ * fail the build outright — which is exactly what happened when these two
13
+ * functions still lived there.
14
+ *
15
+ * Import from here in Server Components, `generateMetadata`, route handlers,
16
+ * `sitemap.ts` / `robots.ts`. Never from a `'use client'` file.
17
+ */
18
+
19
+ /**
20
+ * Server-side client — calls the backend directly (no proxy needed for public
21
+ * data). Used by Server Components for SSR data fetching.
22
+ *
23
+ * ⛔ ASYNC on purpose. The backend compares the `Origin` we send against the
24
+ * domain configured on the sales channel, and a LIVE channel rejects both a
25
+ * mismatched Origin AND a missing one with 403. The correct value is only
26
+ * knowable from the request when no `SITE_URL` is configured — the norm for
27
+ * AI-builder deploys — so resolving it needs an await. This previously read
28
+ * `process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'`, which sent
29
+ * localhost from every such deploy, so every SSR call failed the moment the
30
+ * channel went Live.
31
+ */
32
+ export async function getServerClient(locale?: string): Promise<BrainerceClient> {
33
+ const apiUrl = process.env.BRAINERCE_API_URL || 'https://api.brainerce.com';
34
+ const client = new BrainerceClient({
35
+ salesChannelId: SALES_CHANNEL_ID,
36
+ baseUrl: apiUrl,
37
+ origin: await getRequestOrigin(),
38
+ });
39
+ if (locale) {
40
+ client.setLocale(locale);
41
+ }
42
+ return client;
43
+ }
44
+
45
+ /**
46
+ * Reads a fetch `Response` body as text, self-healing an observed runtime bug:
47
+ * on Next.js 15.5.26, a `fetch()` made from a Route Handler or Server
48
+ * Component can return a Response whose body is still gzip-compressed bytes,
49
+ * even though an identical request (same URL, same headers) made via plain
50
+ * Node `fetch()` outside Next decompresses correctly — confirmed by diffing
51
+ * the raw response bytes both ways. The exact point inside Next's
52
+ * server-side fetch instrumentation that causes this isn't confirmed, only
53
+ * the externally observable symptom is. Calling `.text()`/`.json()` on a
54
+ * Response like that UTF-8-decodes still-gzipped bytes — lossy (replacement
55
+ * characters) and unparseable as JSON.
56
+ *
57
+ * A correctly-behaving fetch (browsers; plain Node; this bug's absence) never
58
+ * hands back bytes that still start with gzip's magic number (`1f 8b`) — real
59
+ * compression has already been undone by the time a body reaches here — so
60
+ * checking for it before ever decoding as text can't misfire into
61
+ * double-decompressing an already-plain body.
62
+ *
63
+ * This only works because the fetch below pins `Accept-Encoding: gzip`
64
+ * instead of the runtime default (which also offers Brotli) — Brotli has no
65
+ * reliable magic-number signature and no Web Streams API decoder, so this
66
+ * self-healing has no fallback for it. That header is correctness-critical
67
+ * here, not a tuning choice: reverting it to the runtime default reintroduces
68
+ * this exact corruption, just for Brotli-compressed responses instead of
69
+ * gzip ones.
70
+ */
71
+ async function readBackendBody(response: Response): Promise<string> {
72
+ const buffer = new Uint8Array(await response.arrayBuffer());
73
+ if (buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b) {
74
+ const decompressed = new Blob([buffer])
75
+ .stream()
76
+ .pipeThrough(new DecompressionStream('gzip'));
77
+ return new TextDecoder('utf-8').decode(await new Response(decompressed).arrayBuffer());
78
+ }
79
+ return new TextDecoder('utf-8').decode(buffer);
80
+ }
81
+
82
+ /**
83
+ * Server-side cached fetch of store info, used to seed `<StoreProvider>` at
84
+ * SSR so client components don't render with `null` storeInfo and bake the
85
+ * wrong currency symbol into the HTML Googlebot indexes.
86
+ *
87
+ * Two cache layers:
88
+ * - React `cache()` dedupes calls within a single request render so the
89
+ * layout + `generateMetadata` share one fetch.
90
+ * - Next.js `fetch({ next: { revalidate, tags } })` caches the response
91
+ * across requests for ~60s and lets the dashboard bust the cache via
92
+ * `revalidateTag('store-info')` on store-config save.
93
+ *
94
+ * Returns `null` on hard failure so the provider's client-side `useEffect`
95
+ * fallback can take over — behaviour identical to pre-SSR-hydration.
96
+ *
97
+ * Bypasses {@link getServerClient} on purpose: the SDK's internal `fetch`
98
+ * does not forward `next:` options, so it cannot participate in the Next.js
99
+ * Data Cache today. Switch back to the SDK once it accepts `fetchOptions`.
100
+ */
101
+ export const fetchStoreInfo = cache(async (locale?: string): Promise<PublicStoreInfo | null> => {
102
+ const apiUrl = process.env.BRAINERCE_API_URL || 'https://api.brainerce.com';
103
+ const siteOrigin = await getRequestOrigin();
104
+ const url = `${apiUrl}/api/vc/${encodeURIComponent(SALES_CHANNEL_ID)}/info`;
105
+ try {
106
+ const res = await fetch(url, {
107
+ headers: {
108
+ Origin: siteOrigin,
109
+ // Pinned to gzip — see readBackendBody() for why.
110
+ 'Accept-Encoding': 'gzip',
111
+ ...(locale ? { 'Accept-Language': locale } : {}),
112
+ },
113
+ next: { revalidate: 60, tags: ['store-info'] },
114
+ });
115
+ if (!res.ok) return null;
116
+ const text = await readBackendBody(res);
117
+ return pickPublicStoreInfo(text ? JSON.parse(text) : {});
118
+ } catch {
119
+ return null;
120
+ }
121
+ });
@@ -0,0 +1,29 @@
1
+ import { getErrorCode } from '@/core/lib/add-to-cart-error';
2
+
3
+ /**
4
+ * Message keys (under `coupon.*`) for the stable codes `applyCoupon` /
5
+ * `applyCheckoutCoupon` return. The API's `message` is English whatever the
6
+ * shopper's language, so a known code is always shown from our own messages.
7
+ * A refusal with no code (a checkout past its editable stage, say) returns
8
+ * `null` and the caller falls back to the API's message.
9
+ */
10
+ const COUPON_ERROR_KEYS = {
11
+ COUPON_NOT_FOUND: 'invalidCode',
12
+ COUPON_NOT_ACTIVE: 'notActive',
13
+ COUPON_EXPIRED: 'expired',
14
+ COUPON_USAGE_LIMIT_REACHED: 'usageLimitReached',
15
+ COUPON_PER_CUSTOMER_LIMIT_REACHED: 'perCustomerLimitReached',
16
+ COUPON_MINIMUM_NOT_MET: 'minimumNotMet',
17
+ COUPON_REGION_RESTRICTED: 'regionRestricted',
18
+ COUPON_NO_ELIGIBLE_ITEMS: 'noEligibleItems',
19
+ } as const;
20
+
21
+ export type CouponErrorKey = (typeof COUPON_ERROR_KEYS)[keyof typeof COUPON_ERROR_KEYS];
22
+
23
+ export function couponErrorKey(err: unknown): CouponErrorKey | null {
24
+ const code = getErrorCode(err);
25
+ if (code && code in COUPON_ERROR_KEYS) {
26
+ return COUPON_ERROR_KEYS[code as keyof typeof COUPON_ERROR_KEYS];
27
+ }
28
+ return null;
29
+ }
@@ -6,7 +6,11 @@ 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
+ import {
10
+ addToCartErrorMessageKey,
11
+ toAddToCartError,
12
+ type AddToCartError,
13
+ } from '@/core/lib/add-to-cart-error';
10
14
  import { Badge } from '@/components/ui/badge';
11
15
  import { Button } from '@/components/ui/button';
12
16
  import { Card } from '@/components/ui/card';
@@ -127,7 +131,7 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
127
131
  */}
128
132
  {addError && (
129
133
  <p role="alert" className="text-destructive mt-2 text-xs">
130
- {addError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}
134
+ {tp(addToCartErrorMessageKey(addError))}
131
135
  </p>
132
136
  )}
133
137
  </Card>
@@ -8,7 +8,11 @@ import { formatPrice } from 'brainerce';
8
8
  import { getClient } from '@/core/lib/brainerce';
9
9
  import { useCurrency } from '@/core/lib/use-currency';
10
10
  import { useTranslations } from '@/core/lib/translations';
11
- import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
11
+ import {
12
+ addToCartErrorMessageKey,
13
+ toAddToCartError,
14
+ type AddToCartError,
15
+ } from '@/core/lib/add-to-cart-error';
12
16
  import { Button } from '@/components/ui/button';
13
17
  import { cn } from '@/core/lib/utils';
14
18
 
@@ -198,9 +202,7 @@ export function CartUpgradeBanner({
198
202
  <p role="alert" className="text-destructive mt-1 text-xs">
199
203
  {upgradeError === 'ORIGINAL_NOT_REMOVED'
200
204
  ? t('upgradeOriginalNotRemoved')
201
- : upgradeError === 'CART_FULL'
202
- ? tp('cartFull')
203
- : tp('addToCartFailed')}
205
+ : tp(addToCartErrorMessageKey(upgradeError))}
204
206
  </p>
205
207
  )}
206
208
  </div>